From dd8bc4aba245c3c189837c36ac5fd02cfd2e4931 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 21 Jun 2026 05:31:03 +0200 Subject: [PATCH 1/4] Update Hermes and plugins for unified stores --- codex-plugin/README.md | 14 +- .../fixing-build-and-type-errors/SKILL.md | 2 +- codex-plugin/skills/porting-code/SKILL.md | 2 +- .../skills/recalling-project-memory/SKILL.md | 2 +- .../skills/running-impacted-tests/SKILL.md | 2 +- .../fixing-build-and-type-errors/SKILL.md | 2 +- cursor-plugin/skills/porting-code/SKILL.md | 2 +- .../skills/recalling-project-memory/SKILL.md | 2 +- .../skills/running-impacted-tests/SKILL.md | 2 +- scripts/hermes_plugin_unit_check.py | 25 ++- src/agents/hermes.rs | 19 +- src/agents/hermes/lifecycle.rs | 139 ++------------- src/agents/hermes/profile_config.rs | 36 +--- src/agents/hermes/templates.rs | 105 ++++++----- src/agents/hermes/tokensave_migration.rs | 166 ------------------ src/agents/mod.rs | 8 +- tests/hermes_lcm_bridge_test.rs | 143 +++++++-------- tests/hermes_transcript_ingest_test.rs | 5 +- 18 files changed, 190 insertions(+), 486 deletions(-) delete mode 100644 src/agents/hermes/tokensave_migration.rs diff --git a/codex-plugin/README.md b/codex-plugin/README.md index d1b5ff6f..d39a8966 100644 --- a/codex-plugin/README.md +++ b/codex-plugin/README.md @@ -15,9 +15,11 @@ Codex. only when the workflow matches. These mirror the model-invocable Cursor skills so both hosts steer agents toward the same tracedecay tools. - **Lifecycle hooks** (`hooks/hooks.json`, referenced from the manifest's - `hooks` field): `SessionStart`, `UserPromptSubmit`, `SubagentStart`, and - `PostToolUse` handlers that inject index status and tool-routing steering and - keep the graph and session store warm. + `hooks` field): `SessionStart`, `UserPromptSubmit`, `SubagentStart`, + `PostToolUse`, and `PostCompact` handlers that inject index status and + tool-routing steering, keep the graph/session store warm, and replace + encrypted Codex compaction placeholders with auxiliary app-server summaries + backed by the visible source messages in TraceDecay's LCM DAG. Codex skips newly installed or changed command hooks until they are trusted — run `/hooks` in Codex to review and trust the tracedecay hooks. @@ -25,3 +27,9 @@ run `/hooks` in Codex to review and trust the tracedecay hooks. Codex has no always-applied rule surface (unlike Cursor's `rules/`), so the tool-routing steering Cursor places in a rule is injected through the `SessionStart`/`UserPromptSubmit` hooks instead. + +The `PostCompact` hook starts `codex app-server` as a short-lived child process +and sets `TRACEDECAY_CODEX_SUMMARY_CHILD=1` to prevent recursive summary hooks. +Set `TRACEDECAY_CODEX_BIN` to use a different Codex binary, +`TRACEDECAY_CODEX_SUMMARY_MODEL` to pin a model, or +`TRACEDECAY_CODEX_SUMMARY_TIMEOUT_SECS` to adjust the child timeout. diff --git a/codex-plugin/skills/fixing-build-and-type-errors/SKILL.md b/codex-plugin/skills/fixing-build-and-type-errors/SKILL.md index 33f6dfcc..a39db0fc 100644 --- a/codex-plugin/skills/fixing-build-and-type-errors/SKILL.md +++ b/codex-plugin/skills/fixing-build-and-type-errors/SKILL.md @@ -10,7 +10,7 @@ Use this when build or type diagnostics are relevant to the task. Prefer pasted ## Workflow 1. **Already have raw output? → `tracedecay_diagnose`** (`cargo_output` required, `severity?`: `error`|`warning`|`all`, `include_callers?`, `max_diagnostics?`): paste full `cargo check`/`clippy`/`rustc` stderr; each diagnostic maps to the smallest containing node with up to 5 callers pre-attached. No toolchain run — cheap and safe. -2. **Need fresh diagnostics → `tracedecay_diagnostics`** (`scope`: `workspace` (default) | `package` (needs `name`) | `file` (needs `path`)): structured errors/warnings, each mapped to the enclosing graph node. Forces target dir `.tracedecay/target/`; the **first** run on a fresh tree can take minutes, later calls are sub-second. +2. **Need fresh diagnostics → `tracedecay_diagnostics`** (`scope`: `workspace` (default) | `package` (needs `name`) | `file` (needs `path`)): structured errors/warnings, each mapped to the enclosing graph node. Forces target dir `/tmp/tracedecay-target//diagnostics`; the **first** run on a fresh tree can take minutes, later calls are sub-second. 3. **Understand the failing code:** resolve/inspect with the `tracedecay:searching-for-code` ladder; widen blast radius with `tracedecay_impact` if a fix is risky. 4. **Apply the fix → `tracedecay:atomic-code-edits`** (or your normal edit tools). 5. **Re-check** with the cheapest applicable diagnostic path, then verify behavior via `tracedecay:running-impacted-tests`. diff --git a/codex-plugin/skills/porting-code/SKILL.md b/codex-plugin/skills/porting-code/SKILL.md index 2238a42e..bcdb248f 100644 --- a/codex-plugin/skills/porting-code/SKILL.md +++ b/codex-plugin/skills/porting-code/SKILL.md @@ -21,7 +21,7 @@ description: Use when porting, migrating, or rewriting code between directories, - Never port a symbol before its dependencies (respect `tracedecay_port_order`). - `tracedecay_port_status` / `tracedecay_port_order` and the lookups are read-only; the editing tools and `tracedecay_diagnostics` mutate the working tree / run the toolchain. Use them when porting/verification is relevant and respect Cursor approval/run-mode. -- `tracedecay_diagnostics` forces target dir `.tracedecay/target/`; the first run can take minutes. +- `tracedecay_diagnostics` forces target dir `/tmp/tracedecay-target//diagnostics`; the first run can take minutes. ## Output diff --git a/codex-plugin/skills/recalling-project-memory/SKILL.md b/codex-plugin/skills/recalling-project-memory/SKILL.md index 7fda797c..e392bec5 100644 --- a/codex-plugin/skills/recalling-project-memory/SKILL.md +++ b/codex-plugin/skills/recalling-project-memory/SKILL.md @@ -9,7 +9,7 @@ Recall memory **before** reaching for external or web search — prior sessions ## Workflow -1. **Past conversations → `tracedecay_message_search`** (`query`, optional `provider`, `limit`) over ingested Cursor/Codex/agent transcripts (project-local FTS index). +1. **Past conversations → `tracedecay_message_search`** (`query`, optional `provider`, `limit`) over ingested Cursor/Codex/agent transcripts (active project FTS index). 2. **Durable facts → `tracedecay_fact_store`** with `action: "search"` (or `"probe"` / `"reason"`), plus `query` and `min_trust`. 3. **If the user asks to inspect or repair memory health → `tracedecay_memory_status`** (repairs derived vectors/banks; returns fact/entity counts + trust distribution). 4. **If the user rates a recalled fact → `tracedecay_fact_feedback`** (`helpful` / `unhelpful`) to tune its trust score. diff --git a/codex-plugin/skills/running-impacted-tests/SKILL.md b/codex-plugin/skills/running-impacted-tests/SKILL.md index 7154e85f..ab99e021 100644 --- a/codex-plugin/skills/running-impacted-tests/SKILL.md +++ b/codex-plugin/skills/running-impacted-tests/SKILL.md @@ -20,7 +20,7 @@ Use this when impacted-test verification is relevant to the task. Respect Cursor ## Guardrails -- `tracedecay_run_affected_tests` and `tracedecay_diagnostics` run cargo-backed checks, and the first `diagnostics` build can take minutes (forced target dir `.tracedecay/target/`). Respect Cursor approval/run-mode and avoid duplicate runs. +- `tracedecay_run_affected_tests` and `tracedecay_diagnostics` run cargo-backed checks, and the first `diagnostics` build can take minutes (forced target dir `/tmp/tracedecay-target//diagnostics`). Respect Cursor approval/run-mode and avoid duplicate runs. - `tracedecay_run_affected_tests` is cargo-only. For non-Rust repos use `tracedecay_diagnostics` (tsc/pyright) and the project's own test runner. - Steps 1–2 and 5 are read-only and safe to run first to preview scope before the user commits to a run. - Pure coverage questions ("which tests cover X", "is this tested", "where should the next test go") that don't need a run → `tracedecay:assessing-test-coverage`. diff --git a/cursor-plugin/skills/fixing-build-and-type-errors/SKILL.md b/cursor-plugin/skills/fixing-build-and-type-errors/SKILL.md index 33f6dfcc..a39db0fc 100644 --- a/cursor-plugin/skills/fixing-build-and-type-errors/SKILL.md +++ b/cursor-plugin/skills/fixing-build-and-type-errors/SKILL.md @@ -10,7 +10,7 @@ Use this when build or type diagnostics are relevant to the task. Prefer pasted ## Workflow 1. **Already have raw output? → `tracedecay_diagnose`** (`cargo_output` required, `severity?`: `error`|`warning`|`all`, `include_callers?`, `max_diagnostics?`): paste full `cargo check`/`clippy`/`rustc` stderr; each diagnostic maps to the smallest containing node with up to 5 callers pre-attached. No toolchain run — cheap and safe. -2. **Need fresh diagnostics → `tracedecay_diagnostics`** (`scope`: `workspace` (default) | `package` (needs `name`) | `file` (needs `path`)): structured errors/warnings, each mapped to the enclosing graph node. Forces target dir `.tracedecay/target/`; the **first** run on a fresh tree can take minutes, later calls are sub-second. +2. **Need fresh diagnostics → `tracedecay_diagnostics`** (`scope`: `workspace` (default) | `package` (needs `name`) | `file` (needs `path`)): structured errors/warnings, each mapped to the enclosing graph node. Forces target dir `/tmp/tracedecay-target//diagnostics`; the **first** run on a fresh tree can take minutes, later calls are sub-second. 3. **Understand the failing code:** resolve/inspect with the `tracedecay:searching-for-code` ladder; widen blast radius with `tracedecay_impact` if a fix is risky. 4. **Apply the fix → `tracedecay:atomic-code-edits`** (or your normal edit tools). 5. **Re-check** with the cheapest applicable diagnostic path, then verify behavior via `tracedecay:running-impacted-tests`. diff --git a/cursor-plugin/skills/porting-code/SKILL.md b/cursor-plugin/skills/porting-code/SKILL.md index 2238a42e..bcdb248f 100644 --- a/cursor-plugin/skills/porting-code/SKILL.md +++ b/cursor-plugin/skills/porting-code/SKILL.md @@ -21,7 +21,7 @@ description: Use when porting, migrating, or rewriting code between directories, - Never port a symbol before its dependencies (respect `tracedecay_port_order`). - `tracedecay_port_status` / `tracedecay_port_order` and the lookups are read-only; the editing tools and `tracedecay_diagnostics` mutate the working tree / run the toolchain. Use them when porting/verification is relevant and respect Cursor approval/run-mode. -- `tracedecay_diagnostics` forces target dir `.tracedecay/target/`; the first run can take minutes. +- `tracedecay_diagnostics` forces target dir `/tmp/tracedecay-target//diagnostics`; the first run can take minutes. ## Output diff --git a/cursor-plugin/skills/recalling-project-memory/SKILL.md b/cursor-plugin/skills/recalling-project-memory/SKILL.md index 7fda797c..e392bec5 100644 --- a/cursor-plugin/skills/recalling-project-memory/SKILL.md +++ b/cursor-plugin/skills/recalling-project-memory/SKILL.md @@ -9,7 +9,7 @@ Recall memory **before** reaching for external or web search — prior sessions ## Workflow -1. **Past conversations → `tracedecay_message_search`** (`query`, optional `provider`, `limit`) over ingested Cursor/Codex/agent transcripts (project-local FTS index). +1. **Past conversations → `tracedecay_message_search`** (`query`, optional `provider`, `limit`) over ingested Cursor/Codex/agent transcripts (active project FTS index). 2. **Durable facts → `tracedecay_fact_store`** with `action: "search"` (or `"probe"` / `"reason"`), plus `query` and `min_trust`. 3. **If the user asks to inspect or repair memory health → `tracedecay_memory_status`** (repairs derived vectors/banks; returns fact/entity counts + trust distribution). 4. **If the user rates a recalled fact → `tracedecay_fact_feedback`** (`helpful` / `unhelpful`) to tune its trust score. diff --git a/cursor-plugin/skills/running-impacted-tests/SKILL.md b/cursor-plugin/skills/running-impacted-tests/SKILL.md index 7154e85f..ab99e021 100644 --- a/cursor-plugin/skills/running-impacted-tests/SKILL.md +++ b/cursor-plugin/skills/running-impacted-tests/SKILL.md @@ -20,7 +20,7 @@ Use this when impacted-test verification is relevant to the task. Respect Cursor ## Guardrails -- `tracedecay_run_affected_tests` and `tracedecay_diagnostics` run cargo-backed checks, and the first `diagnostics` build can take minutes (forced target dir `.tracedecay/target/`). Respect Cursor approval/run-mode and avoid duplicate runs. +- `tracedecay_run_affected_tests` and `tracedecay_diagnostics` run cargo-backed checks, and the first `diagnostics` build can take minutes (forced target dir `/tmp/tracedecay-target//diagnostics`). Respect Cursor approval/run-mode and avoid duplicate runs. - `tracedecay_run_affected_tests` is cargo-only. For non-Rust repos use `tracedecay_diagnostics` (tsc/pyright) and the project's own test runner. - Steps 1–2 and 5 are read-only and safe to run first to preview scope before the user commits to a run. - Pure coverage questions ("which tests cover X", "is this tested", "where should the next test go") that don't need a run → `tracedecay:assessing-test-coverage`. diff --git a/scripts/hermes_plugin_unit_check.py b/scripts/hermes_plugin_unit_check.py index 68b1b5ac..ddc9c81d 100644 --- a/scripts/hermes_plugin_unit_check.py +++ b/scripts/hermes_plugin_unit_check.py @@ -352,11 +352,13 @@ def call_llm(self, **kwargs): assert degraded["matches"] == retrieval["matches"] ok("expand-query synthesis degrades on RuntimeError with retrieval intact") - # Storage scope: the pin never forces project_local anymore. + # Storage routing: the pin routes LCM/memory through the resolved project + # store instead of falling back to a hidden profile-local sessions DB. storage = plugin._storage_args("/some/pin", str(hermes_home)) - assert storage["storage_scope"] == "hermes_profile", storage - assert storage["hermes_home"] == str(hermes_home), storage - ok("LCM/memory storage stays hermes_profile-scoped") + assert storage["project_root"] == "/some/pin", storage + fallback_storage = plugin._storage_args(None, str(hermes_home)) + assert fallback_storage["project_root"] == str(hermes_home), fallback_storage + ok("LCM/memory storage routes through resolved project roots") # ── 4. provider hooks call the right verbs ─────────────────────────── provider = ctx.provider @@ -370,27 +372,32 @@ def call_llm(self, **kwargs): real_tool = plugin.tools.call_tracedecay_tool try: plugin.tools.call_tracedecay_tool = lambda name, args, **kw: ( - calls.append((name, args)) or "{}" + calls.append((name, args, kw)) or "{}" ) provider.sync_turn("u", "a", session_id="other-session", messages=messages) - name, args = calls[-1] + name, args, kwargs = calls[-1] assert name == "tracedecay_lcm_preflight", calls assert args["session_id"] == "other-session" assert args["messages"] == messages - assert args["storage_scope"] == "hermes_profile" + assert args["project_root"] == str(hermes_home), args + assert kwargs["project_root"] == str(hermes_home), kwargs ok("sync_turn ingests via tracedecay_lcm_preflight") provider.sync_turn("only user", "and assistant", session_id="s2", messages=None) - name, args = calls[-1] + name, args, kwargs = calls[-1] + assert name == "tracedecay_lcm_preflight", calls assert args["messages"][0]["content"] == "only user" assert args["messages"][1]["content"] == "and assistant" + assert args["project_root"] == str(hermes_home), args + assert kwargs["project_root"] == str(hermes_home), kwargs ok("sync_turn synthesizes a turn when messages are missing") provider.on_memory_write("add", "user", "likes rust", {"session_id": "s"}) - name, args = calls[-1] + name, args, kwargs = calls[-1] assert name == "tracedecay_fact_store", calls assert args["action"] == "add" and args["category"] == "user_pref" assert args["metadata"]["hermes_action"] == "add" + assert kwargs["project_root"] == str(hermes_home), kwargs before = len(calls) provider.on_memory_write("remove", "memory", "anything") assert len(calls) == before diff --git a/src/agents/hermes.rs b/src/agents/hermes.rs index 771ccc52..d9183dc7 100644 --- a/src/agents/hermes.rs +++ b/src/agents/hermes.rs @@ -6,7 +6,6 @@ mod dashboard_wrapper; mod lifecycle; mod profile_config; -mod tokensave_migration; use std::io::ErrorKind; use std::path::{Path, PathBuf}; @@ -70,9 +69,9 @@ impl AgentIntegration for HermesIntegration { } fn has_tracedecay(&self, home: &Path) -> bool { - let locations = tokensave_migration::profile_locations(home, None); - locations.plugin_dir.join("plugin.yaml").exists() - || locations.legacy_plugin_dir.join("plugin.yaml").exists() + hermes_profile_dir(home, None) + .join("plugins/tracedecay/plugin.yaml") + .exists() } } @@ -283,8 +282,6 @@ pub(super) fn write_plugin_files(plugin_dir: &Path, tracedecay_bin: &str) -> Res /// Plugin directories with a detected generated install (a `plugin.yaml` /// manifest), deduplicated across the default profile, named profiles, /// `HERMES_HOME`, and the current directory's project-local `.hermes`. -/// Legacy `TokenSave` manifests are mapped to their steady-state tracedecay -/// plugin directory so refreshes write current artifacts in-place. pub(super) fn detected_plugin_dirs(home: &Path) -> Vec { let mut roots = vec![hermes_home(home)]; if let Some(env_home) = std::env::var_os("HERMES_HOME") { @@ -301,8 +298,12 @@ pub(super) fn detected_plugin_dirs(home: &Path) -> Vec { roots .into_iter() .filter_map(|root| { - let plugin_dir = tokensave_migration::detected_plugin_dir(&root)?; - seen.insert(plugin_dir.clone()).then_some(plugin_dir) + let plugin_dir = root.join("plugins/tracedecay"); + plugin_dir + .join("plugin.yaml") + .is_file() + .then_some(plugin_dir) + .filter(|plugin_dir| seen.insert(plugin_dir.clone())) }) .collect() } @@ -327,9 +328,7 @@ pub(super) fn remove_generated_plugin_files(plugin_dir: &Path) -> Result<()> { remove_generated_file(&plugin_dir.join("__init__.py"))?; remove_generated_file(&plugin_dir.join("cli.py"))?; remove_generated_file(&plugin_dir.join("skills/tracedecay/SKILL.md"))?; - remove_generated_file(&plugin_dir.join("skills/tokensave/SKILL.md"))?; remove_empty_dir(&plugin_dir.join("skills/tracedecay"))?; - remove_empty_dir(&plugin_dir.join("skills/tokensave"))?; remove_empty_dir(&plugin_dir.join("skills"))?; dashboard_wrapper::uninstall(plugin_dir)?; diff --git a/src/agents/hermes/lifecycle.rs b/src/agents/hermes/lifecycle.rs index 7bc99fe7..a9444932 100644 --- a/src/agents/hermes/lifecycle.rs +++ b/src/agents/hermes/lifecycle.rs @@ -13,22 +13,18 @@ use crate::errors::Result; #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct InstallOutcome { pub plugin_dir: PathBuf, - pub legacy_plugin_dir: PathBuf, } #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct UninstallOutcome { pub plugin_dir: PathBuf, - pub legacy_plugin_dir: PathBuf, } pub(super) fn install(ctx: &InstallContext) -> Result { let profile = super::normalize_profile(ctx.profile.as_deref())?; - let locations = super::tokensave_migration::profile_locations(&ctx.home, profile.as_deref()); - let plugin_dir = locations.plugin_dir.clone(); - let legacy_plugin_dir = locations.legacy_plugin_dir.clone(); + let plugin_dir = + super::hermes_profile_dir(&ctx.home, profile.as_deref()).join("plugins/tracedecay"); - super::tokensave_migration::migrate_before_install(&locations)?; super::install_plugin( &plugin_dir, &ctx.tracedecay_bin, @@ -41,22 +37,18 @@ pub(super) fn install(ctx: &InstallContext) -> Result { eprintln!(" 1. cd into your project and run: tracedecay init"); eprintln!(" 2. Start Hermes — tracedecay plugin tools are now available"); - Ok(InstallOutcome { - plugin_dir, - legacy_plugin_dir, - }) + Ok(InstallOutcome { plugin_dir }) } pub(super) fn install_local(ctx: &InstallContext, project_path: &Path) -> Result { let profile = super::normalize_profile(ctx.profile.as_deref())?; - let locations = match profile.as_deref() { - Some(profile) => super::tokensave_migration::profile_locations(&ctx.home, Some(profile)), - None => super::tokensave_migration::project_local_locations(project_path), + let plugin_dir = match profile.as_deref() { + Some(profile) => { + super::hermes_profile_dir(&ctx.home, Some(profile)).join("plugins/tracedecay") + } + None => project_path.join(".hermes/plugins/tracedecay"), }; - let plugin_dir = locations.plugin_dir.clone(); - let legacy_plugin_dir = locations.legacy_plugin_dir.clone(); - super::tokensave_migration::migrate_before_install(&locations)?; super::install_plugin( &plugin_dir, &ctx.tracedecay_bin, @@ -71,10 +63,7 @@ pub(super) fn install_local(ctx: &InstallContext, project_path: &Path) -> Result ); } - Ok(InstallOutcome { - plugin_dir, - legacy_plugin_dir, - }) + Ok(InstallOutcome { plugin_dir }) } pub(super) fn update_plugin(ctx: &InstallContext) -> Result { @@ -91,20 +80,16 @@ pub(super) fn update_plugin(ctx: &InstallContext) -> Result /// Detection covers the default profile (`~/.hermes`), every named profile /// (`~/.hermes/profiles/*`), a `HERMES_HOME` override, and a project-local /// `.hermes` in the current directory — a plugin install is "detected" when -/// either its current or legacy generated `plugin.yaml` exists. For each -/// install the existing `plugins.tracedecay.project_root` pin is read from the -/// profile config (with a legacy `plugins.tokensave.project_root` fallback) and +/// its generated `plugin.yaml` exists. For each install the existing +/// `plugins.tracedecay.project_root` pin is read from the profile config and /// re-baked into refreshed artifacts. `update-plugin` does not rewrite -/// `config.yaml`; full config alias migration happens on install/reinstall. +/// `config.yaml`. fn refresh_installed_plugins(home: &Path, tracedecay_bin: &str) -> Result> { let mut refreshed = Vec::new(); for plugin_dir in super::detected_plugin_dirs(home) { - let pinned_project_root = super::effective_pinned_project_root(&plugin_dir) - .or_else(|| super::tokensave_migration::legacy_pinned_project_root(&plugin_dir)); - let had_dashboard = super::dashboard_wrapper::is_deployed(&plugin_dir) - || super::tokensave_migration::legacy_dashboard_deployed(&plugin_dir); + let pinned_project_root = super::effective_pinned_project_root(&plugin_dir); + let had_dashboard = super::dashboard_wrapper::is_deployed(&plugin_dir); - super::tokensave_migration::migrate_before_refresh(&plugin_dir)?; super::write_plugin_files(&plugin_dir, tracedecay_bin)?; super::dashboard_wrapper::refresh_if_previously_deployed( &plugin_dir, @@ -123,21 +108,16 @@ fn refresh_installed_plugins(home: &Path, tracedecay_bin: &str) -> Result Result { let profile = super::normalize_profile(ctx.profile.as_deref())?; - let locations = super::tokensave_migration::profile_locations(&ctx.home, profile.as_deref()); - let plugin_dir = locations.plugin_dir.clone(); - let legacy_plugin_dir = locations.legacy_plugin_dir.clone(); + let plugin_dir = + super::hermes_profile_dir(&ctx.home, profile.as_deref()).join("plugins/tracedecay"); super::uninstall_plugin(&plugin_dir)?; - super::tokensave_migration::remove_legacy_generated_plugin_if_present(&legacy_plugin_dir)?; eprintln!(); eprintln!("Uninstall complete. Tracedecay has been removed from Hermes."); eprintln!("Restart Hermes for changes to take effect."); - Ok(UninstallOutcome { - plugin_dir, - legacy_plugin_dir, - }) + Ok(UninstallOutcome { plugin_dir }) } #[cfg(test)] @@ -240,47 +220,9 @@ mod tests { } #[test] - fn update_migrates_legacy_install_artifacts_without_rewriting_config() { - let home = TempDir::new().unwrap(); - let project = TempDir::new().unwrap(); - let legacy_dir = home.path().join(".hermes/plugins/tokensave"); - std::fs::create_dir_all(legacy_dir.join("dashboard")).unwrap(); - std::fs::write(legacy_dir.join("plugin.yaml"), "name: tokensave\n").unwrap(); - std::fs::write(legacy_dir.join("dashboard/manifest.json"), "{}\n").unwrap(); - let config_path = home.path().join(".hermes/config.yaml"); - let pinned_root = serde_json::to_string(&project.path().display().to_string()).unwrap(); - std::fs::write( - &config_path, - format!( - "plugins:\n enabled:\n - tokensave\n tokensave:\n project_root: {pinned_root}\nmemory:\n provider: tokensave\ncontext:\n engine: tokensave\n# user data\nui:\n theme: dark\n" - ), - ) - .unwrap(); - let config_before = std::fs::read(&config_path).unwrap(); - - let outcome = with_hermes_home(&home.path().join(".hermes"), || { - update_plugin(&ctx(home.path(), NEW_BIN)).unwrap() - }); - - let plugin_dir = home.path().join(".hermes/plugins/tracedecay"); - assert!( - matches!(outcome, UpdatePluginOutcome::Refreshed(paths) if paths == vec![plugin_dir.clone()]) - ); - assert_eq!(std::fs::read(&config_path).unwrap(), config_before); - assert!(!legacy_dir.join("plugin.yaml").exists()); - assert!(plugin_dir.join("plugin.yaml").is_file()); - let api = text(&plugin_dir.join("dashboard/plugin_api.py")); - assert!(api.contains(NEW_BIN)); - assert!(api.contains(&pinned_root)); - } - - #[test] - fn uninstall_removes_generated_current_and_legacy_plugin_state() { + fn uninstall_removes_generated_current_plugin_state() { let home = TempDir::new().unwrap(); install(&ctx(home.path(), NEW_BIN)).unwrap(); - let legacy_dir = home.path().join(".hermes/plugins/tokensave"); - std::fs::create_dir_all(&legacy_dir).unwrap(); - std::fs::write(legacy_dir.join("plugin.yaml"), "name: tokensave\n").unwrap(); let outcome = uninstall(&ctx(home.path(), NEW_BIN)).unwrap(); @@ -289,7 +231,6 @@ mod tests { home.path().join(".hermes/plugins/tracedecay") ); assert!(!outcome.plugin_dir.join("plugin.yaml").exists()); - assert!(!outcome.legacy_plugin_dir.exists()); let config = text(&home.path().join(".hermes/config.yaml")); assert!( !config.contains("tracedecay"), @@ -297,50 +238,6 @@ mod tests { ); } - #[test] - fn install_recovers_from_legacy_partial_state_before_writing_current_plugin() { - let home = TempDir::new().unwrap(); - let legacy_dir = home.path().join(".hermes/plugins/tokensave"); - std::fs::create_dir_all(&legacy_dir).unwrap(); - std::fs::write(legacy_dir.join("plugin.yaml"), "name: tokensave\n").unwrap(); - - let outcome = install(&ctx(home.path(), NEW_BIN)).unwrap(); - - assert!( - !legacy_dir.exists(), - "legacy generated plugin dir should be removed first" - ); - assert!(outcome.plugin_dir.join("plugin.yaml").is_file()); - } - - #[test] - fn install_migrates_legacy_config_once_and_preserves_user_data() { - let home = TempDir::new().unwrap(); - let project = TempDir::new().unwrap(); - let legacy_dir = home.path().join(".hermes/plugins/tokensave"); - std::fs::create_dir_all(&legacy_dir).unwrap(); - std::fs::write(legacy_dir.join("plugin.yaml"), "name: tokensave\n").unwrap(); - let config_path = home.path().join(".hermes/config.yaml"); - let pinned_root = serde_json::to_string(&project.path().display().to_string()).unwrap(); - let original = format!( - "plugins:\n enabled:\n - tokensave\n tokensave:\n project_root: {pinned_root}\nmemory:\n provider: tokensave\ncontext:\n engine: tokensave\n# user data\nui:\n theme: dark\n" - ); - std::fs::write(&config_path, &original).unwrap(); - - install(&ctx(home.path(), NEW_BIN)).unwrap(); - let first = text(&config_path); - install(&ctx(home.path(), NEW_BIN)).unwrap(); - let second = text(&config_path); - - assert_eq!(first, second, "migration should be idempotent"); - assert!(second.contains("# user data\nui:\n theme: dark\n")); - assert!(second.contains("- tracedecay")); - assert!(second.contains("provider: tracedecay")); - assert!(second.contains("engine: tracedecay")); - assert!(!second.contains("tokensave")); - assert_eq!(text(&home.path().join(".hermes/config.yaml.bak")), original); - } - #[test] fn install_propagates_config_validation_failure_after_artifact_write() { let home = TempDir::new().unwrap(); diff --git a/src/agents/hermes/profile_config.rs b/src/agents/hermes/profile_config.rs index e56a507c..86fc1385 100644 --- a/src/agents/hermes/profile_config.rs +++ b/src/agents/hermes/profile_config.rs @@ -12,7 +12,6 @@ use crate::agents::backup_config_file; use crate::errors::{Result, TraceDecayError}; /// Reads `plugins.tracedecay.project_root` from a Hermes profile config.yaml. -/// Falls back to legacy `plugins.tokensave.project_root` when present. /// /// This is the single source of truth for the pin (the same /// `plugins.` block bundled Hermes plugins use): install writes it, @@ -21,9 +20,7 @@ pub(crate) fn read_config_pinned_project_root(config_path: &Path) -> Option = config.lines().collect(); let (plugins_start, plugins_end) = find_top_level_section_in(&lines, "plugins")?; - read_pinned_project_root_from_block(&lines, plugins_start, plugins_end, "tracedecay").or_else( - || read_pinned_project_root_from_block(&lines, plugins_start, plugins_end, "tokensave"), - ) + read_pinned_project_root_from_block(&lines, plugins_start, plugins_end, "tracedecay") } fn read_pinned_project_root_from_block( @@ -65,8 +62,7 @@ fn parse_yaml_scalar(value: &str) -> Option { /// `plugins.tracedecay.project_root` key of the profile config.yaml. /// /// A pin pointing at the profile home itself is the legacy storage-home -/// conflation (storage is profile-scoped now and code tools resolve per -/// cwd), so it is treated — and re-propagated on reinstall — as no pin. +/// conflation, so it is treated — and re-propagated on reinstall — as no pin. pub(super) fn effective_pinned_project_root(plugin_dir: &Path) -> Option { let profile_dir = plugin_dir.parent()?.parent()?; let pin = read_config_pinned_project_root(&profile_dir.join("config.yaml"))?; @@ -147,7 +143,6 @@ fn enable_plugin_list_config(existing: &str) -> std::result::Result { lines = remove_list_item(lines, start, end, "tracedecay"); - lines = remove_list_item(lines, start, end, "tokensave"); } ChildSection::Missing | ChildSection::EmptyFlow { .. } => {} } @@ -158,16 +153,6 @@ fn enable_plugin_list_config(existing: &str) -> std::result::Result { - lines = remove_list_item(lines, start, end, "tokensave"); - let (plugins_start, plugins_end) = - find_top_level_section_from_strings(&lines, "plugins") - .ok_or_else(|| "unsupported Hermes plugins config".to_string())?; - let ChildSection::Block { start, end } = - find_child_section_from_strings(&lines, plugins_start, plugins_end, "enabled") - .ok_or_else(|| "unsupported Hermes plugins config".to_string())? - else { - return Err("unsupported Hermes plugins config".to_string()); - }; if !list_contains_item_strings(&lines, start, end, "tracedecay") { // Match the existing list's item indentation (Hermes writes // 2-space items); only default to 4 when the list is empty. @@ -186,8 +171,7 @@ fn enable_plugin_list_config(existing: &str) -> std::result::Result std::result::Result { @@ -205,7 +189,6 @@ fn disable_plugin_config(existing: &str) -> std::result::Result { ChildSection::Block { start, end } => { lines = remove_list_item(lines, start, end, "tracedecay"); - lines = remove_list_item(lines, start, end, "tokensave"); } ChildSection::Missing | ChildSection::EmptyFlow { .. } => {} } @@ -236,9 +219,7 @@ fn enable_memory_provider_config(existing: &str) -> std::result::Result std::result::Result std::result::Result { + None | Some("compressor") => { lines[engine_line] = " engine: tracedecay".to_string(); } Some("tracedecay") => {} @@ -344,7 +325,7 @@ fn disable_context_engine_config(existing: &str) -> std::result::Result std::result::Result { - let without_new = remove_pinned_project_root_from_block(existing, "tracedecay")?; - remove_pinned_project_root_from_block(&without_new, "tokensave") + remove_pinned_project_root_from_block(existing, "tracedecay") } fn remove_pinned_project_root_from_block( diff --git a/src/agents/hermes/templates.rs b/src/agents/hermes/templates.rs index 7472e7ea..e1c7445d 100644 --- a/src/agents/hermes/templates.rs +++ b/src/agents/hermes/templates.rs @@ -92,10 +92,9 @@ MAX_CAPTURE_CHARS = 4000 # E2BIG/EFAULT at exec time. ARGS_FILE_THRESHOLD_BYTES = 100000 -# Profile-global state tools: memory facts and transcript search are owned by -# the Hermes profile, not by whichever code project the agent's cwd happens -# to resolve. Without an explicit project pin these anchor at the Hermes -# home so results never shard per working directory. +# Profile-state tools: memory facts and transcript search use the active +# pinned/resolved project when available, falling back to the Hermes profile +# home only for unpinned profiles. PROFILE_STORE_TOOLS = frozenset(( "tracedecay_fact_store", "tracedecay_fact_feedback", @@ -159,8 +158,7 @@ def config_pinned_project_root(hermes_home=None): return None pin = value.strip() # Legacy installs pinned the Hermes home itself as the "project" — that - # was a storage-home conflation, not a code project. Storage is - # profile-scoped now and code tools resolve per cwd, so a home-equal pin + # was a storage-home conflation, not a code project, so a home-equal pin # is treated as no pin at all. try: if os.path.realpath(pin) == os.path.realpath(hermes_home_dir(hermes_home)): @@ -235,21 +233,19 @@ def call_tracedecay_tool(name: str, args: dict, **kwargs) -> str: # Project routing: # 1. An explicit per-call project_root (call kwarg / tool arg) # wins for every tool. - # 2. Profile-global state tools (facts, transcript search) ALWAYS - # anchor at the Hermes home: an install-time pin is a - # code-project anchor for code-graph tools and must never - # reroute memory/transcript state away from the profile store. - # 3. Code-graph tools fall back to the install-time pin when one - # exists, else resolve per cwd: `tracedecay tool` walks up from - # the working directory to the nearest initialised project. + # 2. Hermes state tools use the active code project when one is + # pinned/resolved, so LCM and memory share the unified + # user-level tracedecay store for that project. + # 3. Unpinned Hermes profiles fall back to the profile home as + # their project identity; storage still lives in the user-level + # tracedecay registry, not under the Hermes profile directory. project_root = kwargs.get("project_root") or tool_args.get("project_root") if not project_root and tool_args.get("storage_scope") == "hermes_profile": project_root = None if not project_root and tool_args.get("storage_scope") != "hermes_profile": - if name in PROFILE_STORE_TOOLS: + project_root = code_project_root(cwd=kwargs.get("cwd") or tool_args.get("cwd")) + if not project_root and name in PROFILE_STORE_TOOLS: project_root = hermes_home_dir() - else: - project_root = code_project_root(cwd=kwargs.get("cwd") or tool_args.get("cwd")) argv = [TRACEDECAY_BIN, "tool"] if project_root: argv.extend(["--project", str(project_root)]) @@ -928,29 +924,9 @@ def _tracedecay_binary_available() -> bool: return shutil.which(tools.TRACEDECAY_BIN) is not None def _storage_args(project_root=None, hermes_home=None): - """Storage args for LCM/session state: always the Hermes profile store. - - Conversation state (LCM raw store, summary DAG) belongs to the profile, - not to whichever code project the agent happens to be exploring. The - legacy behavior — a ``project_root`` pin forcing ``project_local`` scope - for everything — conflated the storage home with the code project and - pointed code-graph tools at an index of the Hermes home itself, so the - pin no longer influences storage scope (``project_root`` is accepted for - call-site compatibility and ignored). - """ - del project_root - # _resolve_hermes_home() always yields a path (expanduser fallback), so - # hermes_profile scope is never emitted without its required home. - home = hermes_home or _resolve_hermes_home() - return {"storage_scope": "hermes_profile", "hermes_home": str(home)} - -def _hermes_profile_session_db_path(hermes_home): - if not hermes_home: - return None - primary = Path(hermes_home) / ".tracedecay" - legacy = Path(hermes_home) / ".tokensave" - base = legacy if not primary.is_dir() and legacy.is_dir() else primary - return (base / "sessions.db").as_posix() + """Storage args for LCM/session state in the unified tracedecay store.""" + root = project_root or hermes_home or _resolve_hermes_home() + return {"project_root": str(root)} if root else {} # Conventional config home: a `plugins.tracedecay` block in the profile # config.yaml (the same `plugins.` convention bundled Hermes plugins @@ -2671,7 +2647,7 @@ class TraceDecayContextEngine(ContextEngine): def get_status(self): storage = _storage_args(self.project_root, self.hermes_home) - hermes_home = storage.get("hermes_home") + project_root = storage.get("project_root") last_result = self.last_compress_result if not isinstance(last_result, dict): last_result = {"status": "never_ran"} @@ -2685,13 +2661,13 @@ class TraceDecayContextEngine(ContextEngine): "engine": self.name, "session_id": self.active_session_id, "active_session_id": self.active_session_id, - "storage_scope": storage.get("storage_scope"), - "hermes_home": hermes_home, - "lcm_session_db_path": _hermes_profile_session_db_path(hermes_home), + "storage_scope": "profile_sharded", + "hermes_home": self.hermes_home, + "lcm_project_root": project_root, + "lcm_session_db_path": None, "storage_note": ( - "Hermes LCM conversation state is stored in the Hermes profile " - ".tracedecay/sessions.db, or legacy .tokensave/sessions.db when " - "that is the existing profile store." + "Hermes LCM conversation state is stored in the unified " + "user-level tracedecay project store resolved from lcm_project_root." ), "project_root": self.project_root, "tracedecay_binary_path": tools.TRACEDECAY_BIN, @@ -3344,6 +3320,7 @@ class TracedecayMemoryProvider(MemoryProvider): def __init__(self): self.hermes_home = None + self.project_root = None self.session_id = None self.agent_context = "" self._prefetch_lock = threading.Lock() @@ -3359,6 +3336,15 @@ class TracedecayMemoryProvider(MemoryProvider): def initialize(self, session_id=None, **kwargs): self.hermes_home = kwargs.get("hermes_home") or _resolve_hermes_home() + config = _with_plugin_block(kwargs.get("config"), self.hermes_home) + self.project_root = ( + _code_project_root( + explicit=kwargs.get("project_root"), + cwd=kwargs.get("cwd"), + configured=_configured_project_root(config), + ) + or self.hermes_home + ) self.session_id = session_id # Execution context ("", "cron", "flush", ...): cron/flush runs are # not primary conversations and must not write turn state (cron @@ -3370,10 +3356,9 @@ class TracedecayMemoryProvider(MemoryProvider): """`hermes memory setup` hand-off: verify the binary and warm the store. tracedecay_memory_status repairs derived holographic vectors/banks - and creates the profile store on first touch, so one call doubles + and creates the resolved user-level store on first touch, so one call doubles as install repair + initialization. """ - del config if not _tracedecay_binary_available(): print( f" tracedecay binary not found at {tools.TRACEDECAY_BIN} — " @@ -3381,11 +3366,16 @@ class TracedecayMemoryProvider(MemoryProvider): ) return home = str(hermes_home or self.hermes_home or _resolve_hermes_home()) - status = call_tracedecay_json("tracedecay_memory_status", {}, project_root=home) + resolved_config = _with_plugin_block(config, home) + project_root = ( + _code_project_root(configured=_configured_project_root(resolved_config)) + or home + ) + status = call_tracedecay_json("tracedecay_memory_status", {}, project_root=project_root) if isinstance(status, dict) and not status.get("error"): facts = status.get("fact_count", status.get("facts")) suffix = f" ({facts} facts)" if facts is not None else "" - print(f" tracedecay memory store ready at {home}{suffix}.") + print(f" tracedecay memory store ready at {project_root}{suffix}.") else: detail = status.get("error") if isinstance(status, dict) else status print(f" tracedecay memory store check failed: {detail}") @@ -3455,6 +3445,7 @@ class TracedecayMemoryProvider(MemoryProvider): payload = call_tracedecay_json( "tracedecay_fact_store", {"action": "search", "query": text[:512], "limit": 3}, + project_root=self.project_root, ) except Exception as exc: logger.debug("tracedecay memory prefetch failed: %s", exc) @@ -3511,10 +3502,14 @@ class TracedecayMemoryProvider(MemoryProvider): for idx, entry in enumerate(turn_messages): role = str(entry.get("role") or "user") entry["id"] = f"tracedecay_sync_{batch_id}_{timestamp_ns}_{idx}_{role}" - args = _storage_args(None, self.hermes_home) + args = _storage_args(self.project_root, self.hermes_home) args.update({"session_id": sid, "messages": turn_messages}) try: - tools.call_tracedecay_tool("tracedecay_lcm_preflight", args) + tools.call_tracedecay_tool( + "tracedecay_lcm_preflight", + args, + project_root=self.project_root, + ) except Exception as exc: logger.debug("tracedecay sync_turn ingest failed: %s", exc) @@ -3538,7 +3533,11 @@ class TracedecayMemoryProvider(MemoryProvider): "metadata": fact_metadata, } try: - tools.call_tracedecay_tool("tracedecay_fact_store", fact_args) + tools.call_tracedecay_tool( + "tracedecay_fact_store", + fact_args, + project_root=self.project_root, + ) except Exception as exc: logger.debug("tracedecay on_memory_write mirror failed: %s", exc) diff --git a/src/agents/hermes/tokensave_migration.rs b/src/agents/hermes/tokensave_migration.rs deleted file mode 100644 index 9e6c0372..00000000 --- a/src/agents/hermes/tokensave_migration.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! Legacy TokenSave-to-TraceDecay migration helpers. -//! -//! The Hermes integration used to install generated plugin files under -//! `plugins/tokensave` and write `tokensave` aliases into `config.yaml`. The -//! steady-state lifecycle code should only deal with `tracedecay` installs; -//! this module centralizes the compatibility edge cases that detect legacy -//! layouts, preserve user files, and remove only generated legacy artifacts. - -use std::path::{Path, PathBuf}; - -use crate::errors::Result; - -/// Current and legacy plugin locations for one Hermes install scope. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct PluginLocations { - pub(super) plugin_dir: PathBuf, - pub(super) legacy_plugin_dir: PathBuf, -} - -pub(super) fn profile_locations(home: &Path, profile: Option<&str>) -> PluginLocations { - let profile_dir = super::hermes_profile_dir(home, profile); - PluginLocations::from_profile_dir(&profile_dir) -} - -pub(super) fn project_local_locations(project_path: &Path) -> PluginLocations { - PluginLocations::from_profile_dir(&project_path.join(".hermes")) -} - -impl PluginLocations { - fn from_profile_dir(profile_dir: &Path) -> Self { - let plugins_dir = profile_dir.join("plugins"); - Self { - plugin_dir: plugins_dir.join("tracedecay"), - legacy_plugin_dir: plugins_dir.join("tokensave"), - } - } -} - -/// Returns the current tracedecay plugin path when either the current or the -/// legacy `TokenSave` plugin has a generated manifest below `hermes_root`. -pub(super) fn detected_plugin_dir(hermes_root: &Path) -> Option { - let locations = PluginLocations::from_profile_dir(hermes_root); - let detected = locations.plugin_dir.join("plugin.yaml").is_file() - || locations.legacy_plugin_dir.join("plugin.yaml").is_file(); - detected.then_some(locations.plugin_dir) -} - -/// Removes generated legacy `TokenSave` plugin artifacts before an install. -/// -/// This intentionally does not edit `config.yaml`; the subsequent steady-state -/// enable path migrates `tokensave` config aliases to `tracedecay` in one -/// backup-protected write while preserving unrelated user data. -pub(super) fn migrate_before_install(locations: &PluginLocations) -> Result<()> { - remove_legacy_generated_plugin_if_present(&locations.legacy_plugin_dir) -} - -/// Removes generated legacy `TokenSave` plugin artifacts found next to a current -/// plugin dir during `update-plugin`, without rewriting profile config. -pub(super) fn migrate_before_refresh(plugin_dir: &Path) -> Result<()> { - if let Some(legacy_plugin_dir) = legacy_plugin_dir_for_current(plugin_dir) { - remove_legacy_generated_plugin_if_present(&legacy_plugin_dir)?; - } - Ok(()) -} - -/// Returns a project pin from the legacy `TokenSave` plugin's owning profile -/// config when no current tracedecay pin exists. -pub(super) fn legacy_pinned_project_root(plugin_dir: &Path) -> Option { - legacy_plugin_dir_for_current(plugin_dir) - .and_then(|legacy| super::effective_pinned_project_root(&legacy)) -} - -/// True when the legacy `TokenSave` plugin had a generated dashboard wrapper. -pub(super) fn legacy_dashboard_deployed(plugin_dir: &Path) -> bool { - legacy_plugin_dir_for_current(plugin_dir) - .as_deref() - .is_some_and(super::dashboard_wrapper::is_deployed) -} - -/// Removes legacy generated plugin files as part of uninstall after the current -/// plugin path has already disabled tracedecay/tokensave config aliases. -pub(super) fn remove_legacy_generated_plugin_if_present(legacy_plugin_dir: &Path) -> Result<()> { - if legacy_plugin_dir.exists() { - super::remove_generated_plugin_files(legacy_plugin_dir)?; - } - Ok(()) -} - -fn legacy_plugin_dir_for_current(plugin_dir: &Path) -> Option { - plugin_dir - .parent() - .map(|plugins_dir| plugins_dir.join("tokensave")) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use tempfile::TempDir; - - use super::*; - - #[test] - fn profile_locations_point_at_current_and_legacy_plugin_dirs() { - let home = TempDir::new().unwrap(); - - let locations = profile_locations(home.path(), Some("work")); - - assert_eq!( - locations.plugin_dir, - home.path().join(".hermes/profiles/work/plugins/tracedecay") - ); - assert_eq!( - locations.legacy_plugin_dir, - home.path().join(".hermes/profiles/work/plugins/tokensave") - ); - } - - #[test] - fn detection_maps_legacy_manifest_to_current_plugin_dir() { - let home = TempDir::new().unwrap(); - let legacy_dir = home.path().join(".hermes/plugins/tokensave"); - std::fs::create_dir_all(&legacy_dir).unwrap(); - std::fs::write(legacy_dir.join("plugin.yaml"), "name: tokensave\n").unwrap(); - - assert_eq!( - detected_plugin_dir(&home.path().join(".hermes")), - Some(home.path().join(".hermes/plugins/tracedecay")) - ); - } - - #[test] - fn migration_removes_generated_legacy_files_but_preserves_user_files() { - let home = TempDir::new().unwrap(); - let locations = profile_locations(home.path(), None); - std::fs::create_dir_all(locations.legacy_plugin_dir.join("skills/tokensave")).unwrap(); - std::fs::write( - locations.legacy_plugin_dir.join("plugin.yaml"), - "name: tokensave\n", - ) - .unwrap(); - std::fs::write( - locations - .legacy_plugin_dir - .join("skills/tokensave/SKILL.md"), - "generated skill\n", - ) - .unwrap(); - std::fs::write( - locations.legacy_plugin_dir.join("user-note.txt"), - "keep me\n", - ) - .unwrap(); - - migrate_before_install(&locations).unwrap(); - - assert!(!locations.legacy_plugin_dir.join("plugin.yaml").exists()); - assert!(!locations - .legacy_plugin_dir - .join("skills/tokensave/SKILL.md") - .exists()); - assert_eq!( - std::fs::read_to_string(locations.legacy_plugin_dir.join("user-note.txt")).unwrap(), - "keep me\n" - ); - } -} diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 23814183..c77ad82a 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -157,10 +157,10 @@ pub struct InstallContext { pub tracedecay_bin: String, pub tool_permissions: Vec, pub profile: Option, - /// Hermes only: pin the generated plugin's project root so every tool - /// call resolves this project's `.tracedecay/` stores regardless of the - /// host's working directory (`tracedecay install --agent hermes - /// --project-root `). `None` preserves any existing pin. + /// Hermes: pin the generated plugin to a project. Codex update/uninstall + /// can also use this as an explicit repo-local plugin target. Storage + /// resolves through the user/profile-level `TraceDecay` store scoped to this + /// project. `None` preserves any existing pin. pub project_root: Option, /// Hermes only: deploy the dashboard wrapper plugin page alongside the /// agent plugin (default; `tracedecay install --agent hermes diff --git a/tests/hermes_lcm_bridge_test.rs b/tests/hermes_lcm_bridge_test.rs index 0a404b60..01ccb18c 100644 --- a/tests/hermes_lcm_bridge_test.rs +++ b/tests/hermes_lcm_bridge_test.rs @@ -295,16 +295,11 @@ status = engine.get_status() assert status["engine"] == "tracedecay" assert status["session_id"] == "session-1" assert status["active_session_id"] == "session-1" -assert status["storage_scope"] == "hermes_profile" +assert status["storage_scope"] == "profile_sharded" assert status["hermes_home"] == os.environ["HERMES_HOME"] -assert status["lcm_session_db_path"].endswith("/.tracedecay/sessions.db") -assert "Hermes profile" in status["storage_note"] -legacy_home = pathlib.Path(os.environ["HERMES_HOME"]).parent / "legacy-hermes" -(legacy_home / ".tokensave").mkdir(parents=True) -legacy_engine = plugin.TraceDecayContextEngine(hermes_home=str(legacy_home)) -legacy_engine.initialize(session_id="legacy-session", hermes_home=str(legacy_home)) -legacy_status = legacy_engine.get_status() -assert legacy_status["lcm_session_db_path"].endswith("/.tokensave/sessions.db") +assert status["lcm_project_root"] == "/tmp/project" +assert status["lcm_session_db_path"] is None +assert "user-level tracedecay project store" in status["storage_note"] assert status["project_root"] == "/tmp/project" assert status["tracedecay_binary_path"] == plugin.tools.TRACEDECAY_BIN assert isinstance(status["tracedecay_binary_available"], bool) @@ -364,7 +359,7 @@ assert json.loads(implicit_current_result) == {"ok": True, "tool": "tracedecay_l assert calls[0][0] == "tracedecay_lcm_preflight" assert calls[0][1]["messages"] == [{"role": "user", "content": "current turn"}] assert calls[0][1]["session_id"] == "session-1" -assert calls[0][1]["storage_scope"] == "hermes_profile" +assert calls[0][1]["project_root"] == "/tmp/project" assert calls[1][0] == "tracedecay_lcm_grep" assert calls[1][1]["query"] == "orchard" assert calls[1][1]["scope"] == "current" @@ -377,8 +372,7 @@ assert "session_scope" not in calls[1][1] assert "time_from" not in calls[1][1] assert "time_to" not in calls[1][1] assert "messages" not in calls[1][1] -assert calls[1][1]["storage_scope"] == "hermes_profile" -assert calls[1][1]["hermes_home"] == os.environ["HERMES_HOME"] +assert calls[1][1]["project_root"] == "/tmp/project" assert calls[1][1]["session_id"] == "session-1" assert calls[1][2] == {} assert calls[2][0] == "tracedecay_lcm_preflight" @@ -410,15 +404,13 @@ assert calls[7][0] == "tracedecay_lcm_grep" assert calls[7][1]["query"] == "direct" assert calls[7][1]["scope"] == "all" assert "session_scope" not in calls[7][1] -assert calls[7][1]["storage_scope"] == "hermes_profile" -assert calls[7][1]["hermes_home"] == os.environ["HERMES_HOME"] +assert calls[7][1]["project_root"] == "/tmp/project" assert calls[7][1]["session_id"] == "session-1" assert calls[8][0] == "tracedecay_lcm_grep" assert calls[8][1]["query"] == "implicit" assert calls[8][1]["scope"] == "current" assert "session_scope" not in calls[8][1] -assert calls[8][1]["storage_scope"] == "hermes_profile" -assert calls[8][1]["hermes_home"] == os.environ["HERMES_HOME"] +assert calls[8][1]["project_root"] == "/tmp/project" assert calls[8][1]["session_id"] == "session-1" "#, "generated context engine should expose Hermes-style native LCM surface", @@ -506,7 +498,7 @@ assert engine.active_session_id == "new-session" } #[test] -fn generated_context_engine_uses_env_hermes_home_for_profile_storage() { +fn generated_context_engine_uses_env_hermes_home_for_unpinned_storage_identity() { run_generated_plugin_script( "check_context_engine_env_home.py", r#" @@ -548,8 +540,9 @@ engine = plugin.TraceDecayContextEngine() engine.initialize(session_id="session-1") assert engine.hermes_home == "/tmp/hermes-from-env" status = engine.get_status() -assert status["storage_scope"] == "hermes_profile" +assert status["storage_scope"] == "profile_sharded" assert status["hermes_home"] == "/tmp/hermes-from-env" +assert status["lcm_project_root"] == "/tmp/hermes-from-env" engine.handle_tool_call( "lcm_grep", @@ -558,14 +551,12 @@ engine.handle_tool_call( ) assert calls[0][0] == "tracedecay_lcm_preflight" -assert calls[0][1]["storage_scope"] == "hermes_profile" -assert calls[0][1]["hermes_home"] == "/tmp/hermes-from-env" +assert calls[0][1]["project_root"] == "/tmp/hermes-from-env" assert calls[0][1]["messages"] == [{"role": "user", "content": "profile current turn"}] assert calls[1][0] == "tracedecay_lcm_grep" -assert calls[1][1]["storage_scope"] == "hermes_profile" -assert calls[1][1]["hermes_home"] == "/tmp/hermes-from-env" +assert calls[1][1]["project_root"] == "/tmp/hermes-from-env" "#, - "generated context engine should resolve HERMES_HOME for profile storage", + "generated context engine should use HERMES_HOME as the unpinned storage project", ); } @@ -829,8 +820,9 @@ with tempfile.TemporaryDirectory() as tmp: assert normalized(engine.hermes_home) == normalized(expected), engine.hermes_home status = engine.get_status() - assert status["storage_scope"] == "hermes_profile" + assert status["storage_scope"] == "profile_sharded" assert normalized(status["hermes_home"]) == normalized(expected), status + assert normalized(status["lcm_project_root"]) == normalized(expected), status "#, "generated context engine should default to ~/.hermes even if missing", ); @@ -1011,27 +1003,19 @@ assert engine.active_session_id == "session-123" assert engine.hermes_home == "/tmp/hermes-profile" assert engine.project_root == "/tmp/project" -# LCM/session storage is always profile-scoped: a project_root pin is a -# code-project anchor for code-graph tools, never a storage-home switch. +# LCM/session storage routes through the unified user-level tracedecay store +# for the active project, falling back to the Hermes profile home only when +# no project is pinned/resolved. local_args = plugin._storage_args(project_root="/tmp/project", hermes_home="/tmp/hermes-profile") -assert local_args == { - "storage_scope": "hermes_profile", - "hermes_home": "/tmp/hermes-profile", -} +assert local_args == {"project_root": "/tmp/project"} profile_args = plugin._storage_args(hermes_home="/tmp/hermes-profile") -assert profile_args == { - "storage_scope": "hermes_profile", - "hermes_home": "/tmp/hermes-profile", -} +assert profile_args == {"project_root": "/tmp/hermes-profile"} fallback_args = plugin._storage_args() # Match the plugin's expanduser fallback byte-for-byte: pathlib normalizes # separators on Windows while expanduser("~/.hermes") emits mixed ones. -assert fallback_args == { - "storage_scope": "hermes_profile", - "hermes_home": os.path.expanduser("~/.hermes"), -} +assert fallback_args == {"project_root": os.path.expanduser("~/.hermes")} calls = [] @@ -1047,8 +1031,7 @@ profile_engine.should_compress_preflight(messages=[], current_tokens=123) name, args, kwargs = calls.pop() assert name == "tracedecay_lcm_preflight" assert args["session_id"] == "session-1" -assert args["storage_scope"] == "hermes_profile" -assert args["hermes_home"] == "/tmp/hermes" +assert args["project_root"] == "/tmp/hermes" project_engine = plugin.TraceDecayContextEngine() project_engine.on_session_start( @@ -1060,9 +1043,7 @@ project_engine.should_compress_preflight(messages=[], current_tokens=456) name, args, kwargs = calls.pop() assert name == "tracedecay_lcm_preflight" assert args["session_id"] == "session-2" -assert args["storage_scope"] == "hermes_profile" -assert args["hermes_home"] == "/tmp/hermes" -assert "project_root" not in args +assert args["project_root"] == "/tmp/project" project_engine = plugin.TraceDecayContextEngine() project_engine.initialize(session_id="initial", project_root="/tmp/project") @@ -1071,9 +1052,7 @@ project_engine.should_compress_preflight(messages=[], current_tokens=789) name, args, kwargs = calls.pop() assert name == "tracedecay_lcm_preflight" assert args["session_id"] == "next" -assert args["storage_scope"] == "hermes_profile" -assert args["hermes_home"] == os.path.expanduser("~/.hermes") -assert "project_root" not in args +assert args["project_root"] == "/tmp/project" profile_engine = plugin.TraceDecayContextEngine() profile_engine.initialize(session_id="initial", hermes_home="/tmp/hermes") @@ -1082,8 +1061,7 @@ profile_engine.should_compress_preflight(messages=[], current_tokens=321) name, args, kwargs = calls.pop() assert name == "tracedecay_lcm_preflight" assert args["session_id"] == "next" -assert args["storage_scope"] == "hermes_profile" -assert args["hermes_home"] == "/tmp/hermes" +assert args["project_root"] == "/tmp/hermes" class LegacyCtx: def register_tool(self, *args, **kwargs): @@ -1202,13 +1180,11 @@ assert result["messages"] == [] assert len(calls) == 1 argv = calls[0] assert argv[0] == plugin.tools.TRACEDECAY_BIN -assert argv[1:4] == ["tool", "tracedecay_lcm_preflight", "--json"] -assert "--project" not in argv +assert argv[1:6] == ["tool", "--project", "/tmp/project", "tracedecay_lcm_preflight", "--json"] args_index = argv.index("--args") args = json.loads(argv[args_index + 1]) assert args == { - "storage_scope": "hermes_profile", - "hermes_home": "/tmp/hermes-profile", + "project_root": "/tmp/project", "fresh_tail_count": 64, "leaf_chunk_tokens": 20000, "dynamic_leaf_chunk_enabled": False, @@ -1317,13 +1293,10 @@ engine.on_session_start( assert len(calls) == 1 argv = calls[0] assert argv[0] == plugin.tools.TRACEDECAY_BIN -assert argv[1:4] == ["tool", "tracedecay_lcm_session_boundary", "--json"] -assert "--project" not in argv +assert argv[1:6] == ["tool", "--project", "/tmp/project", "tracedecay_lcm_session_boundary", "--json"] args = json.loads(argv[argv.index("--args") + 1]) -# expanduser matches the plugin's fallback byte-for-byte on Windows too. assert args == { - "storage_scope": "hermes_profile", - "hermes_home": os.path.expanduser("~/.hermes"), + "project_root": "/tmp/project", "session_id": "session-b", "old_session_id": "session-c", "boundary_reason": "compression", @@ -1446,8 +1419,7 @@ assert engine.last_compress_result == {"status": "not_implemented", "message": " assert len(calls) == 1 argv = calls[0] assert argv[0] == plugin.tools.TRACEDECAY_BIN -assert argv[1] == "tool" -assert "--project" not in argv +assert argv[1:4] == ["tool", "--project", "/tmp/project"] tool_idx = argv.index("tracedecay_lcm_compress") assert argv[tool_idx + 1] == "--json" assert argv[tool_idx + 2] == "--args" @@ -1459,8 +1431,7 @@ else: args = json.loads(args_ref) # expanduser matches the plugin's fallback byte-for-byte on Windows too. assert args == { - "storage_scope": "hermes_profile", - "hermes_home": os.path.expanduser("~/.hermes"), + "project_root": "/tmp/project", "response_handle_project_root": "/tmp/project", "fresh_tail_count": 64, "leaf_chunk_tokens": 20000, @@ -1695,13 +1666,10 @@ answer = project_engine.expand_query(prompt="What changed?", query="orchard") assert answer["status"] == "ok" project_argv = calls.pop() assert project_argv[0] == plugin.tools.TRACEDECAY_BIN -# LCM session state is profile-scoped even for project-pinned engines. -assert project_argv[1:4] == ["tool", "tracedecay_lcm_expand_query", "--json"] -assert "--project" not in project_argv +# LCM session state uses the unified user-level store for the project. +assert project_argv[1:6] == ["tool", "--project", "/tmp/project", "tracedecay_lcm_expand_query", "--json"] project_args = json.loads(project_argv[project_argv.index("--args") + 1]) -assert project_args["storage_scope"] == "hermes_profile" -# expanduser matches the plugin's fallback byte-for-byte on Windows too. -assert project_args["hermes_home"] == os.path.expanduser("~/.hermes") +assert project_args["project_root"] == "/tmp/project" profile_engine = plugin.TraceDecayContextEngine() profile_engine.initialize(session_id="session-2", hermes_home="/tmp/hermes-profile") @@ -1710,11 +1678,9 @@ assert profile_result["status"] == "ok" assert profile_engine.should_compress_preflight([], current_tokens=100) is False profile_argv = calls.pop() assert profile_argv[0] == plugin.tools.TRACEDECAY_BIN -assert profile_argv[1:4] == ["tool", "tracedecay_lcm_preflight", "--json"] -assert "--project" not in profile_argv +assert profile_argv[1:6] == ["tool", "--project", "/tmp/hermes-profile", "tracedecay_lcm_preflight", "--json"] profile_args = json.loads(profile_argv[profile_argv.index("--args") + 1]) -assert profile_args["storage_scope"] == "hermes_profile" -assert profile_args["hermes_home"] == "/tmp/hermes-profile" +assert profile_args["project_root"] == "/tmp/hermes-profile" explicit = plugin.tools.call_tracedecay_tool( "tracedecay_lcm_status", @@ -1738,7 +1704,7 @@ assert explicit_argv[1:6] == ["tool", "--project", "/tmp/project", "tracedecay_l .expect("python3 should run generated Hermes project flag bridge check"); assert!( output.status.success(), - "generated bridge should pass project-local roots through tracedecay tool --project without affecting profile calls\nstdout:\n{}\nstderr:\n{}", + "generated bridge should pass resolved project roots through tracedecay tool --project without affecting explicit profile calls\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); @@ -1905,8 +1871,7 @@ def needs_synthesis(): def fake_call_tracedecay_tool(name, args, **kwargs): assert name == "tracedecay_lcm_expand_query" assert args["session_id"] == "session-1" - assert args["storage_scope"] == "hermes_profile" - assert args["hermes_home"] == os.environ["HERMES_HOME"] + assert args["project_root"] == "/tmp/project" assert args["prompt"] == "What changed?" assert args["query"] == "orchard" return mcp_response(responses.pop(0)) @@ -2344,12 +2309,19 @@ def fake_call_tracedecay_tool(name, args, **kwargs): plugin.tools.call_tracedecay_tool = fake_call_tracedecay_tool provider = plugin.TracedecayMemoryProvider() -provider.initialize(session_id="session-1", hermes_home="/tmp/hermes") +provider.initialize(session_id="session-1", hermes_home="/tmp/hermes", project_root="/tmp/project") provider.sync_turn("repeat", "same", session_id="session-1") provider.sync_turn("repeat", "same", session_id="session-1") provider.sync_turn("repeat", "same", session_id="session-1", messages=[]) provider.sync_turn("repeat", "same", session_id="session-1", messages=[]) +assert provider.project_root == "/tmp/project" +assert len(calls) == 4 +for name, args, kwargs in calls: + assert name == "tracedecay_lcm_preflight" + assert args["project_root"] == "/tmp/project" + assert kwargs["project_root"] == "/tmp/project" + first_messages = calls[0][1]["messages"] second_messages = calls[1][1]["messages"] empty_list_first_messages = calls[2][1]["messages"] @@ -2364,6 +2336,13 @@ assert all(message.get("id") for message in empty_list_first_messages) assert all(message.get("id") for message in empty_list_second_messages) assert empty_list_first_messages[0]["id"] != empty_list_second_messages[0]["id"] assert empty_list_first_messages[1]["id"] != empty_list_second_messages[1]["id"] + +fallback = plugin.TracedecayMemoryProvider() +fallback.initialize(session_id="session-2", hermes_home="/tmp/hermes") +fallback.sync_turn("user", "assistant", session_id="session-2") +assert fallback.project_root == "/tmp/hermes" +assert calls[-1][1]["project_root"] == "/tmp/hermes" +assert calls[-1][2]["project_root"] == "/tmp/hermes" "#, "sync_turn fallback messages should not collapse repeated identical turns", ); @@ -4708,20 +4687,22 @@ argv = captured[-1] idx = argv.index("--project") assert argv[idx + 1] == "/explicit/root", argv -# Profile-store tools stay anchored at the Hermes profile home, not the code pin. +# Profile-state tools follow the pinned project so memory and LCM share +# the same user-level project store. tools.call_tracedecay_tool("tracedecay_fact_store", {}) argv = captured[-1] idx = argv.index("--project") -assert os.path.samefile(argv[idx + 1], plugin._resolve_hermes_home()), argv -assert argv[idx + 1] != "/pinned/project", argv +assert argv[idx + 1] == "/pinned/project", argv -# Native LCM calls carry hermes_profile storage args and do not need a code --project. +# Native LCM calls from the generated engine carry project_root storage args, +# so direct hermes_profile calls remain only a backward-compatible escape hatch. tools.call_tracedecay_tool( "tracedecay_lcm_status", - {"storage_scope": "hermes_profile", "hermes_home": "/tmp/hermes-profile"}, + {"project_root": "/tmp/hermes-profile"}, ) argv = captured[-1] -assert "--project" not in argv, argv +idx = argv.index("--project") +assert argv[idx + 1] == "/tmp/hermes-profile", argv # Engine resolution: pin applies by default, config beats pin, kwargs beat # config, and cwd no longer overrides any pin. diff --git a/tests/hermes_transcript_ingest_test.rs b/tests/hermes_transcript_ingest_test.rs index 3a4b55ea..a058d970 100644 --- a/tests/hermes_transcript_ingest_test.rs +++ b/tests/hermes_transcript_ingest_test.rs @@ -496,9 +496,8 @@ async fn unpinned_profile_maps_to_its_own_home_store() { let stats = ingest_homes(&db, std::slice::from_ref(&hermes_home), &unrelated_project).await; assert_eq!(stats.messages_upserted, 0); - // Sweeping the profile home itself ingests into the profile-scoped store - // (`/.tracedecay/sessions.db`) — the store the generated - // plugin's hermes_profile storage scope serves. + // Sweeping the profile home itself ingests into the user-level tracedecay + // store for that profile-home project identity. let profile_db = open_project_session_db(&profile_dir).await.unwrap(); let stats = ingest_homes( &profile_db, From b35070b8358ce58ca619f99239c54cc801cf4273 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 21 Jun 2026 06:25:00 +0200 Subject: [PATCH 2/4] fix Hermes profile storage routing --- src/agents/hermes/profile_config.rs | 41 +++++++++++++++++++++++++++-- src/agents/hermes/templates.rs | 8 ++++-- tests/hermes_lcm_bridge_test.rs | 31 +++++++++++++++------- 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/agents/hermes/profile_config.rs b/src/agents/hermes/profile_config.rs index 86fc1385..6dd1e859 100644 --- a/src/agents/hermes/profile_config.rs +++ b/src/agents/hermes/profile_config.rs @@ -218,8 +218,11 @@ fn enable_memory_provider_config(existing: &str) -> std::result::Result std::result::Result Option<&str> { + let value = line.trim().strip_prefix("provider:")?.trim(); + Some(value.trim_matches(['"', '\''])) +} + fn disable_memory_provider_config(existing: &str) -> std::result::Result { if existing.trim().is_empty() { return Ok(existing.to_string()); @@ -855,6 +863,35 @@ mod tests { assert_eq!(second.matches("- tracedecay").count(), 1); } + #[test] + fn enable_plugin_upgrades_legacy_memory_provider_name() { + let dir = TempDir::new().unwrap(); + let config = dir.path().join("config.yaml"); + std::fs::write( + &config, + "plugins:\n enabled:\n - tracedecay\nmemory:\n provider: tokensave\n", + ) + .unwrap(); + + enable_plugin(&config, None).unwrap(); + + let updated = read(&config); + assert!(updated.contains("memory:\n provider: tracedecay\n")); + } + + #[test] + fn enable_plugin_still_rejects_unrelated_memory_provider() { + let dir = TempDir::new().unwrap(); + let config = dir.path().join("config.yaml"); + let original = "memory:\n provider: other\n"; + std::fs::write(&config, original).unwrap(); + + let err = enable_plugin(&config, None).unwrap_err().to_string(); + + assert!(err.contains("Hermes memory provider already configured")); + assert_eq!(read(&config), original); + } + #[test] fn enable_plugin_backs_up_existing_config_before_write() { let dir = TempDir::new().unwrap(); diff --git a/src/agents/hermes/templates.rs b/src/agents/hermes/templates.rs index e1c7445d..29750181 100644 --- a/src/agents/hermes/templates.rs +++ b/src/agents/hermes/templates.rs @@ -925,8 +925,12 @@ def _tracedecay_binary_available() -> bool: def _storage_args(project_root=None, hermes_home=None): """Storage args for LCM/session state in the unified tracedecay store.""" - root = project_root or hermes_home or _resolve_hermes_home() - return {"project_root": str(root)} if root else {} + if project_root: + return {"project_root": str(project_root)} + home = hermes_home or _resolve_hermes_home() + if home: + return {"storage_scope": "hermes_profile", "hermes_home": str(home)} + return {} # Conventional config home: a `plugins.tracedecay` block in the profile # config.yaml (the same `plugins.` convention bundled Hermes plugins diff --git a/tests/hermes_lcm_bridge_test.rs b/tests/hermes_lcm_bridge_test.rs index 01ccb18c..ab52a9fb 100644 --- a/tests/hermes_lcm_bridge_test.rs +++ b/tests/hermes_lcm_bridge_test.rs @@ -542,7 +542,7 @@ assert engine.hermes_home == "/tmp/hermes-from-env" status = engine.get_status() assert status["storage_scope"] == "profile_sharded" assert status["hermes_home"] == "/tmp/hermes-from-env" -assert status["lcm_project_root"] == "/tmp/hermes-from-env" +assert status["lcm_project_root"] is None engine.handle_tool_call( "lcm_grep", @@ -551,12 +551,14 @@ engine.handle_tool_call( ) assert calls[0][0] == "tracedecay_lcm_preflight" -assert calls[0][1]["project_root"] == "/tmp/hermes-from-env" +assert calls[0][1]["storage_scope"] == "hermes_profile" +assert calls[0][1]["hermes_home"] == "/tmp/hermes-from-env" assert calls[0][1]["messages"] == [{"role": "user", "content": "profile current turn"}] assert calls[1][0] == "tracedecay_lcm_grep" -assert calls[1][1]["project_root"] == "/tmp/hermes-from-env" +assert calls[1][1]["storage_scope"] == "hermes_profile" +assert calls[1][1]["hermes_home"] == "/tmp/hermes-from-env" "#, - "generated context engine should use HERMES_HOME as the unpinned storage project", + "generated context engine should use HERMES_HOME as the unpinned profile store", ); } @@ -1010,12 +1012,18 @@ local_args = plugin._storage_args(project_root="/tmp/project", hermes_home="/tmp assert local_args == {"project_root": "/tmp/project"} profile_args = plugin._storage_args(hermes_home="/tmp/hermes-profile") -assert profile_args == {"project_root": "/tmp/hermes-profile"} +assert profile_args == { + "storage_scope": "hermes_profile", + "hermes_home": "/tmp/hermes-profile", +} fallback_args = plugin._storage_args() # Match the plugin's expanduser fallback byte-for-byte: pathlib normalizes # separators on Windows while expanduser("~/.hermes") emits mixed ones. -assert fallback_args == {"project_root": os.path.expanduser("~/.hermes")} +assert fallback_args == { + "storage_scope": "hermes_profile", + "hermes_home": os.path.expanduser("~/.hermes"), +} calls = [] @@ -1031,7 +1039,8 @@ profile_engine.should_compress_preflight(messages=[], current_tokens=123) name, args, kwargs = calls.pop() assert name == "tracedecay_lcm_preflight" assert args["session_id"] == "session-1" -assert args["project_root"] == "/tmp/hermes" +assert args["storage_scope"] == "hermes_profile" +assert args["hermes_home"] == "/tmp/hermes" project_engine = plugin.TraceDecayContextEngine() project_engine.on_session_start( @@ -1061,7 +1070,8 @@ profile_engine.should_compress_preflight(messages=[], current_tokens=321) name, args, kwargs = calls.pop() assert name == "tracedecay_lcm_preflight" assert args["session_id"] == "next" -assert args["project_root"] == "/tmp/hermes" +assert args["storage_scope"] == "hermes_profile" +assert args["hermes_home"] == "/tmp/hermes" class LegacyCtx: def register_tool(self, *args, **kwargs): @@ -1678,9 +1688,10 @@ assert profile_result["status"] == "ok" assert profile_engine.should_compress_preflight([], current_tokens=100) is False profile_argv = calls.pop() assert profile_argv[0] == plugin.tools.TRACEDECAY_BIN -assert profile_argv[1:6] == ["tool", "--project", "/tmp/hermes-profile", "tracedecay_lcm_preflight", "--json"] +assert profile_argv[1:4] == ["tool", "tracedecay_lcm_preflight", "--json"] profile_args = json.loads(profile_argv[profile_argv.index("--args") + 1]) -assert profile_args["project_root"] == "/tmp/hermes-profile" +assert profile_args["storage_scope"] == "hermes_profile" +assert profile_args["hermes_home"] == "/tmp/hermes-profile" explicit = plugin.tools.call_tracedecay_tool( "tracedecay_lcm_status", From 87d9d6e228196b1156c9b17260de4e31b332e21f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 21 Jun 2026 06:47:56 +0200 Subject: [PATCH 3/4] fix Hermes profile storage routing --- scripts/hermes_plugin_unit_check.py | 20 +++++++---- src/agents/hermes/templates.rs | 54 +++++++++++++---------------- tests/agent_test.rs | 4 +-- tests/hermes_lcm_bridge_test.rs | 12 ++++--- 4 files changed, 47 insertions(+), 43 deletions(-) diff --git a/scripts/hermes_plugin_unit_check.py b/scripts/hermes_plugin_unit_check.py index ddc9c81d..606a353b 100644 --- a/scripts/hermes_plugin_unit_check.py +++ b/scripts/hermes_plugin_unit_check.py @@ -357,7 +357,8 @@ def call_llm(self, **kwargs): storage = plugin._storage_args("/some/pin", str(hermes_home)) assert storage["project_root"] == "/some/pin", storage fallback_storage = plugin._storage_args(None, str(hermes_home)) - assert fallback_storage["project_root"] == str(hermes_home), fallback_storage + assert fallback_storage["storage_scope"] == "hermes_profile", fallback_storage + assert fallback_storage["hermes_home"] == str(hermes_home), fallback_storage ok("LCM/memory storage routes through resolved project roots") # ── 4. provider hooks call the right verbs ─────────────────────────── @@ -379,8 +380,10 @@ def call_llm(self, **kwargs): assert name == "tracedecay_lcm_preflight", calls assert args["session_id"] == "other-session" assert args["messages"] == messages - assert args["project_root"] == str(hermes_home), args - assert kwargs["project_root"] == str(hermes_home), kwargs + assert args["storage_scope"] == "hermes_profile", args + assert args["hermes_home"] == str(hermes_home), args + assert kwargs["storage_scope"] == "hermes_profile", kwargs + assert kwargs["hermes_home"] == str(hermes_home), kwargs ok("sync_turn ingests via tracedecay_lcm_preflight") provider.sync_turn("only user", "and assistant", session_id="s2", messages=None) @@ -388,8 +391,10 @@ def call_llm(self, **kwargs): assert name == "tracedecay_lcm_preflight", calls assert args["messages"][0]["content"] == "only user" assert args["messages"][1]["content"] == "and assistant" - assert args["project_root"] == str(hermes_home), args - assert kwargs["project_root"] == str(hermes_home), kwargs + assert args["storage_scope"] == "hermes_profile", args + assert args["hermes_home"] == str(hermes_home), args + assert kwargs["storage_scope"] == "hermes_profile", kwargs + assert kwargs["hermes_home"] == str(hermes_home), kwargs ok("sync_turn synthesizes a turn when messages are missing") provider.on_memory_write("add", "user", "likes rust", {"session_id": "s"}) @@ -397,7 +402,10 @@ def call_llm(self, **kwargs): assert name == "tracedecay_fact_store", calls assert args["action"] == "add" and args["category"] == "user_pref" assert args["metadata"]["hermes_action"] == "add" - assert kwargs["project_root"] == str(hermes_home), kwargs + assert args["storage_scope"] == "hermes_profile", args + assert args["hermes_home"] == str(hermes_home), args + assert kwargs["storage_scope"] == "hermes_profile", kwargs + assert kwargs["hermes_home"] == str(hermes_home), kwargs before = len(calls) provider.on_memory_write("remove", "memory", "anything") assert len(calls) == before diff --git a/src/agents/hermes/templates.rs b/src/agents/hermes/templates.rs index 29750181..124426c7 100644 --- a/src/agents/hermes/templates.rs +++ b/src/agents/hermes/templates.rs @@ -229,23 +229,25 @@ def call_tracedecay_tool(name: str, args: dict, **kwargs) -> str: if "messages" in kwargs and "messages" not in tool_args: tool_args = dict(tool_args) tool_args["messages"] = kwargs["messages"] - payload = json.dumps(tool_args) # Project routing: # 1. An explicit per-call project_root (call kwarg / tool arg) # wins for every tool. # 2. Hermes state tools use the active code project when one is # pinned/resolved, so LCM and memory share the unified # user-level tracedecay store for that project. - # 3. Unpinned Hermes profiles fall back to the profile home as - # their project identity; storage still lives in the user-level - # tracedecay registry, not under the Hermes profile directory. + # 3. Unpinned Hermes profile-store calls use the hermes_profile + # storage scope instead of synthesizing a project root. project_root = kwargs.get("project_root") or tool_args.get("project_root") if not project_root and tool_args.get("storage_scope") == "hermes_profile": project_root = None if not project_root and tool_args.get("storage_scope") != "hermes_profile": project_root = code_project_root(cwd=kwargs.get("cwd") or tool_args.get("cwd")) if not project_root and name in PROFILE_STORE_TOOLS: - project_root = hermes_home_dir() + tool_args = dict(tool_args) + tool_args.setdefault("storage_scope", "hermes_profile") + tool_args.setdefault("hermes_home", hermes_home_dir()) + project_root = None + payload = json.dumps(tool_args) argv = [TRACEDECAY_BIN, "tool"] if project_root: argv.extend(["--project", str(project_root)]) @@ -2671,7 +2673,7 @@ class TraceDecayContextEngine(ContextEngine): "lcm_session_db_path": None, "storage_note": ( "Hermes LCM conversation state is stored in the unified " - "user-level tracedecay project store resolved from lcm_project_root." + "user-level tracedecay store; unpinned profiles use hermes_profile storage." ), "project_root": self.project_root, "tracedecay_binary_path": tools.TRACEDECAY_BIN, @@ -3341,13 +3343,10 @@ class TracedecayMemoryProvider(MemoryProvider): def initialize(self, session_id=None, **kwargs): self.hermes_home = kwargs.get("hermes_home") or _resolve_hermes_home() config = _with_plugin_block(kwargs.get("config"), self.hermes_home) - self.project_root = ( - _code_project_root( - explicit=kwargs.get("project_root"), - cwd=kwargs.get("cwd"), - configured=_configured_project_root(config), - ) - or self.hermes_home + self.project_root = _code_project_root( + explicit=kwargs.get("project_root"), + cwd=kwargs.get("cwd"), + configured=_configured_project_root(config), ) self.session_id = session_id # Execution context ("", "cron", "flush", ...): cron/flush runs are @@ -3371,15 +3370,14 @@ class TracedecayMemoryProvider(MemoryProvider): return home = str(hermes_home or self.hermes_home or _resolve_hermes_home()) resolved_config = _with_plugin_block(config, home) - project_root = ( - _code_project_root(configured=_configured_project_root(resolved_config)) - or home - ) - status = call_tracedecay_json("tracedecay_memory_status", {}, project_root=project_root) + project_root = _code_project_root(configured=_configured_project_root(resolved_config)) + storage = _storage_args(project_root, home) + status = call_tracedecay_json("tracedecay_memory_status", storage, **storage) if isinstance(status, dict) and not status.get("error"): facts = status.get("fact_count", status.get("facts")) suffix = f" ({facts} facts)" if facts is not None else "" - print(f" tracedecay memory store ready at {project_root}{suffix}.") + label = project_root or home + print(f" tracedecay memory store ready at {label}{suffix}.") else: detail = status.get("error") if isinstance(status, dict) else status print(f" tracedecay memory store check failed: {detail}") @@ -3446,11 +3444,9 @@ class TracedecayMemoryProvider(MemoryProvider): if not text: return "" try: - payload = call_tracedecay_json( - "tracedecay_fact_store", - {"action": "search", "query": text[:512], "limit": 3}, - project_root=self.project_root, - ) + args = _storage_args(self.project_root, self.hermes_home) + args.update({"action": "search", "query": text[:512], "limit": 3}) + payload = call_tracedecay_json("tracedecay_fact_store", args, **args) except Exception as exc: logger.debug("tracedecay memory prefetch failed: %s", exc) return "" @@ -3512,7 +3508,7 @@ class TracedecayMemoryProvider(MemoryProvider): tools.call_tracedecay_tool( "tracedecay_lcm_preflight", args, - project_root=self.project_root, + **args, ) except Exception as exc: logger.debug("tracedecay sync_turn ingest failed: %s", exc) @@ -3537,11 +3533,9 @@ class TracedecayMemoryProvider(MemoryProvider): "metadata": fact_metadata, } try: - tools.call_tracedecay_tool( - "tracedecay_fact_store", - fact_args, - project_root=self.project_root, - ) + args = _storage_args(self.project_root, self.hermes_home) + args.update(fact_args) + tools.call_tracedecay_tool("tracedecay_fact_store", args, **args) except Exception as exc: logger.debug("tracedecay on_memory_write mirror failed: %s", exc) diff --git a/tests/agent_test.rs b/tests/agent_test.rs index 8c1f72c1..e8947d90 100644 --- a/tests/agent_test.rs +++ b/tests/agent_test.rs @@ -1170,13 +1170,13 @@ assert provider.is_available() is False plugin.tools.TRACEDECAY_BIN = original_bin provider.initialize("session-123", hermes_home="/tmp/hermes-profile") assert provider.hermes_home == "/tmp/hermes-profile" -assert not hasattr(provider, "project_root") +assert provider.project_root is None assert provider.session_id == "session-123" # Without an explicit hermes_home the provider resolves the active profile # home itself (sync_turn/prefetch need a storage anchor). provider.initialize("session-only") assert provider.hermes_home == os.environ["HERMES_HOME"] -assert not hasattr(provider, "project_root") +assert provider.project_root is None assert provider.session_id == "session-only" # Collapsed schema surface: fact_store(action=...) covers the nine legacy diff --git a/tests/hermes_lcm_bridge_test.rs b/tests/hermes_lcm_bridge_test.rs index ab52a9fb..bd5ba6db 100644 --- a/tests/hermes_lcm_bridge_test.rs +++ b/tests/hermes_lcm_bridge_test.rs @@ -299,7 +299,7 @@ assert status["storage_scope"] == "profile_sharded" assert status["hermes_home"] == os.environ["HERMES_HOME"] assert status["lcm_project_root"] == "/tmp/project" assert status["lcm_session_db_path"] is None -assert "user-level tracedecay project store" in status["storage_note"] +assert "user-level tracedecay store" in status["storage_note"] assert status["project_root"] == "/tmp/project" assert status["tracedecay_binary_path"] == plugin.tools.TRACEDECAY_BIN assert isinstance(status["tracedecay_binary_available"], bool) @@ -824,7 +824,7 @@ with tempfile.TemporaryDirectory() as tmp: status = engine.get_status() assert status["storage_scope"] == "profile_sharded" assert normalized(status["hermes_home"]) == normalized(expected), status - assert normalized(status["lcm_project_root"]) == normalized(expected), status + assert status["lcm_project_root"] is None, status "#, "generated context engine should default to ~/.hermes even if missing", ); @@ -2351,9 +2351,11 @@ assert empty_list_first_messages[1]["id"] != empty_list_second_messages[1]["id"] fallback = plugin.TracedecayMemoryProvider() fallback.initialize(session_id="session-2", hermes_home="/tmp/hermes") fallback.sync_turn("user", "assistant", session_id="session-2") -assert fallback.project_root == "/tmp/hermes" -assert calls[-1][1]["project_root"] == "/tmp/hermes" -assert calls[-1][2]["project_root"] == "/tmp/hermes" +assert fallback.project_root is None +assert calls[-1][1]["storage_scope"] == "hermes_profile" +assert calls[-1][1]["hermes_home"] == "/tmp/hermes" +assert calls[-1][2]["storage_scope"] == "hermes_profile" +assert calls[-1][2]["hermes_home"] == "/tmp/hermes" "#, "sync_turn fallback messages should not collapse repeated identical turns", ); From ee92aeb5834e953a82fde4d15278909849c47515 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 21 Jun 2026 08:33:28 +0200 Subject: [PATCH 4/4] refactor: remove legacy Hermes provider alias --- src/agents/hermes/profile_config.rs | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/src/agents/hermes/profile_config.rs b/src/agents/hermes/profile_config.rs index 6dd1e859..5ff91d86 100644 --- a/src/agents/hermes/profile_config.rs +++ b/src/agents/hermes/profile_config.rs @@ -220,9 +220,7 @@ fn enable_memory_provider_config(existing: &str) -> std::result::Result