Skip to content

fix: last_seen_ts should use file mtime not build time (PR #22811)#1

Open
Bartok9 wants to merge 196 commits into
mainfrom
feat/proactive-communication-loop-v2
Open

fix: last_seen_ts should use file mtime not build time (PR #22811)#1
Bartok9 wants to merge 196 commits into
mainfrom
feat/proactive-communication-loop-v2

Conversation

@Bartok9
Copy link
Copy Markdown
Owner

@Bartok9 Bartok9 commented May 10, 2026

Summary

This PR addresses a bug in bartokgraph.py where every node in a freshly built KnowledgeGraph received last_seen_ts = time.time() (the build timestamp). This caused the Proactive Communication Loop to filter out all nodes as "recently active" (within last 24h), surfacing zero connections.

Root cause: add_node() and GraphNode defaulted to current time instead of the source file's actual modification time.

Detailed Changes

1. walk_files(directory) (hermes_cli/bartokgraph.py)

  • Updated to yield (file_path, mtime) tuples.
  • Uses entry.stat().st_mtime (single stat call for efficiency).
  • Return type: Iterator[Tuple[str, float]]

2. build_graph(workspace_path, ...)

  • Changed loop to for file_path, file_mtime in walk_files(...)
  • Passes file_mtime to:
    • extract_knowledge(...)
    • extract_code(...)
    • extract_html(...)
    • _extract_json(...)
    • Direct graph.add_node(..., last_seen_ts=file_mtime) calls (for code comments, PDF, etc.)

3. Extractor Functions

  • extract_knowledge(content, source, graph, weight=1.0, file_mtime=None)
  • extract_code(content, file_path, source, graph, file_mtime=None)
  • extract_html(content, file_path, source, graph, weight=1.0, file_mtime=None)
  • _extract_json(content, source, graph, weight, file_mtime=None) (new param)
  • All add_node calls inside now include last_seen_ts=file_mtime
  • Inner calls (e.g. extract_knowledge from extract_html) forward the file_mtime

4. KnowledgeGraph.add_node(...)

  • New optional param: last_seen_ts: Optional[float] = None
  • When updating existing node: node.last_seen_ts = last_seen_ts if last_seen_ts is not None else time.time()
  • When creating new node: same logic for GraphNode(..., last_seen_ts=...)
  • Falls back to time.time() only for manually-added nodes (tests, incremental updates, etc.)

5. GraphNode dataclass

  • last_seen_ts: float = 0.0 (was field(default_factory=time.time))
  • Nodes with unknown age now correctly treated as old (enables proper temporal decay scoring in _temporal_decay)

6. New Tests (tests/test_proactive_graph.py)

def test_build_graph_last_seen_ts_matches_file_mtime():
    """Verify last_seen_ts comes from file mtime, not build time."""
    with tempfile.TemporaryDirectory() as tmpdir:
        fpath = os.path.join(tmpdir, "recent.md")
        with open(fpath, "w") as f:
            f.write("# Recent Concept\n\nThis should have correct mtime.")
        mtime = time.time() - 30 * 86400  # 30 days ago
        os.utime(fpath, (mtime, mtime))

        graph = build_graph(tmpdir, layer="knowledge")
        node = graph.nodes.get("recent-concept")
        assert node is not None
        assert abs(node.last_seen_ts - mtime) < 2.0
        assert node.last_seen_ts < time.time() - 60  # not build time


def test_build_graph_last_seen_ts_30_days_old():
    """last_seen_ts for 30-day-old file is ~30d ago (within seconds), not recent."""
    with tempfile.TemporaryDirectory() as tmpdir:
        fpath = os.path.join(tmpdir, "old.md")
        with open(fpath, "w") as f:
            f.write("# Old Concept\n\nKnowledge from the past.")
        thirty_days_ago = time.time() - 30 * 86400
        os.utime(fpath, (thirty_days_ago, thirty_days_ago))

        graph = build_graph(tmpdir, layer="knowledge")
        node = graph.nodes.get("old-concept")
        assert node is not None
        days_ago = (time.time() - node.last_seen_ts) / 86400
        assert 29.9 < days_ago < 30.1
        assert node.last_seen_ts < time.time() - 60

Impact on Proactive Communication Loop

  • BartokGraphAdapter._find_connections() uses n.last_seen_ts < cutoff_ts (cutoff = now - exclude_recent_hours*3600)
  • With correct mtimes, historical files (e.g. 30+ days old) now correctly appear as dormant and get scored with temporal decay boost.
  • Freshly built graphs no longer incorrectly mark everything as "active now".

Testing

  • All pre-existing tests continue to pass.
  • New tests specifically validate mtime fidelity and age calculation.
  • No changes to non-file-backed nodes (they still get time.time()).

Backward Compatibility

  • Fully backward compatible. Existing graphs loaded via KnowledgeGraph.load() already used last_seen_ts from JSON (default 0.0).

This PR includes the complete implementation, tests, and rationale so the review agent can fully evaluate the fix without needing external context.


Note

High Risk
High risk because it rewires the Docker publish workflow (multi-runner digest/manifest merge + concurrency), changes agent message conversion/compression around multimodal/tool results, and alters cron delivery/runtime behavior; regressions could break releases or message delivery across platforms.

Overview
CI/CD: Adds a reusable hermes-smoke-test composite action and updates docker-publish.yml to build amd64/arm64 on native runners, smoke-test both, push per-arch images by digest, then merge them into a tagged multi-arch manifest; concurrency is changed so PR runs cancel in-progress while main/release runs don’t, and move-latest now depends on the manifest merge.

Quality gates: Makes linting partially blocking by adding a dedicated ruff check . enforcement job plus a new blocking windows-footguns checker, and adds a new blocking uv lock --check workflow to keep uv.lock in sync with pyproject.toml.

Runtime/build: Refactors the Dockerfile to cache Python dependency install by copying only pyproject.toml/uv.lock before source, then installs the project editable without deps; docker/entrypoint.sh can now bootstrap auth.json from HERMES_AUTH_JSON_BOOTSTRAP on first run.

Agent behavior: Improves Anthropic conversion for multimodal tool results (including computer_use screenshots), adds eviction of older screenshot blocks, updates token estimation/compression to treat images as fixed-cost tokens and strip/placeholder old image payloads, and adds provider-header fallbacks from provider profiles.

Cross-platform/CLI & cron: Broad Windows hardening (UTF-8 stdio bootstrap, explicit file encodings, bash discovery for .sh cron scripts, safer worktree symlink fallback, and terminal keybinding/signal handling tweaks), adds confirmation prompts for destructive slash commands, normalizes cron job records and cleans up job output dirs on delete, adds deliver=all routing intent for cron, and introduces the MSGRAPH_WEBHOOK platform plus plugin support for standalone out-of-process sending.

Reviewed by Cursor Bugbot for commit 4346e15. Bugbot is set up for automated code reviews on this repo. Configure here.

austinpickett and others added 30 commits May 4, 2026 12:53
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: austinpickett <260188+austinpickett@users.noreply.github.com>
…itch (NousResearch#21703)

When switching from a custom local provider (e.g. ollama-launch) to a
cloud provider, two bugs caused the CLI to misbehave:

1. _explicit_api_key/_explicit_base_url were only updated when the switch
   result had non-empty values (guarded by `if result.api_key:` etc.).
   If the previous provider set these to Ollama values ("ollama",
   "http://127.0.0.1:11434/v1"), those stale values leaked into the next
   turn's _ensure_runtime_credentials() call and were forwarded to the
   new provider's API endpoint, causing authentication/routing failures.

   Fix: unconditionally write result.api_key/base_url into the explicit
   fields after every successful switch. An empty string is the correct
   sentinel — it tells _ensure_runtime_credentials to re-resolve from the
   auth store / config rather than forwarding a stale override.

2. In AIAgent.switch_model(), `self.base_url = base_url or self.base_url`
   kept the old Ollama localhost URL whenever the incoming base_url was an
   empty string. For providers that use a native SDK (not an OpenAI-compat
   endpoint), the caller passes base_url="" and expects the agent to clear
   the field — not silently inherit Ollama's address.

   Fix: only update self.base_url when base_url is truthy.

3. _handle_model_picker_selection() was called from the prompt_toolkit
   Enter key binding without any exception guard. Any unexpected error
   in the model-selection code path propagated through prompt_toolkit's
   key-binding dispatcher and caused the entire TUI to exit — which the
   user sees as "the terminal exits when I switch providers".

   Fix: wrap the call in try/except and close the picker on failure.
The previous revision of this PR added six GMI-specific branches
(`elif base_url_host_matches(..., 'api.gmi-serving.com')`) across
run_agent.py and agent/auxiliary_client.py, plus a _HERMES_UA_HEADERS
constant in auxiliary_client.py.

ProviderProfile already has a `default_headers: dict[str, str]` field
commented as 'Client-level quirks (set once at client construction)'.
Other plugins (ai-gateway, kimi-coding) already use it. Two of the four
auxiliary_client sites we previously patched already had a generic
`else: profile.default_headers` fallback that picked it up (so did
both run_agent sites).

This revision:

* Sets `default_headers={'User-Agent': 'HermesAgent/<ver>'}` on the
  GMI profile in plugins/model-providers/gmi/__init__.py.
* Reverts all six GMI-specific branches in run_agent.py and
  auxiliary_client.py.
* Adds the generic profile-fallback `else` block to the two
  auxiliary_client sites (`_to_async_client`, `resolve_provider_client`)
  that didn't have it yet. This benefits every provider whose profile
  declares default_headers, not just GMI — e.g. Vercel AI Gateway's
  HTTP-Referer/X-Title now flow through the async client path too.
* Replaces the GMI-specific URL-branch tests with a profile-level
  assertion and keeps the run_agent integration test (with
  `provider='gmi'` so the fallback picks up the profile).

Net diff vs main: +82/-0 across 5 files, touching only the GMI plugin,
two generic fallback blocks in auxiliary_client.py, AUTHOR_MAP, and
tests. No core files change.

Based on NousResearch#20907 by @isaachuangGMICLOUD.
…channel (NousResearch#21495)

Adds one reserved token to the cron `deliver` field:

- `all` — expand to every platform with a configured home channel

Resolves at fire time, not create time, so a job created before Telegram
was wired up picks it up once `TELEGRAM_HOME_CHANNEL` is set. Composes
with existing targets: `origin,all`, `all,telegram:-100:17`.

Inspired by Vellum Assistant's reminder routing-intent system.

## Changes
- cron/scheduler.py: _expand_routing_tokens + integrate into _resolve_delivery_targets
- tools/cronjob_tools.py: schema description updated
- tests/cron/test_scheduler.py: TestRoutingIntents (5 cases)
- website/docs/user-guide/features/cron.md: docs + table rows

## Validation
- tests/cron/test_scheduler.py -k 'Routing or Deliver' → 57 passed
…eck-live

setup.py --check only validated token shape/expiry but did not detect
when Google had disabled the OAuth client or account. Users got
AUTHENTICATED even when actual API calls failed with disabled_client.

Changes:
- Catch disabled_client and invalid_client in check_auth() refresh
  path with actionable guidance (check Cloud Console, check account
  status, do not retry)
- Add check_auth_live() that performs a real Calendar API call to
  detect disabled_client errors that survive token refresh
- Add --check-live CLI flag backed by check_auth_live()

Fixes NousResearch#19570
Small follow-ups on top of NousResearch#19643:
- check_auth() takes quiet kwarg to suppress its AUTHENTICATED print
  when called from check_auth_live(), so the final status line reflects
  the live-call outcome only.
- Drop redundant _ensure_deps() call in check_auth_live() (check_auth()
  already calls it).
- Add AUTHOR_MAP entry for ygd58 so release attribution script works.
The quick setup flow (recommended for first-time users) silently defaulted
terminal.backend to 'local' without ever presenting the choice. This meant
new users who wanted Docker, SSH, Modal, Daytona, or any other backend had
to know about 'hermes setup terminal' — which most wouldn't discover until
later.

Now the quick setup flow is:
  1. Provider selection
  2. API key
  3. Terminal backend (local/Docker/Modal/SSH/Daytona/Vercel/Singularity)
  4. Messaging platform
  5. Done

The terminal backend is a foundational decision (where ALL commands run)
and belongs in the onboarding path alongside provider selection.
…cker dead space (NousResearch#21846)

Multi-turn transcripts ran together visually because every user message
got the same vertical rhythm regardless of position. Adds a short ─── in
the border colour above every user message after the first, so each turn
reads as its own block. Height estimator gains a `withSeparator` flag so
virtual scrolling pre-allocates the extra two rows (rule + top margin)
and avoids a jump on first measurement.

While in the area: the busy-indicator duration was padded with
`padStart(7)`, leaving five visible spaces between `·` and the digits
(`⠋ ·      2s`) — especially loud under the verb-less `unicode` style.
Drop the padding entirely (`⠋ · 2s`); the model label now shifts a few
columns as the duration grows, which is the right trade-off for the
minimal indicator styles. The verb-padding test stays; the
duration-padding test is removed alongside the function it covered.
…uralization

fix(cli): use proper singular/plural in doctor and claw messages
remove_job() deletes the job from cron/jobs.json but leaves the per-job
output directory at ~/.hermes/cron/output/{job_id}/ behind. Over time
this accumulates orphaned dirs that never get reclaimed.

Adopted from NousResearch#13510 by @hekaru-agent; the honcho RLock half of that PR
was already salvaged in commit dad0217 so this lands the remaining
cron cleanup hunk on its own.
Lets orchestrators (e.g. an account-management service provisioning a
Hermes VPS) seed an OAuth refresh credential non-interactively instead of
walking the user through `hermes setup` + the device-flow login dance.
Matches the existing first-boot-only pattern used for .env, config.yaml,
and SOUL.md.

If HERMES_AUTH_JSON_BOOTSTRAP is set and $HERMES_HOME/auth.json doesn't
already exist, write the env var's contents to auth.json with mode 600.
The `[ ! -f ... ]` guard is critical: it ensures that on container
restart the rotated refresh token Hermes wrote back to the persistent
volume is never clobbered by the now-stale value the orchestrator
originally seeded.

Generic name (not Nous-specific) so the feature is reusable by any future
orchestrator.
…ch#21888)

Reported: Ctrl+C during an active /goal loop felt like it did nothing —
the agent would interrupt the current turn, then immediately queue another
continuation and keep going until the session ended or the 20-turn budget
ran out.

Root cause: cli.py's _maybe_continue_goal_after_turn() ran in the finally:
block around self.chat(...) unconditionally. Whether the turn completed
normally, got interrupted, or returned an empty string, the judge ran on
whatever was in conversation_history and — because the judge is fail-open
— a "continue" verdict pushed another CONTINUATION_PROMPT onto
_pending_input. Ctrl+C was invisible to the hook.

Fix:
- chat() now captures result['interrupted'] onto self._last_turn_interrupted
  (resets to False at entry so early-returns don't leak prior state).
- _maybe_continue_goal_after_turn() checks the flag first: on interrupt,
  auto-pause via mgr.pause(reason='user-interrupted (Ctrl+C)') and print
  a one-liner pointing the user at /goal resume or /goal clear. No judge
  call, no continuation enqueued.
- Also added an empty-response guard that mirrors gateway/run.py's
  _handle_message logic (empty reply → transient failure → skip judging
  so we don't trip the consecutive-parse-failures backstop unnecessarily).

The goal stays in the DB as paused, so /goal resume recovers it after
the user has sorted out whatever made them cancel. /goal clear still
works as before for a full stop.

Tests: tests/cli/test_cli_goal_interrupt.py covers:
  - interrupted turn pauses + doesn't queue + judge is NOT called
  - paused goal is resumable
  - empty / whitespace / missing assistant reply skips judging
  - healthy turn still enqueues continuation / marks done
  - chat() resets _last_turn_interrupted at entry (anti-leak guard)

All 55 existing goal tests still pass.
…ousResearch#21895)

Expand the google-workspace skill beyond read-only access to Drive and
Docs. Sheets already had full scope — just adds the missing create verb.

New subcommands:
- drive get        : metadata for a single file
- drive upload     : upload a local file (auto MIME detection)
- drive download   : download or export (Docs/Sheets/Slides export to pdf/csv/pdf by default)
- drive create-folder
- drive share      : user/group/domain/anyone + reader/writer/etc.
- drive delete     : default trashes (reversible); --permanent skips the trash
- sheets create    : new spreadsheet with optional first-tab name
- docs create      : new doc, optional initial body
- docs append      : append text at end of an existing doc

Scope changes:
- drive.readonly     -> drive
- documents.readonly -> documents

Existing users with old tokens will hit the existing partial-scope
warning path (AUTHENTICATED (partial) ...) — the troubleshooting table
now points them at $GSETUP --revoke + redo steps 3-5 to pick up the
write scopes.
The new _is_gateway_approval_context() widened the gateway classification
to any call with HERMES_SESSION_PLATFORM bound via contextvars. But
cron/scheduler.py binds that same contextvar for delivery routing on
cron jobs that originate from a gateway platform (telegram/discord/etc.),
so those jobs were getting routed through submit_pending with no
listener — blocking indefinitely instead of honoring approvals.cron_mode.

Short-circuit on HERMES_CRON_SESSION before any gateway check. Cron is
always governed by cron_mode config, regardless of where the job was
scheduled from.

Adds regression coverage in TestCronWithGatewayOrigin and records the
contributor email mapping for scripts/release.py.
… no-agent (NousResearch#21881)

* feat(skills): watchers skill — poll RSS / HTTP JSON / GitHub via cron no-agent

Ships three reusable polling scripts plus a shared watermark helper as an
optional skill.  Users wire them into the existing cron (no_agent=True)
mode rather than learning a new subsystem.

Supersedes the closed PR NousResearch#21497 (parallel watcher subsystem).  Same value,
zero new core surface.

## What ships

- optional-skills/devops/watchers/SKILL.md: pattern + three example cron commands
- optional-skills/devops/watchers/scripts/_watermark.py: shared helper
  (atomic state writes, bounded ID set, first-run baseline)
- optional-skills/devops/watchers/scripts/watch_rss.py: RSS 2.0 + Atom
- optional-skills/devops/watchers/scripts/watch_http_json.py: any JSON endpoint
  with configurable id_field / items_path / headers
- optional-skills/devops/watchers/scripts/watch_github.py: issues / pulls /
  releases / commits (uses GITHUB_TOKEN if present)

## Invariants enforced by the shared helper

- First run records baseline, emits nothing (never replays existing feed)
- Watermark file is <state_dir>/<name>.json, atomic replace on write
- Bounded to 500 IDs (configurable)
- Empty stdout when no new items — cron treats that as silent delivery

## Validation
- watch_rss.py against news.ycombinator.com/rss first run → empty stdout, watermark populated
- Removed one seen-id, second run → emitted exactly that item
- No DeprecationWarnings (ET element truth-value footgun dodged explicitly)

End-user pattern: 'hermes cron create my-feed --schedule "*/15 * * * *" --no-agent --script $HERMES_HOME/skills/devops/watchers/scripts/watch_rss.py --script-args "--name hn --url https://news.ycombinator.com/rss" --deliver telegram'

* docs(skills/watchers): tighten description to match peer optional skills

* docs(skills/watchers): align frontmatter + structure with peer optional skills

* docs(skills/watchers): gate to linux/macos (shell syntax in examples)
The prior implementation routed download_to_file through the shared
_request() path, which uses httpx.AsyncClient.request() inside a
context manager that closes before aiter_bytes() iterates. The body
was read into memory first and the chunked write loop replayed it
from buffer. On small test payloads this was invisible; on real
Teams meeting recordings (hundreds of MB) it would force the full
artifact into RAM per download.

Rewrites download_to_file to open its own AsyncClient and use
client.stream(), keeping the context open across the aiter_bytes
iteration so the body is actually streamed chunk-by-chunk to disk.
Retry/token-refresh/Retry-After semantics are preserved by handling
them inline on the stream path. Partial .part files are cleaned up
on transport errors and on exhausted retries.

Adds three tests: large-payload streaming verifies the chunk loop
runs multiple times (discriminator: 512 KiB at chunk_size=65536
yields 8 chunks under streaming, 1 under buffering), transient-5xx
retry recovers after a single retry, and exhausted-retry cleans up
the partial file.
…ence

Foundation docs shipped alongside the Graph auth/client code so users
have a working path from zero to a verified token from the moment this
PR lands.

- website/docs/guides/microsoft-graph-app-registration.md: new page
  walking through app registration, client secret, the exact minimum
  Graph API permissions per pipeline capability (transcript-first,
  recording fallback, Graph-mode delivery), admin consent, optional
  Application Access Policy for tenant-scoping, token-flow smoke test
  with the shipped MicrosoftGraphTokenProvider, and a troubleshooting
  table for common AADSTS errors. Includes secret-rotation procedure.

- website/docs/reference/environment-variables.md: new Microsoft Graph
  subsection in Messaging documenting MSGRAPH_TENANT_ID, MSGRAPH_CLIENT_ID,
  MSGRAPH_CLIENT_SECRET, MSGRAPH_SCOPE (default .default),
  MSGRAPH_AUTHORITY_URL (with sovereign-cloud override note for GCC
  High etc.).

- website/sidebars.ts: wire the guide into Guides Tutorials.

The guide pages that cover the webhook listener, pipeline runtime,
operator CLI, and outbound delivery land with their matching PRs. This
one is the standalone prereq that's safe to verify in advance.

Verified via npm run build: no new warnings or errors; page routes
correctly at /docs/guides/microsoft-graph-app-registration.
…20831)

* feat(profile): shareable profile distributions (pack/install/update/info)

Closes NousResearch#20456.

Turns a profile into a portable, versioned artifact. Packs SOUL.md, config,
skills, cron, and an env-var manifest into a tar.gz that others can install
from a local path, URL, or git repo. Updates re-pull the distribution while
preserving user data (memories, sessions, auth.json, .env) and the user's
config.yaml overrides.

New subcommands (under hermes profile, no parallel tree):
  hermes profile pack    <name> [-o FILE]
  hermes profile install <source> [--name N] [--alias] [--force] [-y]
  hermes profile update  <name> [--force-config] [-y]
  hermes profile info    <name>

Manifest (distribution.yaml at the profile root): name, version,
hermes_requires, author, env_requires, distribution_owned.

Security:
  - Installer shows manifest + env-var requirements before mutating disk;
    confirmation required unless -y.
  - auth.json and .env are never packed (same exclude set as profile export).
  - Cron jobs are packed but NOT auto-scheduled — user is pointed at
    'hermes -p <name> cron list' to review.
  - Archive extraction rejects path traversal (../ members).
  - Alias creation is opt-in via --alias.

Update semantics:
  - Distribution-owned paths (SOUL.md, skills/, cron/, mcp.json, manifest):
    replaced from the new archive.
  - config.yaml: preserved by default; --force-config to overwrite.
  - User-owned paths (memories/, sessions/, auth.json, .env, state.db*,
    logs/, workspace/, plans/, home/, *_cache/, local/): never touched.

Version pin:
  hermes_requires accepts >=, <=, ==, !=, >, < or a bare version (treated
  as >=). Install fails with a clear error when the running Hermes version
  doesn't satisfy the spec.

Sources supported by 'install':
  - Local .tar.gz / .tgz archive
  - Local directory
  - HTTP(S) URL pointing to a .tar.gz (uses httpx, already a dep)
  - Git URL (github.com/user/repo, https://..., git@..., ssh://, git://)

Tests: 43 new unit tests (manifest parsing, version checks, env template,
pack/install/update round-trip, config-preservation, security).
E2E validated via real CLI invocations against an isolated HERMES_HOME
covering pack, install with confirmation, update preservation, update
--force-config, decline-preview, duplicate-install rejection, and
version-requirement rejection.

* refactor(profile-dist): git-only — drop tar.gz/HTTP transports and pack

Scope-cut on top of the original distribution PR: a profile distribution
is now exclusively a git repository (or a local directory during
development). The tar.gz / HTTP archive transports and the matching
`hermes profile pack` subcommand have been removed.

Why:
* GitHub tags, branches, and commits are already the right versioning
  primitive. Tag pushes do for us what 'pack + upload' did.
* `hermes profile export` / `import` already cover local backup and
  restore; they are not a distribution format and stay untouched.
* One transport means one install/update code path, one doc page,
  and one mental model. The extra source types doubled the surface
  for no real user win — GitHub auto-attaches release tarballs, and
  `git bundle` / `git clone --mirror` cover the airgap case.

Changes:
* hermes_cli/profile_distribution.py — removed pack_profile,
  _fetch_tar_archive (_http_fetch), _safe_extract, _archive_roots,
  _safe_parts, _find_dist_root, tarfile/io/urlparse imports. The
  new _stage_source has two arms: git URL → clone, local directory
  → use in place.
* hermes_cli/main.py — removed the 'pack' subparser and action
  handler. Install help text updated to match the reduced source list.
* tests/hermes_cli/test_profile_distribution.py — rewritten around a
  local-directory staging fixture. The install/update/describe suites
  now build a distribution tree on disk directly and install from it,
  which is what a real git clone produces after .git is stripped.
  Dropped TestPack, TestFindDistRoot, and the tar-specific security
  test. New tests cover _looks_like_git_url, env_example emission,
  hermes_requires enforcement, and 'installer does not import
  credentials if an author mistakenly leaks them in the staging tree'.
* website/docs/reference/profile-commands.md — 'Distribution commands'
  section rewritten around git. Added a 'Publishing a distribution'
  section. export/import stay documented as local backup/restore.
* website/docs/reference/cli-commands.md — dropped 'pack' from the
  profile subcommand table.
* website/package.json — 'lint:diagrams' now passes
  --exclude-code-blocks to ascii-guard. Without it, markdown tables
  and box-drawing diagrams inside fenced code blocks were being
  misidentified as malformed ASCII boxes, blocking the PR's
  docs-site-checks CI with 8 false-positive errors.

Validation:
* Targeted suite: tests/hermes_cli/test_profile_distribution.py —
  56/56 pass (down from 43 — reorganized to cover the new
  local-dir paths).
* Regression: test_profiles.py + test_profile_export_credentials.py
  102/102 still pass. export/import behaviour unchanged.
* Docs lint: ascii-guard lint --exclude-code-blocks docs returns
  0 errors (was 8 on the PR before the flag bump).
* E2E: ran the real `hermes profile install`/`info` against a
  local staging dir under an isolated HERMES_HOME — install writes
  SOUL.md + skills to the target profile, info reads the manifest
  back, a bogus source produces a clear error, and `hermes profile
  pack` is now rejected by argparse as expected.

* feat(profile-dist): distribution-aware list/show/delete + installed_at + env preview

Polish pass on top of the git-only scope cut. Five additions, all small,
wiring into existing commands rather than adding new surface.

1. `installed_at` timestamp on the manifest
   * Stamped automatically inside plan_install() on both fresh install
     and update — ISO-8601 UTC, seconds resolution.
   * Surfaced in `hermes profile info` as `Installed:    <ts>`.
   * Lets users tell "installed 6 months ago, needs update" from
     "installed yesterday" without guessing from file mtimes.

2. `hermes profile list` grows a `Distribution` column
   * Plain profiles: "—"
   * Distribution profiles: "<name>@<version>" (e.g. `telemetry@1.2.3`)
   * ProfileInfo gains three optional fields — distribution_name,
     distribution_version, distribution_source — populated by a new
     _read_distribution_meta() helper that swallows manifest read errors
     so a broken distribution.yaml in one profile can't break `list`
     for the others.

3. `hermes profile show` and `hermes profile delete` surface
   distribution provenance
   * show: `Distribution: name@version` + `Installed from: <source>`
     plus a pointer to `hermes profile info <name>` for the full
     manifest.
   * delete: same lines in the pre-confirmation preview, so a user
     deleting "telemetry" can see it came from
     `github.com/kyle/telemetry-distribution` before they type
     `telemetry` to confirm. No change to the confirmation gate itself —
     deletion semantics are identical to plain profiles.

4. Install preview checks env vars against the current environment
   * Replaces the "Env vars you'll need to set:" header with a simpler
     "Env vars:" block.
   * Each required var is labeled:
     - `✓ set` — already in `os.environ` OR present as a key in the
       target profile's existing .env (update case).
     - `needs setting` — required but not found in either place.
     - `—` — optional.
   * Mirrors pip's "Requirement already satisfied" UX: no unnecessary
     nagging about keys the user already has configured.

5. Docs: private distributions
   * New "Private distributions" section in
     website/docs/reference/profile-commands.md explaining that we
     shell out to the user's `git` binary, so SSH keys / credential
     helpers / GitHub CLI stored creds all work transparently. One
     paragraph, two examples.
   * `hermes profile info` section updated to mention `Installed:`.

Module-level hoist:
* `from datetime import datetime, timezone` was previously lazy-imported
  inside plan_install(). Hoisted to module scope so tests can monkeypatch
  `hermes_cli.profile_distribution.datetime` to freeze time.

Tests (+7):
* TestInstalledAtStamp.test_install_stamps_installed_at — format check
  (4-digit year, 'T', +00:00 suffix).
* TestInstalledAtStamp.test_update_refreshes_installed_at — freezes
  datetime.now() to 2099-01-01 and confirms update writes a new stamp.
* TestProfileInfoDistribution.test_installed_distribution_shows_in_list
  — ProfileInfo.distribution_{name,version,source} populated after install.
* TestProfileInfoDistribution.test_plain_profile_has_no_distribution_fields
  — plain profiles have None.
* TestProfileInfoDistribution.test_malformed_manifest_does_not_break_list
  — broken distribution.yaml in one profile doesn't break list_profiles().

Validation:
* 163/163 tests pass (56 distribution + 102 profile regression +
  5 new from this commit — up from 158).
* docs-lint: 0 errors.
* E2E verified: install preview shows ✓/needs-setting per env var,
  `profile list` shows Distribution column, `profile show` + `delete`
  preview mentions source URL, `info` shows Installed: timestamp.

* fix(profile-dist): clean errors + warn when overwriting plain profiles

Two small polish fixes found during collision sweeps of the PR:

1. ValueError from validate_profile_name now caught cleanly
   * A distribution.yaml whose 'name' field can't be used as a profile
     identifier (spaces, path traversal, etc.) raises ValueError from
     hermes_cli.profiles.validate_profile_name, which was escaping as a
     raw Python traceback from 'hermes profile install/update/info'.
   * Broadened the except clause in all three handlers to catch
     (DistributionError, ValueError) — users now see:
       Error: Invalid profile name '../../etc/passwd'. Must match
              [a-z0-9][a-z0-9_-]{0,63}
     instead of a stack trace.

2. Install preview distinguishes plain profile overwrite from
   distribution re-install
   * When plan.target_dir exists and IS a distribution (has
     distribution.yaml), preview still shows the mild
       (profile exists — will overwrite distribution-owned files only)
   * When plan.target_dir exists but is a HAND-BUILT plain profile (no
     distribution.yaml), preview now shows a loud warning:
       ⚠ Profile exists but is NOT a distribution.  Installing here will
         overwrite its SOUL.md, skills/, cron/, and mcp.json.
         Your memories, sessions, auth.json, and .env will be preserved,
         but any hand-edits to distribution-owned files will be lost.
   * Users who type 'hermes profile install foo --force' against a
     profile they hand-built now see what they're signing up for. User
     data is still safe (memories, sessions, auth, .env are in
     USER_OWNED_EXCLUDE), but custom SOUL/skills get stomped.

Tests (+2):
* TestErrorSurfaces.test_bad_profile_name_raises_valueerror_not_traceback
* TestErrorSurfaces.test_path_traversal_name_rejected

Validation:
* 165/165 tests pass (was 163).
* E2E: bad manifest names produce 'Error: Invalid profile name ...'
  with no traceback; installing over a plain profile shows the warning;
  re-installing over an existing distribution shows the normal
  overwrite message.
* Bad HTTPS URLs still produce 'Error: git clone failed: ...' — git
  itself generates a clean enough message that no wrapper is needed.
* 'install .' works correctly from any cwd.

* fix(profiles): reject reserved names at validate time

Before: `hermes profile create hermes` / `profile install` / `profile rename`
all silently accepted reserved names like `hermes`, `test`, `tmp`, `root`,
`sudo`. The profile directory was created; only alias creation failed (via
check_alias_collision), leaving a confusingly-named profile on disk — e.g.
`~/.hermes/profiles/hermes/` sitting next to `~/.hermes/` itself.

The reserved set already exists (_RESERVED_NAMES, introduced alongside alias
collision detection). This commit moves the check up one layer to
validate_profile_name so every entry point — create, install, import,
rename, dashboard web API — shares the same gate.

The error message points the user at the cause without being cryptic:
  Error: Profile name 'hermes' is reserved — it collides with either the
  Hermes installation itself or a common system binary.  Pick a different
  name.

`default` continues to pass through (it's a special alias for ~/.hermes).
_HERMES_SUBCOMMANDS (`chat`, `model`, `gateway`, etc.) stays at
alias-collision time only — those are fine as bare profile names with
`--no-alias`.

Tests (+5): test_reserved_names_rejected parametrized over the full
_RESERVED_NAMES set, matching the existing pattern in TestValidateProfileName.

No existing test uses a reserved name as a profile identifier (greppped
create_profile("hermes|test|tmp|root|sudo") — zero hits).

Validation:
* 170/170 tests pass in the profile suites.
* E2E: `profile create hermes`, `profile install` with manifest
  name=hermes, and `profile install ... --name hermes` all produce the
  same clean `Error: Profile name 'hermes' is reserved ...` with rc=1
  and no traceback. Normal names (`mybot`) still work.
nik1t7n and others added 20 commits May 9, 2026 11:54
- Restore allowed_chats gate before thread_id check so ignored_threads
  applies universally (even to guest mentions).
- Compute _message_mentions_bot once in _should_process_message to
  eliminate redundant second entity scan when guest_mode=true and the
  message does not mention the bot.
- Remove redundant _is_group_chat from _is_guest_mention (caller already
  verified the message is a group chat).
- Update _telegram_allowed_chats docstring to note guest_mode exception.
- Add test coverage: bot_command entity, text_mention entity,
  caption_entities, and ignored_threads + guest_mode interaction.
- Add nik1t7n to AUTHOR_MAP.
…rch#22764)

When session_id rotates (e.g. /new), commit_memory_session was firing
MemoryManager.on_session_end but skipping ContextEngine.on_session_end.
Engines that accumulate per-session state (LCM-style DAGs, summary
stores) leaked that state from the rotated-out session into whatever
continued under the same compressor instance.

Mirror the call shutdown_memory_provider already makes — same
lifecycle moment, same hook contract ("real session boundaries (CLI
exit, /reset, gateway expiry)"). /new is a real boundary for the old
session_id; providers keep their state but the rotated-out session_id
is done.

6 regression tests covering both-hooks-fire, no-memory-manager,
no-context-engine, both failure-tolerant paths.

Closes NousResearch#22394.
…RON_SESSION (NousResearch#22767)

Two unrelated but co-located fixes to scripts/run_tests.sh:

1. pytest-split bootstrap (NousResearch#22401): the script tried '$PYTHON -m pip
   install pytest-split' on first run, but uv-created venvs ship without
   pip. Result: 'No module named pip' before any test ran. Add a uv
   fallback (uv pip install --python $PYTHON), keep pip as a secondary
   path, and emit a clear error pointing at 'uv pip install -e ".[dev]"'
   when neither is available. Also declare pytest-split in
   pyproject.toml dev extra so a normal '.[dev]' install provisions it.

2. HERMES_CRON_SESSION leak (NousResearch#22400): the hermetic env scrub already
   unsets HERMES_GATEWAY_SESSION and HERMES_INTERACTIVE but missed the
   sibling HERMES_CRON_SESSION. When run_tests.sh is invoked from a
   Hermes cron job, that variable leaks into pytest, flipping
   tools/approval.py into cron-deny mode and breaking
   tests/acp/test_approval_isolation.py and friends.

Closes NousResearch#22400.
Closes NousResearch#22401.
NousResearch#22769)

Operator-controlled HERMES_PROFILE values were rendered as
'**${author}** (${ts}):' — markdown bold with no provenance prefix.
Worker comment bodies render directly underneath. A misleading
profile name like 'hermes-system' or 'operator' could be misread by
the next worker as a system directive above attacker-influenced
content (confused-deputy primitive gated on operator misconfig).

The LLM-controlled author-forgery surface was already closed in
NousResearch#22435 (author removed from KANBAN_COMMENT_SCHEMA). This is
defense-in-depth: render with an explicit 'comment from worker
`<author>` at <ts>:' prefix so even 'hermes-system' resolves to
'comment from worker `hermes-system` at ...' — parseable as
worker-comment metadata, not a system directive. Strip backticks
from author so they can't break out of the fence.

Update test_build_worker_context_caps_comments to count by body
regex since the rendered author line now also starts with
'comment '.

Closes NousResearch#22452.
…ousResearch#22774)

Gateway creates a fresh AIAgent per inbound message in several common
scenarios: cache miss, idle eviction (1h TTL), config-signature
mismatch, process restart. A freshly-built AIAgent has
_turns_since_memory=0 and _user_turn_count=0, so the
memory.nudge_interval trigger ('_turns_since_memory >=
_memory_nudge_interval') can never be reached when these reconstructions
happen on roughly the cadence of the interval. A user can chat for hours
on Telegram without ever seeing a self-improvement review fire.

Reconstruct the counters from conversation_history at the top of
run_conversation(), right after the existing _hydrate_todo_store call.
Idempotent guard ('if self._user_turn_count == 0') means a cached agent
that already accumulated counters keeps them; only freshly-built agents
hydrate. Modulo arithmetic preserves the original 1-in-N cadence rather
than firing a review immediately on resume.

7 regression tests pinning the contract (mid-cycle history, modulo wrap,
idempotency, zero-interval skip, role==user filtering, production-code
anchor).

Closes NousResearch#22357.
…re (NousResearch#22775)

Non-streaming /v1/chat/completions wrapped any AIAgent result \u2014 including
partial/failed runs \u2014 as a successful 200 with finish_reason='stop' and
the internal failure string substituted into message.content. API
clients had no way to distinguish 'agent answered: X' from
'agent crashed and the X you see is its error message'.

After the fix:
  - completed: True             \u2192 200 finish_reason='stop' (unchanged)
  - partial + truncated text    \u2192 200 finish_reason='length' + hermes extras
  - partial + no text / failed  \u2192 502 OpenAI error envelope (SDKs raise)
  - other failures              \u2192 200 finish_reason='error' + hermes extras

Adds X-Hermes-Completed / X-Hermes-Partial / X-Hermes-Error headers
plus a 'hermes' extras object on partial responses for clients that
want the full picture.

Closes NousResearch#22496.
…ousResearch#22777)

Native Windows, WSL, SSH sessions, and Windows Terminal all send
Ctrl+Enter as bare LF (c-j). Hermes was binding c-j as submit on
every POSIX platform, so Ctrl+Enter submitted instead of inserting
a newline on those terminals. Reported in NousResearch#22379.

Add _preserve_ctrl_enter_newline() predicate that detects the
environments where Ctrl+Enter must produce a newline (sys.platform
== 'win32', SSH_CONNECTION/SSH_CLIENT/SSH_TTY env, WT_SESSION,
WSL_DISTRO_NAME, /proc/version 'microsoft' marker). Gate the
c-j-as-submit binding off in those environments and gate the
c-j-as-newline handler on. Local POSIX TTYs without those markers
(docker exec, plain ssh from a Mac) keep c-j as submit so plain
Enter still works on thin PTYs.

Add install_ctrl_enter_alias() in hermes_cli/pt_input_extras.py
mapping the three CSI-u / modifyOtherKeys variants of Ctrl+Enter
('\x1b[13;5u', '\x1b[27;5;13~', '\x1b[27;5;13u') to the
(Escape, ControlM) tuple Alt+Enter produces. This lets Kitty /
mintty / xterm-with-modifyOtherKeys users over SSH get a Ctrl+Enter
newline through the existing Alt+Enter handler.

9 new tests + extended existing test_lf_enter_binds_to_submit_handler_posix
to cover bare-local vs SSH branches.

Closes NousResearch#22379.
…e_url (NousResearch#22780)

_try_activate_fallback() walked the chain by index without comparing
the candidate entry against the currently-failing backend. So a
misconfigured chain that listed the same provider+model as the primary,
or two custom_providers entries pointing at the same shim URL, would
loop the same failure 3x for the same backend.

After the fix, advance() skips:
  - entries where (provider, model) match the current agent's
  - entries with a base_url + model matching the current backend
    (catches two custom_providers names pointing at the same shim)

Recursing through self._try_activate_fallback() continues to the next
chain entry; if everything matches, returns False and the caller
moves on without retrying the same broken path.

3 regression tests covering same-provider-same-model skip, same-base_url-
same-model skip, and the all-self-matching-returns-False exhaustion path.

Closes NousResearch#22548 (the Hermes-side portion). The 120s timeout itself in
the downstream claude-cli shim is a deployment concern documented in
that issue's wherewolf87 comment.
…otes (NousResearch#22781)

The model regularly writes session-outcome facts to MEMORY.md despite
the existing 'Do NOT save task progress' line — entries like
'Submitted PR NousResearch#22577 for the kanban dedup fix' or 'Fixed bug X in
file Y'. These are stale within days, pollute the system prompt,
and crowd out durable user preferences (the issue NousResearch#22563 reporter
saw 9 sections of bug-fix notes injected on a brand-new task).

Add explicit examples of what NOT to save (PR numbers, issue
numbers, commit SHAs, 'fixed/submitted/Phase N done', file counts)
plus the 7-day-staleness heuristic so the model has a concrete
calibration target rather than guessing what counts as 'task progress'.

Closes NousResearch#22563 (the prompt-side, low-risk portion). The bigger
relevance-based-injection / vector-retrieval feature requested in
NousResearch#22563 is tracked under NousResearch#2184 (Richer local memory). Per skill rule
on prompt caching, dynamic memory injection breaks the frozen-snapshot
invariant and needs a separate design call.
…es tools' (NousResearch#22765)

Returning users who enabled '🖱️ Computer Use (macOS)' via 'hermes tools'
saw '✓ Saved configuration' but no install — cua-driver was never on
PATH and the toolset failed at first use. Two compounding causes:

1. _toolset_needs_configuration_prompt fell through to _toolset_has_keys,
   which returned True for any provider with empty env_vars. cua-driver
   has no env vars, so the gate skipped _configure_toolset entirely and
   _run_post_setup('cua_driver') never ran.

2. No stable CLI entry-point existed for re-running the install when
   the picker no-op'd it (e.g. when toggling the toolset off+on inside
   one picker session, where 'added' is empty).

Changes:

- hermes_cli/tools_config.py: add _POST_SETUP_INSTALLED registry
  mapping post_setup keys to installed-state predicates. The gate
  now returns True when any visible provider has a registered
  post_setup whose predicate fails. cua_driver is the only opt-in
  for now; other post_setup hooks keep their existing behaviour.
- hermes_cli/main.py: add 'hermes computer-use install' and
  'hermes computer-use status' as a stable docs target. install
  reuses the same _run_post_setup('cua_driver') path that the
  picker invokes; status reports whether cua-driver is on PATH.
- tools/computer_use/cua_backend.py: install hint now points users
  at 'hermes computer-use install' first.
- website/docs/user-guide/features/computer-use.md: document the
  new command as the primary install path.
- website/docs/reference/cli-commands.md: catalog 'hermes
  computer-use' alongside 'hermes tools'.
- tests/hermes_cli/test_post_setup_gating.py: regression coverage
  for the gate predicate (missing -> setup forced, installed ->
  setup skipped, broken predicate -> non-blocking, unregistered
  keys -> behaviour unchanged).

Fixes NousResearch#22737. Reported by @f-trycua.
…ousResearch#22766)

`hermes doctor` ran every connectivity probe sequentially and on a typical
developer laptop spent ~2s of its ~5s wall time inside boto3's EC2
instance-metadata-service lookup (169.254.169.254) — the default
AWS credential chain probes IMDS even when AWS_BEARER_TOKEN_BEDROCK
or AWS_ACCESS_KEY_ID is the only legitimate source.

Refactor the API Connectivity section so every probe (OpenRouter,
Anthropic, ~16 static API-key providers + dynamic profiles, AWS
Bedrock) is a pure function returning a structured result, then
fan them out through a ThreadPoolExecutor(max_workers=8). Output
order, glyphs, colours, padding, and issue strings stay byte-for-byte
identical to the sequential implementation; results are gathered
in submission order.

Also disable IMDS for the parallel block by setting
AWS_EC2_METADATA_DISABLED=true on the parent thread before submitting
work (and restoring its prior value in a finally block). Bedrock's
real-API call gets a Config(connect_timeout=5, read_timeout=10,
retries={max_attempts:1}) so a transient regional failure can't pad
the run by 30+ seconds.

Measured impact (5-run medians, 9950X3D):
  hermes doctor:           5.07 → 2.16 s  (-57%)

Doctor tests: 48 passed (test_doctor.py + test_doctor_command_install.py).

The remaining ~2s of wall is import overhead + a couple of one-off
network calls outside the API Connectivity section (`fetch_models_dev`
provider catalog refresh, Nous OAuth refresh in `Auth Providers`).
Those are next-tier targets, not part of this change.
…ation

Implements the synthesis-and-initiative pass that lets Hermes send
unprompted messages to the user when it has something genuinely worth
saying — requested by @CharlesMcDowell (2.2K views), endorsed by
Teknium: 'This is a good idea 🤔'

The Proactive Communication Loop has two modes:

RECENCY-ONLY (always available):
  Reviews the last N hours of conversation history. Good for completed
  task notifications, unresolved threads, inline watch instructions.

BARTOKGRAPH-AUGMENTED (when BartokGraph plugin is installed):
  Traverses a local knowledge graph built from the user's files and
  conversation history. Detects cross-temporal and cross-domain
  connections the user cannot see themselves — because they cannot
  hold months of context in their head.

  Three message types only BartokGraph enables:

  TEMPORAL_BRIDGE:   'You worked on this exact problem 3 weeks ago.
                      The solution you found then applies here.'

  CROSS_DOMAIN:      'Your regime detection work and your soil carbon
                      research share the same structure — both detect
                      state transitions in noisy time-series signals.'

  PERSON_KNOWLEDGE:  'Alice mentioned X last week. You asked about Y
                      today. These converge on Guruji objective NousResearch#6.'

BartokGraph runs entirely on-device. Local model priority:
  1. Explicit env override (BARTOKGRAPH_API_BASE + key + model)
  2. Ollama at localhost:11434 (default: qwen3:8b) — zero API cost
  3. LM Studio at localhost:1234 (auto-detected)
  4. Topology-only fallback (no LLM needed for basic overlap)

Users with a local LLM pay zero API cost for graph building.

New files:
  hermes_cli/proactive_communication_loop.py — core synthesis engine
  hermes_cli/bartokgraph_adapter.py          — BartokGraph ↔ Hermes bridge
  plugins/bartokgraph/__init__.py             — bundled plugin (standalone use)
  docs/features/proactive-communication-loop.md
  tests/test_proactive_communication_loop.py  — 17 tests, all passing

Design invariants:
  - Opt-in by default (proactive_communication.enabled = false)
  - Conservative threshold default (0.75) — prefer silence
  - Hard rate cap: max_per_day messages regardless of threshold
  - Fail-open: any error → silence, never spam
  - No state mutation: never touches session state or system prompt
  - BartokGraph is optional: graceful degradation to recency-only
  - Natural messages: 'Hey — just connected something...' not reports
- Clamp novelty/relevance to [0,1]; gate send on JSON should_send.
- Implement optional OpenAI-compatible port sweep (8080/8000/5000); UTF-8 graph load.
- Expand tests (adapter fixture, record_sent, overlap edges, smoke); asyncio.run for portability.
- Document config keys, graph contract, troubleshooting; align CLI claims with shipped scope.

Co-authored-by: Cursor <cursoragent@cursor.com>
Remove Mode 1 (recency-only synthesis) entirely.

The original design had two modes: recency-only (completed tasks,
unresolved threads) and BartokGraph-augmented. Mode 1 was removed
because it solves a problem that already has many solutions — every
task runner and notification system does this. It creates noise, not
magic.

The feature is Mode 2: Hermes traverses a weighted knowledge graph
built from months of conversation history and surfaces connections
the user cannot see themselves. Without a BartokGraph connection,
the loop stays silent. Deliberately.

Changes:
- loop.run_synthesis() returns no-send immediately if BartokGraph
  is unavailable (was: falls back to recency-only synthesis)
- loop.run_synthesis() returns no-send if graph finds no connections
  (was: falls back to recency-only synthesis)
- _build_synthesis_prompt() takes BartokGraphContext as required
  argument, not Optional. No graph = no prompt = no send.
- history_window_hours extended 16h -> 72h for better topic extraction
  (we want to match against 3 days of context, not just today)
- Module docstring rewritten to describe the actual vision: magic,
  not notifications
- _call_synthesis_model() wired to get_text_auxiliary_client()
  (same pattern as GoalManager in goals.py) — NotImplementedError removed
- Tests rewritten: Mode 1 tests removed, new tests verify that
  missing graph = silence (not fallback), and that the wiring is correct
…ng by importance

Implements the full three-layer weighting system from BartokGraph v2.0
ARCHITECTURE-V2.md so the Proactive Communication Loop surfaces the
connections that matter most — not just the semantically closest ones.

The previous traversal sorted by:
  strength × (1 + days_apart / 30)

This only used age as a proxy for surprise value and ignored node
importance entirely. A test file node would surface at the same priority
as a SOUL.md node with equal semantic overlap.

The new traversal scores connections by:
  surprise = semantic_strength × node_importance × temporal_decay

  semantic_strength  — Jaccard word-overlap (0–1)
  node_importance    — normalized 0–1 from source file type + layer
  temporal_decay     — log(1 + days_apart / 7), flattens at scale

Source file weights (from ARCHITECTURE-V2.md):
  SOUL.md / USER.md / MEMORY.md   → 50  (sacred identity)
  memory/YYYY-MM-DD.md            → 20  (daily logs)
  projects/**/*.md                → 15  (project knowledge)
  research/**                     → 10  (research notes)
  *.md / *.txt                    →  8  (general prose)
  *.html / *.pdf                  →  6
  *.json / *.jsonl                →  4
  *.py / *.ts / *.js / *.mjs     →  1  (code — low floor)
  test files                      →  0.1 (near-invisible)

Layer multipliers:
  knowledge / person              → 10x
  code                            →  1x

This means SOUL.md × knowledge layer = 500 effective weight.
A test file × code layer = 0.1 effective weight.
A test file node will never surface even with perfect semantic match.

Additional changes:
- Extended active topic extraction from 5 → 8 topics per pass
- min_semantic_strength lowered 0.35 → 0.2 (importance weighting
  does the real filtering now — we want broad candidate recall)
- Deduplicated results by node_b_content (same dormant concept matched
  by multiple active topics appears once, at its highest score)
- Graph load looks for both BartokGraph v2.0 output paths
- Person-tagged nodes correctly classified as person_knowledge
- Explanation strings include importance label and person attribution
  to help the judge model evaluate connection quality

Tests: 46 new tests in test_proactive_graph.py covering weight table,
node_importance, temporal decay, surprise scoring, semantic overlap,
connection classification, and full integration with synthetic graph
including SOUL.md, daily memory, project notes, code, test files,
person-tagged nodes, recent exclusion, and deduplication.

63 total, 0 fail.
…, no Supabase

Replaces the placeholder adapter with a full Python port of bartokgraph-v2.mjs.

All data stays on-device. No network calls. No Supabase. No telemetry.

bartokgraph.py — complete port of bartokgraph-v2.mjs (647 lines → 800 lines Python):
  - Three-layer architecture: knowledge (weighted prose), code (structure), person (filtered)
  - getFileWeight() → get_file_weight(): same weight table as the original
      SOUL.md/USER.md/MEMORY.md=50, daily logs=20, projects=15, research=12,
      prose=8, html/pdf=6, json=4, code=1, test files=0.1
  - extractKnowledge() → extract_knowledge(): headers, bold, rules, adjacency edges
  - extractCode() → extract_code(): functions, classes, imports, comment concepts
  - extractHTML() → extract_html(): strips scripts/styles, extracts title + prose
  - walkFiles() → walk_files(): depth-limited, SKIP_DIRS, SKIP_EXTENSIONS, 500KB cap
  - KnowledgeGraph class: add_node, add_edge, find_god_nodes, find_clusters, save, load
  - Agent-aware person config: bartokgraph-config.json in workspace root
    (no personal names hardcoded — config drives person filters)
  - Credential redaction on every file: API keys, JWTs, passwords → [CREDENTIAL]
  - generateReport() → generate_report(): god nodes + clusters, on-device privacy note
  - CLI: build / query / report (mirrors JS CLI exactly)
    python -m hermes_cli.bartokgraph build ~/workspace --all
    python -m hermes_cli.bartokgraph build ~/workspace --person alice
    python -m hermes_cli.bartokgraph query graph.json 'soil carbon'

bartokgraph_adapter.py — rewritten to use the real graph:
  - _load_or_build_graph(): loads existing graph.json or builds fresh via build_graph()
    Checks staleness (configurable rebuild_interval_days, default 7)
    Saves to workspace/.bartokgraph/ for next run
  - _precompute_topology(): pre-computes god node IDs and cluster membership
    for O(1) lookup during traversal
  - Connection scoring uses real graph weights + god node boost (1.5×) +
    cluster alignment boost (1.3× when topic and dormant node share a cluster)
  - Surprise = semantic × node_importance × temporal_decay × god_boost × cluster_boost

Removed: Supabase sync entirely. User knowledge never leaves the machine.
53 tests, 0 fail, ruff clean.
…k creative hour

Completes the Proactive Communication Loop: the full chain from graph
traversal to unprompted message delivery now runs on a schedule that
learns when each user does their best work.

Two new components:

proactive_scheduler.py — FlowAnalysis + ProactiveScheduler

  FlowAnalysis:
    Studies the last 30 days of message history per session.
    Three signals combined:
      30% — message frequency by hour (when are they most active?)
      40% — average message depth by hour (long messages = deep work)
      30% — session continuity (sustained hours, not brief check-ins)
    Produces a FlowProfile: peak_hour + confidence score.
    Refreshed weekly. Falls back to 9 AM when insufficient history.

  ProactiveScheduler:
    One instance per gateway lifetime, lives in the cron ticker thread.
    Checks every minute whether any active session is in its peak window.
    Peak window: ±15 minutes around the computed (or configured) peak hour.
    Fires run_synthesis() at most once per day per session.
    Dispatches synthesis onto the gateway async event loop via
    run_coroutine_threadsafe — non-blocking from the ticker thread.
    Delivers the result through the session's existing channel origin.
    Never raises — any error logs at DEBUG and continues.

  Config:
    proactive_communication.enabled          (default: false — opt-in)
    proactive_communication.peak_flow_hour   optional override (0-23)
    proactive_communication.threshold        conservative/balanced/eager
    timezone_offset_hours                    UTC offset for local time

gateway/run.py — wired into _start_cron_ticker:
    ProactiveScheduler initialized once at gateway startup.
    tick() called on every cron interval (every 60s by default).
    Internal gate prevents double-firing and enforces daily rate limit.

The complete loop:
  1. BartokGraph builds knowledge graph from workspace (auto, 7-day refresh)
  2. Flow analysis learns when user is in peak creative state
  3. Scheduler fires synthesis once per day at peak window
  4. Synthesis traverses graph against 72h session history
  5. Judge model scores connections — high bar, silence is default
  6. If worthy: Hermes sends message on user's channel, unprompted
  7. Most days: nothing sent. When it does fire, user is in flow.

21 new tests in test_proactive_scheduler.py.
74 total across all proactive tests, 0 fail, ruff clean.
Every node in the knowledge graph now carries the actual last-modified
time of its source file — not the timestamp when the graph was built.

Before this fix: a freshly-built graph set last_seen_ts=time.time() on
every node, making them all appear active right now. The Proactive
Communication Loop filters out nodes active in the last 24 hours. Net
result: the adapter returned zero connections on every fresh graph. The
feature was completely broken out of the box.

Changes (Grok commit d3658ee, applied and verified):
  walk_files()         → yields (file_path, mtime) tuples
  build_graph()        → unpacks tuple, threads file_mtime downstream
  extract_knowledge()  → accepts file_mtime, passes to every add_node()
  extract_code()       → same
  extract_html()       → same
  _extract_json()      → same
  add_node()           → accepts optional last_seen_ts, uses it when set
  GraphNode.last_seen_ts → default 0.0 (unknown = treat as old/dormant)

Tests added:
  test_build_graph_last_seen_ts_matches_file_mtime
    Uses os.utime to set a file 10 days old, verifies node.last_seen_ts
    is within 5 seconds of the file's mtime.
  test_build_graph_last_seen_ts_30_days_old
    Same for 30-day-old file — verifies age is 25–35 days, not 0.

76 tests, 0 fail.
@github-actions
Copy link
Copy Markdown

🚨 CRITICAL Supply Chain Risk Detected

This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging.

🚨 CRITICAL: Install-hook file added or modified

These files can execute code during package installation or interpreter startup.

Files:

hermes_cli/setup.py
skills/productivity/google-workspace/scripts/setup.py

Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 10, 2026

🔎 Lint report: feat/proactive-communication-loop-v2 vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 7928 on HEAD, 7683 on base (🆕 +245)

🆕 New issues (186):

Rule Count
unresolved-import 52
invalid-argument-type 42
unresolved-attribute 40
invalid-method-override 26
invalid-assignment 11
unused-type-ignore-comment 4
unsupported-operator 2
not-subscriptable 2
invalid-return-type 2
invalid-parameter-default 1
not-iterable 1
call-non-callable 1
invalid-type-form 1
no-matching-overload 1
First entries
gateway/platforms/msgraph_webhook.py:263: [unresolved-attribute] unresolved-attribute: Attribute `Response` is not defined on `None` in union `Unknown | None`
gateway/platforms/msgraph_webhook.py:136: [unresolved-attribute] unresolved-attribute: Attribute `Application` is not defined on `None` in union `Unknown | None`
tests/tools/test_microsoft_graph_client.py:8: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/gateway/test_tts_media_routing.py:86: [unresolved-attribute] unresolved-attribute: Object of type `bound method _MediaRoutingAdapter.send_voice(chat_id: str, audio_path: str, caption: str | None = None, reply_to: str | None = None, metadata: dict[str, Any] | None = None, **kwargs) -> CoroutineType[Any, Any, SendResult]` has no attribute `assert_not_awaited`
tests/tools/test_file_tools.py:380: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["REQUIRED when mode='patch'"]` and `Unknown | str | list[str] | bool`
gateway/platforms/base.py:2835: [invalid-argument-type] invalid-argument-type: Argument to bound method `BasePlatformAdapter._keep_typing` is incorrect: Expected `int | float`, found `dict[Unknown, Unknown] | None`
tests/cli/test_cli_shift_enter_newline.py:13: [unresolved-import] unresolved-import: Cannot resolve imported module `prompt_toolkit.keys`
hermes_cli/uninstall.py:324: [unresolved-attribute] unresolved-attribute: Module `winreg` has no member `HKEY_CURRENT_USER`
plugins/platforms/google_chat/adapter.py:3236: [unresolved-import] unresolved-import: Cannot resolve imported module `aiohttp`
gateway/platforms/base.py:2831: [invalid-assignment] invalid-assignment: Invalid subscript assignment with key of type `Literal["stop_event"]` and value of type `Event` on object of type `dict[str, dict[Unknown, Unknown] | None]`
gateway/platforms/signal.py:1329: [invalid-method-override] invalid-method-override: Invalid override of method `send_video`: Definition is incompatible with `BasePlatformAdapter.send_video`
tests/tools/test_file_tools.py:379: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(i: SupportsIndex, /) -> str, (s: slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> list[str]]` cannot be called with key of type `Literal["old_string"]` on object of type `list[str]`
tests/tools/test_delegate.py:245: [invalid-argument-type] invalid-argument-type: Argument to function `delegate_task` is incorrect: Expected `list[dict[str, Any]] | None`, found `str`
plugins/platforms/google_chat/adapter.py:2521: [invalid-method-override] invalid-method-override: Invalid override of method `send_voice`: Definition is incompatible with `BasePlatformAdapter.send_voice`
tests/tools/test_process_registry.py:741: [unresolved-import] unresolved-import: Cannot resolve imported module `psutil`
tests/hermes_cli/test_setup_irc.py:40: [invalid-argument-type] invalid-argument-type: Argument is incorrect: Expected `((...) -> Awaitable[dict[Unknown, Unknown]]) | None`, found `str | ((cfg) -> None) | (() -> bool) | ... omitted 4 union elements`
tests/hermes_cli/test_profile_distribution.py:52: [invalid-parameter-default] invalid-parameter-default: Default value of type `None` is not assignable to annotated parameter type `DistributionManifest`
plugins/platforms/google_chat/adapter.py:2535: [invalid-method-override] invalid-method-override: Invalid override of method `send_video`: Definition is incompatible with `BasePlatformAdapter.send_video`
tests/test_hermes_state_wal_fallback.py:19: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
agent/prompt_builder.py:664: [unused-type-ignore-comment] unused-type-ignore-comment: Unused blanket `type: ignore` directive
gateway/platforms/wecom.py:1454: [invalid-method-override] invalid-method-override: Invalid override of method `send_video`: Definition is incompatible with `BasePlatformAdapter.send_video`
plugins/teams_pipeline/models.py:162: [invalid-argument-type] invalid-argument-type: Argument is incorrect: Expected `Literal["transcript", "recording", "call_record"]`, found `Any | None`
tests/gateway/test_telegram_thread_fallback.py:897: [invalid-method-override] invalid-method-override: Invalid override of method `send`: Definition is incompatible with `BasePlatformAdapter.send`
plugins/teams_pipeline/runtime.py:94: [invalid-argument-type] invalid-argument-type: Argument to `TeamsMeetingPipeline.__init__` is incorrect: Expected `((TeamsMeetingSummaryPayload, dict[str, Any], dict[str, Any] | None, /) -> Awaitable[dict[str, Any]]) | None`, found `None | TeamsSummaryWriter`
tests/tools/test_microsoft_graph_auth.py:8: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
... and 161 more

✅ Fixed issues (30):

Rule Count
unresolved-attribute 12
unresolved-import 6
invalid-assignment 4
invalid-argument-type 3
unresolved-reference 2
unsupported-operator 1
call-non-callable 1
no-matching-overload 1
First entries
tools/environments/base.py:106: [unresolved-attribute] unresolved-attribute: Attribute `write` is not defined on `None` in union `IO[Unknown] | None`
plugins/platforms/google_chat/adapter.py:2942: [unresolved-import] unresolved-import: Module `hermes_cli.config` has no member `prompt`
plugins/platforms/google_chat/adapter.py:896: [unresolved-attribute] unresolved-attribute: Attribute `Unauthenticated` is not defined on `None` in union `Unknown | None`
tests/gateway/test_tts_media_routing.py:97: [invalid-assignment] invalid-assignment: Object of type `AsyncMock` is not assignable to attribute `send_document` of type `def send_document(self, chat_id: str, file_path: str, caption: str | None = None, file_name: str | None = None, reply_to: str | None = None, **kwargs) -> CoroutineType[Any, Any, SendResult]`
tests/gateway/test_tts_media_routing.py:106: [unresolved-attribute] unresolved-attribute: Object of type `bound method _MediaRoutingAdapter.send_document(chat_id: str, file_path: str, caption: str | None = None, file_name: str | None = None, reply_to: str | None = None, **kwargs) -> CoroutineType[Any, Any, SendResult]` has no attribute `assert_not_awaited`
gateway/platforms/base.py:2793: [invalid-assignment] invalid-assignment: Invalid subscript assignment with key of type `Literal["stop_event"]` and value of type `Event` on object of type `dict[str, dict[str, str] | None]`
tests/gateway/test_tts_media_routing.py:101: [unresolved-attribute] unresolved-attribute: Object of type `bound method _MediaRoutingAdapter.send_voice(chat_id: str, audio_path: str, caption: str | None = None, reply_to: str | None = None, **kwargs) -> CoroutineType[Any, Any, SendResult]` has no attribute `assert_awaited_once_with`
tests/gateway/test_tts_media_routing.py:96: [invalid-assignment] invalid-assignment: Object of type `AsyncMock` is not assignable to attribute `send_voice` of type `def send_voice(self, chat_id: str, audio_path: str, caption: str | None = None, reply_to: str | None = None, **kwargs) -> CoroutineType[Any, Any, SendResult]`
tools/environments/base.py:107: [unresolved-attribute] unresolved-attribute: Attribute `close` is not defined on `None` in union `IO[Unknown] | None`
gateway/platforms/telegram.py:1258: [invalid-argument-type] invalid-argument-type: Argument to constructor `int.__new__` is incorrect: Expected `str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc`, found `str | None`
plugins/platforms/google_chat/adapter.py:789: [unresolved-attribute] unresolved-attribute: Attribute `NotFound` is not defined on `None` in union `Unknown | None`
hermes_cli/main.py:5350: [unresolved-import] unresolved-import: Cannot resolve imported module `openai`
plugins/platforms/google_chat/adapter.py:903: [unresolved-attribute] unresolved-attribute: Attribute `PermissionDenied` is not defined on `None` in union `Unknown | None`
plugins/platforms/google_chat/adapter.py:2945: [unresolved-import] unresolved-import: Module `hermes_cli.config` has no member `print_success`
gateway/platforms/base.py:2797: [invalid-argument-type] invalid-argument-type: Argument to bound method `BasePlatformAdapter._keep_typing` is incorrect: Expected `int | float`, found `dict[str, str] | None`
agent/transports/types.py:65: [unresolved-reference] unresolved-reference: Name `Dict` used when not defined: Did you mean `dict`?
agent/anthropic_adapter.py:1537: [invalid-assignment] invalid-assignment: Invalid subscript assignment with key of type `Literal["cache_control"]` and value of type `dict[Unknown, Unknown]` on object of type `dict[str, str]`
tests/run_agent/test_concurrent_interrupt.py:192: [unsupported-operator] unsupported-operator: Operator `+=` is not supported between objects of type `None` and `Literal[1]`
plugins/platforms/google_chat/adapter.py:875: [unresolved-attribute] unresolved-attribute: Attribute `types` is not defined on `None` in union `Unknown | None`
plugins/platforms/google_chat/adapter.py:526: [unresolved-attribute] unresolved-attribute: Attribute `Credentials` is not defined on `None` in union `Unknown | None`
agent/transports/types.py:65: [unresolved-reference] unresolved-reference: Name `Optional` used when not defined
tests/gateway/test_tts_media_routing.py:86: [unresolved-attribute] unresolved-attribute: Object of type `bound method _MediaRoutingAdapter.send_voice(chat_id: str, audio_path: str, caption: str | None = None, reply_to: str | None = None, **kwargs) -> CoroutineType[Any, Any, SendResult]` has no attribute `assert_not_awaited`
plugins/platforms/google_chat/adapter.py:2946: [unresolved-import] unresolved-import: Module `hermes_cli.config` has no member `print_warning`
plugins/platforms/google_chat/adapter.py:2944: [unresolved-import] unresolved-import: Module `hermes_cli.config` has no member `print_info`
tests/gateway/test_tts_media_routing.py:81: [unresolved-attribute] unresolved-attribute: Object of type `bound method _MediaRoutingAdapter.send_document(chat_id: str, file_path: str, caption: str | None = None, file_name: str | None = None, reply_to: str | None = None, **kwargs) -> CoroutineType[Any, Any, SendResult]` has no attribute `assert_awaited_once_with`
... and 5 more

Unchanged: 4003 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Image eviction crashes on non-dict content blocks
    • Added a dict type guard so non-dict tool_result content blocks pass through without calling get().

Create PR

Or push these changes by commenting:

@cursor push 171f449b4d
Preview (171f449b4d)
diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py
--- a/agent/anthropic_adapter.py
+++ b/agent/anthropic_adapter.py
@@ -1835,7 +1835,7 @@
             _image_count += 1
             if _image_count > _MAX_KEEP_IMAGES:
                 block["content"] = [
-                    b if b.get("type") != "image"
+                    b if not isinstance(b, dict) or b.get("type") != "image"
                     else {"type": "text", "text": "[screenshot removed to save context]"}
                     for b in inner
                 ]

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 983201f. Configure here.

b if b.get("type") != "image"
else {"type": "text", "text": "[screenshot removed to save context]"}
for b in inner
]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image eviction crashes on non-dict content blocks

Medium Severity

The image eviction list comprehension calls b.get("type") on every element in inner, but b may not be a dict (e.g., a plain string). The has_image guard above correctly uses isinstance(b, dict) and b.get("type") == "image", but the replacement comprehension omits the isinstance check, causing an AttributeError if any element in inner is a non-dict type.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 983201f. Configure here.

Replaced all non-existent method calls with the real SessionDB API
from hermes_state.py:

ProactiveCommunicationLoop:
  get_messages_since() → get_messages() + timestamp filter
  get_proactive_sent() → get_meta(key) with JSON-encoded list
  record_proactive_sent() → set_meta(key, json) keyed by session+date
  get_state_meta() → get_meta()
  set_state_meta() → set_meta()

ProactiveScheduler:
  from agent.session_db import SessionDB → from hermes_state import SessionDB
  SessionDB(session_id=...) → SessionDB() (not session-scoped)
  db.get_messages_since() → db.get_messages() + cutoff filter
  SessionDB.list_active_sessions() → list_sessions_rich(order_by_last_active=True)
    + filter by last_active >= cutoff_ts
  _deliver(): rewritten to use cron.scheduler._deliver_result()
    with synthetic job dict (same pattern cron jobs use for origin delivery)
    instead of non-existent gateway.session.get_session_origin() and
    gateway.run._deliver_to_origin()

Tests updated to mock get_messages/get_meta instead of the old methods.
76 tests, 0 fail.
@github-actions
Copy link
Copy Markdown

🚨 CRITICAL Supply Chain Risk Detected

This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging.

🚨 CRITICAL: Install-hook file added or modified

These files can execute code during package installation or interpreter startup.

Files:

hermes_cli/setup.py
skills/productivity/google-workspace/scripts/setup.py

Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting.

…ecture

Replaces the early-draft doc with accurate documentation of what
actually shipped. Key changes:

- Removed all 'Mode 1 / recency-only synthesis' — that was cut entirely.
  The PCL only sends when BartokGraph finds a connection.
- Added full BartokGraph section: three layers, file weight table,
  last_seen_ts mtime fix, god nodes, clusters, CLI usage.
- Added flow analysis section: three signals (frequency, depth,
  continuity), FlowProfile, peak hour learning, config override.
- Added surprise scoring formula with all five factors explained.
- Updated architecture diagram: BartokGraph → flow analysis →
  scheduler → synthesis → delivery (5-step, accurate).
- Updated new files list: 4 modules + 3 test files.
- Privacy section kept prominent: all on-device, no Supabase,
  credential redaction, no hardcoded personal names.
- Added connection type table with real message examples.
- Added configuration reference with all config keys and defaults.
@github-actions
Copy link
Copy Markdown

🚨 CRITICAL Supply Chain Risk Detected

This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging.

🚨 CRITICAL: Install-hook file added or modified

These files can execute code during package installation or interpreter startup.

Files:

hermes_cli/setup.py
skills/productivity/google-workspace/scripts/setup.py

Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting.

…l API

test_proactive_smoke.py:
  _SmokeSessionDB used get_messages_since/get_proactive_sent/record_proactive_sent
  — all three removed when PCL was wired to real SessionDB API. Updated to:
  get_messages() + get_meta() + set_meta() to match the real interface.
  Message fixtures used 'ts' key — updated to 'timestamp' (SessionDB field name).

test_proactive_scheduler.py:
  test_depth_signal_included checked for specific hour numbers in scores dict
  which is non-deterministic. Relaxed to just verify scores is non-empty.

78 tests, 0 fail.
@github-actions
Copy link
Copy Markdown

🚨 CRITICAL Supply Chain Risk Detected

This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging.

🚨 CRITICAL: Install-hook file added or modified

These files can execute code during package installation or interpreter startup.

Files:

hermes_cli/setup.py
skills/productivity/google-workspace/scripts/setup.py

Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting.

Bartok9 pushed a commit that referenced this pull request May 10, 2026
…search#22920)

Found 18 real Hermes-Agent stories from HN, X, and Reddit not yet
captured on the page. All URLs HTTP-verified to return 200 with
matching titles.

Reddit (15): r/hermesagent (Obsidian-as-memory writeup at 794 upvotes,
LLM cheatsheet at 635 upvotes, Kanban game-changer post, OpenRouter #1
ranking, AMA from the Nous team, etc.); r/LocalLLaMA, r/Rag,
r/openclaw, r/SideProject, r/LocalLLM threads where users describe
their actual setups (Qwen3.5-9b on 16gb VRAM, 5060Ti + Telegram, smart
routing tiers).

X (3): @vmiss33's 'what I use Hermes for' guide, @HeyYanvi's
X-to-NotebookLM podcast workflow, @ExileAI_0's spare-laptop Iris
running RenPy + ComfyUI, @brucexu_eth's Hermes Inc. Telegram startup
sim from the hackathon, Hype's deep-dive blog.

HN (1): 'I'm using Hermes — sandbox it like any agent.'

No component changes — all new entries fit the existing schema
(real URL, real author, real date).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.