feat(proactive): Proactive Communication Loop + BartokGraph (clean extract of #1)#3
Conversation
…n extract) Extracts the Proactive Communication Loop subsystem from the large feat/proactive-communication-loop-v2 branch (#1) into a focused, reviewable change against current main. Adds: - hermes_cli/bartokgraph.py — on-device three-layer knowledge graph (KNOWLEDGE/CODE/PERSON), credential redaction, file-weighted extraction. last_seen_ts uses each source file's mtime (not build time) so the loop doesn't treat every node as "recently active". - hermes_cli/bartokgraph_adapter.py — graph connection provider. - hermes_cli/proactive_communication_loop.py — synthesis pipeline; silent by default, never sends without a graph connection, daily rate-limited. - hermes_cli/proactive_scheduler.py — per-session flow scheduler, runs in the gateway cron ticker thread. - plugins/bartokgraph/__init__.py — plugin registration. - gateway/run.py — minimal cron-ticker wiring (guarded, opt-in). - tests + fixture for graph, loop, scheduler, smoke. Test robustness: the loop tests seeded conversation history with a fixed epoch timestamp, which ages out of the 72h history window over time and caused test_temporal_bridge_high_score_sends to fail (history loaded empty). Switched to a relative recent timestamp so the seeded history is always inside the window and the send/no-send paths are actually exercised. Verified: 78 proactive tests pass; ruff clean; no new dependencies. Co-authored-by: Cursor <cursoragent@cursor.com>
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-import |
4 |
unresolved-attribute |
1 |
no-matching-overload |
1 |
invalid-argument-type |
1 |
not-iterable |
1 |
First entries
tests/test_proactive_communication_loop.py:8: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/test_proactive_graph.py:10: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/test_proactive_graph.py:232: [unresolved-attribute] unresolved-attribute: Attribute `weight` is not defined on `None` in union `GraphNode | None`
plugins/bartokgraph/__init__.py:39: [unresolved-import] unresolved-import: Module `hermes_cli.bartokgraph_adapter` has no member `_resolve_local_model_provider`
hermes_cli/proactive_scheduler.py:194: [no-matching-overload] no-matching-overload: No overload of function `max` matches arguments
tests/test_proactive_graph.py:255: [invalid-argument-type] invalid-argument-type: Argument to bound method `KnowledgeGraph.add_edge` is incorrect: Expected `str`, found `str | None`
tests/test_proactive_scheduler.py:11: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
hermes_cli/bartokgraph.py:242: [not-iterable] not-iterable: Object of type `list[Pattern[Unknown]] | None` may not be iterable
✅ Fixed issues: none
Unchanged: 4068 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 4 potential issues.
Bugbot Autofix prepared fixes for all 4 issues found in the latest run.
- ✅ Fixed: Plugin imports non-existent function causing ImportError
- Removed the missing adapter function import and export so the bundled plugin imports successfully.
- ✅ Fixed: Regex character class matches wrong characters for require
- Corrected the require capture group to use a negated quote character class.
- ✅ Fixed: Cluster boost loop iterates but checks same topic
- Changed the cluster boost loop to derive a topic id from each active topic instead of reusing the best topic.
- ✅ Fixed: Classify function always returns temporal_bridge for knowledge nodes
- Removed redundant temporal branches and made recent non-person knowledge nodes classify as cross_domain.
Or push these changes by commenting:
@cursor push 64018ec1b1
Preview (64018ec1b1)
diff --git a/hermes_cli/bartokgraph.py b/hermes_cli/bartokgraph.py
--- a/hermes_cli/bartokgraph.py
+++ b/hermes_cli/bartokgraph.py
@@ -480,7 +480,7 @@
# Matches JS require/ES import strings and Python import/from statements
_IMPORT_RE = re.compile(
r"from\s+([\w./][\w./]{2,59})\s+import"
- r"|require\s*\(\s*['\"]([ ^'\"]{2,59})['\"]{1}\)"
+ r"|require\s*\(\s*['\"]([^'\"]{2,59})['\"]{1}\)"
r"|import\s+([\w.]{2,59})",
re.MULTILINE,
)
diff --git a/hermes_cli/bartokgraph_adapter.py b/hermes_cli/bartokgraph_adapter.py
--- a/hermes_cli/bartokgraph_adapter.py
+++ b/hermes_cli/bartokgraph_adapter.py
@@ -222,8 +222,8 @@
# Boost for cluster alignment: if today's topic is in the same cluster
# as this dormant node, that's structurally significant
cluster_boost = 1.0
- for tokens in topic_tokens:
- topic_id = _to_node_id(best_topic)
+ for topic in active_topics[:8]:
+ topic_id = _to_node_id(topic)
if topic_id in self._cluster_map and node.id in self._cluster_map:
if self._cluster_map[topic_id] == self._cluster_map[node.id]:
cluster_boost = 1.3
@@ -317,11 +317,9 @@
return "person_knowledge"
if node.layer == "code":
return "cross_domain"
- if is_god and days_apart >= 7:
- return "temporal_bridge"
if days_apart >= 7:
return "temporal_bridge"
- return "temporal_bridge"
+ return "cross_domain"
def _explain(topic: str, node, conn_type: str, days_apart: int, importance: float, is_god: bool) -> str:
diff --git a/plugins/bartokgraph/__init__.py b/plugins/bartokgraph/__init__.py
--- a/plugins/bartokgraph/__init__.py
+++ b/plugins/bartokgraph/__init__.py
@@ -36,9 +36,9 @@
rebuild_interval_days: 7
"""
-from hermes_cli.bartokgraph_adapter import BartokGraphAdapter, _resolve_local_model_provider
+from hermes_cli.bartokgraph_adapter import BartokGraphAdapter
-__all__ = ["BartokGraphAdapter", "_resolve_local_model_provider"]
+__all__ = ["BartokGraphAdapter"]
PLUGIN_NAME = "bartokgraph"
PLUGIN_VERSION = "1.0.0"You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit eb4daae. Configure here.
| rebuild_interval_days: 7 | ||
| """ | ||
|
|
||
| from hermes_cli.bartokgraph_adapter import BartokGraphAdapter, _resolve_local_model_provider |
There was a problem hiding this comment.
Plugin imports non-existent function causing ImportError
High Severity
plugins/bartokgraph/__init__.py imports _resolve_local_model_provider from hermes_cli.bartokgraph_adapter, but this function does not exist anywhere in the codebase. Loading this plugin will raise an ImportError at runtime.
Reviewed by Cursor Bugbot for commit eb4daae. Configure here.
| # Matches JS require/ES import strings and Python import/from statements | ||
| _IMPORT_RE = re.compile( | ||
| r"from\s+([\w./][\w./]{2,59})\s+import" | ||
| r"|require\s*\(\s*['\"]([ ^'\"]{2,59})['\"]{1}\)" |
There was a problem hiding this comment.
Regex character class matches wrong characters for require
Medium Severity
The require regex capture group uses ([ ^'\"]{2,59}) which is a character class matching space, caret, and quotes literally. The intent was ([^'\"]{2,59}) — a negated class matching anything except quotes. The leading space turns ^ from a negation operator into a literal character.
Reviewed by Cursor Bugbot for commit eb4daae. Configure here.
| if topic_id in self._cluster_map and node.id in self._cluster_map: | ||
| if self._cluster_map[topic_id] == self._cluster_map[node.id]: | ||
| cluster_boost = 1.3 | ||
| break |
There was a problem hiding this comment.
Cluster boost loop iterates but checks same topic
Low Severity
The loop for tokens in topic_tokens is intended to check cluster alignment across all active topics, but the body always computes topic_id = _to_node_id(best_topic) — a value that doesn't change per iteration. The loop variable tokens is never used, making the loop evaluate the identical condition repeatedly with no effect.
Reviewed by Cursor Bugbot for commit eb4daae. Configure here.
| return "temporal_bridge" | ||
| if days_apart >= 7: | ||
| return "temporal_bridge" | ||
| return "temporal_bridge" |
There was a problem hiding this comment.
Classify function always returns temporal_bridge for knowledge nodes
Low Severity
_classify has three conditional branches for non-person, non-code nodes that all return "temporal_bridge" — including the final fallback for days_apart < 7. The is_god check and the days_apart >= 7 check are redundant since they produce the same output. The cross_domain type is unreachable for knowledge-layer nodes, contradicting the documented three-type classification.
Reviewed by Cursor Bugbot for commit eb4daae. Configure here.
CI noteGreen except the
Net: no regressions introduced; the subsystem is green. The |
A bare `/resume` printed the recent-sessions list but armed no selection state, so typing just `3` on the next line was sent to the agent as chat instead of resuming session #3. `/resume 3` worked, but the natural list-then-pick flow did not. Arm a one-shot pending-resume prompt when bare `/resume` shows the list, and consume the next bare numeric input as the selection (out-of-range is reported, non-numeric/other commands disarm it). Resolves against the same _list_recent_sessions(limit=10) list used everywhere else. Closes NousResearch#34584.
…NousResearch#34192) (NousResearch#34382) NousResearch#34192 reports Hostinger's 'Hermes WebUI' catalog crashes on startup with: /usr/bin/tini: No such file or directory The image moved from tini to s6-overlay as PID 1 (/init) earlier in 2026. Orchestration templates that still pin /usr/bin/tini as the entrypoint \u2014 like the Hostinger Hermes WebUI catalog \u2014 have no binary to exec and the container crashes immediately. Hermes has no control over the Hostinger catalog template, but we can make the image backward-compatible by symlinking /usr/bin/tini -> /init during the s6-overlay install step. External wrappers that exec /usr/bin/tini will land on the same s6-overlay reaper they would have landed on if they'd used the canonical /init entrypoint. The image's own ENTRYPOINT continues to be /init verbatim \u2014 the shim is purely for legacy external wrappers, not for the image's own runtime path. Once affected catalogs are updated, the symlink can be removed. Other issues NousResearch#34192 raises that are NOT addressed by this PR: * Problem #2 (UID 1024 vs 10000 mismatch): already fixed by NousResearch#33148 (S6_KEEP_ENV=1) and NousResearch#32412 (with-contenv shebangs). The Hostinger template likely needs to update its env-var propagation. * Problem #3 (incompatible session formats): RFC for pluggable SessionDB is tracked in NousResearch#23717. * Problem #4 (Telegram polling conflict): an operations problem on Hostinger's side, not in this codebase. This PR is scoped to the one issue that can be fixed inside Dockerfile: the missing /usr/bin/tini binary. Tests (3 in test_dockerfile_tini_compat_shim.py): - test_tini_compat_symlink_present Guard: the symlink line must exist in Dockerfile. - test_tini_compat_comment_explains_why The NousResearch#34192 anchor comment must be present so future readers know why the shim is there (avoid accidental removal). - test_entrypoint_still_init_not_tini Sanity check: ENTRYPOINT remains /init (s6-overlay). The shim is only for external wrappers. Refs: NousResearch#34192 Partial fix: addresses the immediate tini-binary crash. Catalog-side fixes still needed by Hostinger for the UID and session-format problems documented in the issue. Co-authored-by: Cursor <cursoragent@cursor.com>
…w goals (NousResearch#34196, NousResearch#34197) Two related /goal bugs: (review/reflect/suggest/analyze) unless the assistant uses a magic phrase like 'goal complete'. The synthetic continuation loop escalates reflection into producing concrete artifacts that the goal only listed as *examples* of possible help. untracked` even when the user did not ask for staging/commit/push. This races with preflight compression and survives session split, turning a scoped 'done' answer into out-of-scope artifact production. Both bugs converge on the goal-judge prompt machinery in `hermes_cli/goals.py`. The fix is layered, minimal, and reviewable: 1. Tighten JUDGE_SYSTEM_PROMPT with three new explicit guardrails: - EXPLORATORY goals (review/reflect/suggest/analyze) are completable by a substantive synthesis — do NOT require additional artifacts. - Do NOT infer incompletion from untracked / unstaged / uncommitted files unless the goal explicitly required staging/commit/push. - Do NOT require a magic phrase like 'goal complete'. - Treat 'for example' / 'maybe' / 'you could' items as illustrative, NOT as required deliverables. - Scope-narrow goals (one file, one section, one specific change) are DONE when that exact scope is confirmed done — do not expand. 2. Add a transparent keyword classifier `_classify_goal_shape(goal)` that returns 'exploratory', 'illustrative', or 'concrete'. Cheap, reviewer-friendly substring detection — the LLM judge still makes the final DONE/CONTINUE call, but it now sees what kind of goal it is. Kept intentionally simple so behaviour is easy to audit and tune from issue feedback. 3. Append a corresponding goal-shape hint to the user-prompt template when the goal is exploratory or illustrative. The hint reminds the judge that for those shapes, a high-quality synthesis IS the deliverable. Concrete goals get the original strict template unchanged. 4. The with-subgoals template (already enforces strict per-criterion evidence) deliberately does NOT receive the shape hint — the user's explicit /subgoal criteria take precedence over goal-shape heuristics. Why prompt-level vs adding new state: This intentionally avoids adding new GoalState fields, new gateway plumbing, or new compression-lifecycle coupling. The goal judge is the single point where 'should we continue?' is decided; teaching it to read goal shape correctly is the smallest change that addresses both issues' root cause without touching the compression race window described in NousResearch#34197 #2-#5. Those lifecycle concerns are real and documented in the issue's 'Proposed fixes #3-#6' — they belong in a separate gateway-side change. This PR fixes the judge's bad 'continue' verdict that triggers the lifecycle problem in the first place. Without the bad verdict, the race window in NousResearch#34197 has nothing to race over. Tests (17 new, all 67 in test_goals.py pass): - TestClassifyGoalShape (9 tests): exploratory/illustrative/concrete classification including the sanitized NousResearch#34196 repro goal text. - TestJudgeSystemPromptGuardrails (4 tests): system prompt mentions exploratory goals, warns about untracked files, warns about requiring magic phrases, warns about illustrative examples. - TestJudgePromptIncludesGoalShapeHint (4 tests): the user prompt receives the shape hint for exploratory/illustrative goals, does NOT for concrete goals, and the with-subgoals template skips the hint to preserve its strict per-criterion evidence rule. Refs: NousResearch#34196 NousResearch#34197 Closes: NousResearch#34196 NousResearch#34197 Co-authored-by: Cursor <cursoragent@cursor.com>



Summary
A focused, reviewable extraction of the Proactive Communication Loop subsystem from PR #1 (
feat/proactive-communication-loop-v2), which bundled this feature with ~466 unrelated files and had failing CI. This PR contains only the feature + its tests, rebased on currentmain.The feature lets Hermes traverse an on-device weighted knowledge graph (BartokGraph) built from the user's history and surface non-obvious cross-time/cross-domain connections — opt-in, silent by default, daily rate-limited.
What's included (12 files)
hermes_cli/bartokgraph.py— three-layer (KNOWLEDGE / CODE / PERSON) on-device knowledge graph. Credential redaction, file-type weighting.last_seen_tsuses each source file's mtime, not build time (the original bug: every node got the build timestamp, so the loop saw everything as "active in the last 24h" and surfaced zero connections).hermes_cli/bartokgraph_adapter.py— connection provider bridging the graph to the loop.hermes_cli/proactive_communication_loop.py— synthesis pipeline. Never sends without a graph connection; respectsproactive_communication.max_per_day; fails silent.hermes_cli/proactive_scheduler.py— per-session flow scheduler, runs in the gateway cron-ticker thread.plugins/bartokgraph/__init__.py— plugin registration.gateway/run.py— minimal guarded wiring into the existing cron ticker (init once + tick hook). Only ~25 lines; none of the unrelated gateway churn from fix: last_seen_ts should use file mtime not build time (PR #22811) #1.tests/— graph, loop, scheduler, smoke tests + fixture.docs/features/proactive-communication-loop.md.Test-robustness fix
The loop tests seeded conversation history with a fixed epoch timestamp. With the loop's 72h history window, that timestamp ages out as wall-clock time advances, so
test_temporal_bridge_high_score_sendsloaded empty history and failed (and several no-send tests passed trivially for the wrong reason). Switched the seed to a relative recent timestamp so history is always in-window and the real send/no-send paths are exercised.Verification
78 passed—pytest tests/test_proactive_graph.py tests/test_proactive_communication_loop.py tests/test_proactive_scheduler.py tests/test_proactive_smoke.pyruff checkclean on all changed filespython -m py_compile gateway/run.py+ all modules OKuv.lock/pyproject.tomluntouchedagent.auxiliary_client.get_text_auxiliary_client,hermes_cli.config.{load_config,cfg_get},hermes_state.SessionDB,cron.scheduler._deliver_result) verified present onmainRelationship to #1
Supersedes the feature portion of #1. Recommend landing this, then closing #1 (or repurposing it for any genuinely-unrelated remaining changes after a rebase). The feature is opt-in:
proactive_communication.enableddefaults tofalse.Co-authored-by: Cursor cursoragent@cursor.com
Made with Cursor
Note
Medium Risk
Touches gateway cron and unprompted outbound messaging (user-facing, timing-sensitive) and walks local workspace files for graph builds; feature defaults off but judge calls use the configured LLM provider.
Overview
Adds an opt-in Proactive Communication Loop: when enabled, Hermes can send at most one unprompted message per session per day during a learned (or configured) peak hour, only if an on-device BartokGraph finds a strong dormant link to recent work and an auxiliary judge approves it.
New stack:
hermes_cli/bartokgraph.pybuilds a weighted local knowledge graph (prose/code/person layers, credential redaction, god nodes/clusters);bartokgraph_adapter.pyscores cross-time links (Jaccard × importance × temporal decay × boosts);proactive_communication_loop.pyruns the synthesis pipeline (72h history, graph required, thresholds,proactive_loop_judge);proactive_scheduler.pyderives peak hour from ~30 days of messages and dispatches viacron.scheduler._deliver_result.gateway/run.pywires aProactiveSchedulerinto the existing cron ticker (every tick, gated internally). Docs, plugin stub, tests (including relative timestamps so the 72h window does not bit-rot), and a smoke fixture round out the change.Notable fix: graph nodes use source file mtime for
last_seen_tsso “dormant in last 24h” filtering works after a fresh build.Reviewed by Cursor Bugbot for commit eb4daae. Bugbot is set up for automated code reviews on this repo. Configure here.