diff --git a/scripts/hermes_plugin_unit_check.py b/scripts/hermes_plugin_unit_check.py index 4d09874ed..da4e52f62 100644 --- a/scripts/hermes_plugin_unit_check.py +++ b/scripts/hermes_plugin_unit_check.py @@ -161,6 +161,7 @@ def run_checks(work: Path): sys.path.insert(0, str(plugin_dir.parent)) plugin = __import__("tracedecay") assert Path(plugin.__file__).resolve() == (plugin_dir / "__init__.py").resolve() + assert plugin.STANDARD_HERMES_LCM_PROVIDER == "hermes" ok("plugin package imports standalone (no hermes on sys.path)") header = (plugin_dir / "__init__.py").read_text(encoding="utf-8").splitlines()[0] diff --git a/scripts/hermes_stock_integration.sh b/scripts/hermes_stock_integration.sh index ba9ed8143..d67354e1a 100755 --- a/scripts/hermes_stock_integration.sh +++ b/scripts/hermes_stock_integration.sh @@ -36,12 +36,17 @@ fi echo "== stock hermes ref: $(git -C "$HERMES_UPSTREAM_DIR" rev-parse HEAD 2>/dev/null || echo unknown)" echo "== tracedecay binary: $TRACEDECAY_BIN ($("$TRACEDECAY_BIN" --version))" -# Upstream venv with upstream's exact-pinned lock (idempotent). -if [ ! -x "$HERMES_UPSTREAM_DIR/.venv/bin/python" ]; then +# Upstream source installs use either `.venv` (uv) or `venv` (Hermes installer). +if [ -x "$HERMES_UPSTREAM_DIR/.venv/bin/python" ]; then + HERMES_VENV="$HERMES_UPSTREAM_DIR/.venv" +elif [ -x "$HERMES_UPSTREAM_DIR/venv/bin/python" ]; then + HERMES_VENV="$HERMES_UPSTREAM_DIR/venv" +else echo "== creating stock hermes venv (uv sync --frozen --no-dev)" (cd "$HERMES_UPSTREAM_DIR" && uv sync --frozen --no-dev) + HERMES_VENV="$HERMES_UPSTREAM_DIR/.venv" fi -HERMES_PYTHON="$HERMES_UPSTREAM_DIR/.venv/bin/python" +HERMES_PYTHON="$HERMES_VENV/bin/python" STAGE="$(mktemp -d -t hermes-stock-XXXXXX)" trap 'rm -rf "$STAGE"' EXIT @@ -71,7 +76,7 @@ echo "== stock plugin manager / context engine / memory provider / dispatch chec echo "== hermes plugins list" PLUGINS_LIST="$(cd "$HERMES_UPSTREAM_DIR" && HOME="$FAKE_HOME" COLUMNS=200 \ - timeout 120 "$HERMES_UPSTREAM_DIR/.venv/bin/hermes" plugins list)" + timeout 120 "$HERMES_VENV/bin/hermes" plugins list)" echo "$PLUGINS_LIST" | grep tracedecay echo "$PLUGINS_LIST" | grep tracedecay | grep -q enabled echo "ok - hermes plugins list shows tracedecay enabled" diff --git a/src/agents/hermes/templates/plugin_init.py b/src/agents/hermes/templates/plugin_init.py index b4c5aedf7..d1ab40ca1 100644 --- a/src/agents/hermes/templates/plugin_init.py +++ b/src/agents/hermes/templates/plugin_init.py @@ -316,7 +316,7 @@ def _resolve_auxiliary_client(agent=None): "tracedecay_lcm_preflight", )) -STANDARD_HERMES_LCM_PROVIDER = "cursor" +STANDARD_HERMES_LCM_PROVIDER = "hermes" LCM_PROVIDER_LOCAL_TOOL_NAMES = frozenset(( "tracedecay_lcm_compress", diff --git a/src/agents/hermes/templates/skill.md b/src/agents/hermes/templates/skill.md index 50a14dda9..f62b083e7 100644 --- a/src/agents/hermes/templates/skill.md +++ b/src/agents/hermes/templates/skill.md @@ -20,6 +20,18 @@ shows parameters). Hermes tool calls already run through this CLI under the hood execution path without the plugin wrapper. Fall back to it instead of querying `.tracedecay` databases directly or abandoning tracedecay. +Do not invent per-key CLI flags or enum values from memory. Preserve the native +tool's exact JSON schema through `--args`; for example: + +```sh +tracedecay tool context --args '{"task":"trace project routing","mode":"explore","max_nodes":20,"include_code":true}' +``` + +For `context`, the supported modes are `explore` and `plan`, and its result +budget is expressed with `max_nodes` / `max_code_blocks`, not guessed flags such +as `--max-tokens` or `--paths`. When uncertain, run +`tracedecay tool --help` before invoking the fallback. + ## Storage and project identity Hermes may keep its own host files under its Hermes home, but that path never diff --git a/src/agents/mod.rs b/src/agents/mod.rs index a54529fe9..1ed323c11 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -825,6 +825,7 @@ or the server is disconnected, every tool is also available as a shell command: cli_fallback_args_invocation_lit!(), " \ (`tracedecay tool` lists all tools, `tracedecay tool --help` shows parameters). \ +Pass schema fields inside the JSON object; never invent per-key flags or enum values from memory. \ Fall back to that CLI instead of querying `.tracedecay` databases directly or abandoning tracedecay." ); diff --git a/src/hooks/steering.rs b/src/hooks/steering.rs index 3fbff1b2e..5ff729443 100644 --- a/src/hooks/steering.rs +++ b/src/hooks/steering.rs @@ -133,11 +133,10 @@ pub fn build_codex_session_context_for_workspace( tracedecay_diagnose; both map errors to symbols and callers.\n", ); s.push_str( - "Specialists: tracedecay-code-explorer, tracedecay-code-health-auditor, \ - tracedecay-session-historian, tracedecay-runtime-storage-doctor, \ - tracedecay-cross-host-integration-auditor, tracedecay-change-risk-reviewer, \ - tracedecay-usage-intelligence-analyst, tracedecay-automation-auditor. \ - Delegate matching read-only research; the parent owns changes and releases.\n", + "Agents: tracedecay-code-explorer,tracedecay-code-health-auditor,\ + tracedecay-session-historian,tracedecay-runtime-storage-doctor,\ + tracedecay-cross-host-integration-auditor,tracedecay-change-risk-reviewer,\ + tracedecay-usage-intelligence-analyst,tracedecay-automation-auditor\n", ); s.push_str(crate::agents::CLI_FALLBACK_PROMPT_RULES); s.push('\n'); diff --git a/src/main.rs b/src/main.rs index 130e74310..a82ea57e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -178,7 +178,12 @@ fn async_main() -> tracedecay::errors::Result<()> { .map_err(|e| tracedecay::errors::TraceDecayError::Config { message: format!("failed to start async runtime: {e}"), })?; - runtime.block_on(run(cli)) + let result = runtime.block_on(run(cli)); + // Runtime drop waits indefinitely for blocking tasks. Daemon integrations + // can leave OS-backed watcher work behind after their async handles abort, + // so bound teardown after the command's own graceful shutdown completes. + runtime.shutdown_timeout(std::time::Duration::from_secs(2)); + result } fn render_dynamic_command_help(args: &[String]) -> bool { diff --git a/src/memory/store.rs b/src/memory/store.rs index 23eede598..e8dc9ab05 100644 --- a/src/memory/store.rs +++ b/src/memory/store.rs @@ -562,13 +562,8 @@ impl<'a> MemoryStore<'a> { } pub async fn remove_fact(&self, fact_id: i64) -> Result { - let changed = self - .with_immediate_tx("remove_fact", self.remove_fact_inner(fact_id)) - .await?; - if changed { - self.incremental_vacuum().await?; - } - Ok(changed) + self.with_immediate_tx("remove_fact", self.remove_fact_inner(fact_id)) + .await } async fn remove_fact_inner(&self, fact_id: i64) -> Result { @@ -2306,34 +2301,6 @@ impl<'a> MemoryStore<'a> { self.mark_bank_dirty(category.as_str()).await } - async fn incremental_vacuum(&self) -> Result<()> { - let freelist_pages = { - let mut rows = self - .conn - .query("PRAGMA freelist_count", ()) - .await - .map_err(|e| db_error("incremental_vacuum", e))?; - let row = rows - .next() - .await - .map_err(|e| db_error("incremental_vacuum", e))? - .ok_or_else(|| { - db_message("incremental_vacuum", "freelist_count returned no rows") - })?; - row.get::(0) - .map_err(|e| db_error("incremental_vacuum", e))? - }; - if freelist_pages <= 0 { - return Ok(()); - } - - self.conn - .execute_batch(&format!("PRAGMA incremental_vacuum({freelist_pages});")) - .await - .map_err(|e| db_error("incremental_vacuum", e))?; - Ok(()) - } - async fn mark_bank_dirty(&self, bank_name: &str) -> Result<()> { // `updated_at` doubles as an optimistic-concurrency token: `rebuild_dirty_banks` only // clears a marker whose value still matches the row it snapshotted. Since diff --git a/src/sessions/lcm/compression.rs b/src/sessions/lcm/compression.rs index 5cdbdc569..0de23a4c4 100644 --- a/src/sessions/lcm/compression.rs +++ b/src/sessions/lcm/compression.rs @@ -1866,6 +1866,16 @@ async fn ingest_active_messages( let original_content = message_content_value(message); let storage_text = message_storage_text(&original_content); let search_text = message_content(message); + if message + .get("lcm_summary_node_id") + .and_then(Value::as_str) + .is_some_and(|node_id| !node_id.is_empty()) + { + let mut replay = message.clone(); + replay["role"] = Value::String(role); + replay_messages.push(replay); + continue; + } if security::ignore_message_reason_with_compiled(&search_text, &compiled_ignore_patterns) .is_some() { @@ -1874,15 +1884,26 @@ async fn ingest_active_messages( replay_messages.push(replay); continue; } - let message_id = message + let explicit_message_id = message .get("id") .or_else(|| message.get("message_id")) .and_then(Value::as_str) .filter(|value| !value.is_empty()) - .map_or_else( - || deterministic_message_id(provider, session_id, idx, &role, &storage_text), - str::to_string, - ); + .map(str::to_string); + let stored_message_id = match (explicit_message_id.as_ref(), message.get("store_id")) { + (None, Some(store_id)) => match store_id.as_i64() { + Some(store_id) => { + message_id_for_store_id(conn, provider, session_id, store_id).await? + } + None => None, + }, + _ => None, + }; + let message_id = explicit_message_id + .or(stored_message_id) + .unwrap_or_else(|| { + deterministic_message_id(provider, session_id, idx, &role, &storage_text) + }); let existing_state = existing_active_message_state(conn, provider, &message_id).await?; let ordinal = if let Some(existing) = existing_state.as_ref() { existing.ordinal @@ -1969,6 +1990,26 @@ async fn ingest_active_messages( }) } +async fn message_id_for_store_id( + conn: &Connection, + provider: &str, + session_id: &str, + store_id: i64, +) -> Result, LcmError> { + let mut rows = conn + .query( + "SELECT message_id + FROM lcm_raw_messages + WHERE provider = ?1 AND session_id = ?2 AND store_id = ?3", + params![provider, session_id, store_id], + ) + .await?; + Ok(match rows.next().await? { + Some(row) => Some(row.get(0)?), + None => None, + }) +} + fn message_content_value(message: &Value) -> Value { message .get("content") diff --git a/tests/agent_suite/agent_test.rs b/tests/agent_suite/agent_test.rs index 42b368fd1..8f1cd8bc4 100644 --- a/tests/agent_suite/agent_test.rs +++ b/tests/agent_suite/agent_test.rs @@ -1199,6 +1199,14 @@ fn test_hermes_user_install_writes_single_plugin() { skill.contains("instead of querying"), "Hermes skill must warn against querying .tracedecay databases directly" ); + assert!( + skill.contains("Do not invent per-key CLI flags") + && skill.contains("\"mode\":\"explore\"") + && skill.contains("\"max_nodes\":20") + && skill.contains("supported modes are `explore` and `plan`") + && skill.contains("`--max-tokens` or `--paths`"), + "Hermes skill must give schema-safe context fallback guidance" + ); assert_hermes_config_enables_tracedecay_memory(&home.path().join(".hermes/config.yaml")); assert!(!home.path().join(".hermes/profiles").exists()); @@ -1268,7 +1276,7 @@ fn test_hermes_plugin_init_snapshot_matches_embedded_asset() { hasher.update(body.as_bytes()); assert_eq!( hex::encode(hasher.finalize()), - "d92997d9bef2c033022ac3b8ee559720ce08779099d2d8b58df6e5d849d428df", + "954dfa34aad7753f3ad6ff5e536061066e8339f3ee167e65ea3f27ca5a879575", "templates/plugin_init.py payload hash changed — verify the edit is intentional and update this snapshot" ); } diff --git a/tests/agent_suite/cli_args_contract_test.rs b/tests/agent_suite/cli_args_contract_test.rs index 1f36bf04b..9395bc0fa 100644 --- a/tests/agent_suite/cli_args_contract_test.rs +++ b/tests/agent_suite/cli_args_contract_test.rs @@ -44,6 +44,11 @@ fn prompt_rules_teach_the_json_args_contract() { !rules.contains(" --key value"), "prompt rules must not lead with the per-key grammar" ); + let source = read_repo_file("src/agents/mod.rs"); + assert!( + source.contains("never invent per-key flags or enum values from memory"), + "CLI fallback prompt rules must prohibit guessed flags and enum values" + ); } #[test] diff --git a/tests/hermes_suite/lcm_bridge.rs b/tests/hermes_suite/lcm_bridge.rs index bcd593a55..ce6ec8c96 100644 --- a/tests/hermes_suite/lcm_bridge.rs +++ b/tests/hermes_suite/lcm_bridge.rs @@ -1233,7 +1233,7 @@ args_index = argv.index("--args") args = json.loads(argv[args_index + 1]) assert args == { "format": "json", - "provider": "cursor", + "provider": "hermes", "fresh_tail_count": 64, "leaf_chunk_tokens": 20000, "dynamic_leaf_chunk_enabled": False, @@ -1318,7 +1318,7 @@ assert argv[1:6] == ["tool", "--project", "/tmp/project", "tracedecay_lcm_sessio args = json.loads(argv[argv.index("--args") + 1]) assert args == { "format": "json", - "provider": "cursor", + "provider": "hermes", "session_id": "session-b", "old_session_id": "session-c", "boundary_reason": "compression", @@ -1427,7 +1427,7 @@ else: assert args == { "format": "json", "response_handle_project_root": "/tmp/project", - "provider": "cursor", + "provider": "hermes", "fresh_tail_count": 64, "leaf_chunk_tokens": 20000, "dynamic_leaf_chunk_enabled": False, diff --git a/tests/memory_suite/memory_test.rs b/tests/memory_suite/memory_test.rs index 5a5c17d72..b0f8e08a5 100644 --- a/tests/memory_suite/memory_test.rs +++ b/tests/memory_suite/memory_test.rs @@ -1309,7 +1309,7 @@ async fn compact_vectors_keep_recall_ordering_after_bank_rebuild() { } #[tokio::test] -async fn remove_fact_incremental_vacuum_reclaims_blob_pages() { +async fn remove_fact_defers_vacuum_while_peer_connections_are_live() { let (db, tmp) = make_memory_store().await; let store = MemoryStore::new(db.conn()); let db_path = tmp.path().join("tracedecay.db"); @@ -1351,6 +1351,7 @@ async fn remove_fact_incremental_vacuum_reclaims_blob_pages() { .await .unwrap(); let size_after_insert = std::fs::metadata(&db_path).unwrap().len(); + let (peer, _) = Database::open(&db_path).await.unwrap(); for fact_id in fact_ids { assert!(store.remove_fact(fact_id).await.unwrap()); @@ -1362,8 +1363,23 @@ async fn remove_fact_incremental_vacuum_reclaims_blob_pages() { let size_after_delete = std::fs::metadata(&db_path).unwrap().len(); assert!( - size_after_delete < size_after_insert, - "incremental_vacuum should shrink the DB file after deleting compact HRR blobs; before={size_after_insert}, after={size_after_delete}" + scalar_i64(&db, "PRAGMA freelist_count").await > 0, + "fact deletion must leave page reclamation for exclusive maintenance" + ); + assert_eq!( + scalar_i64(&peer, "SELECT COUNT(*) FROM memory_facts").await, + 0, + "a peer opened before deletion must remain usable" + ); + let (fresh, _) = Database::open(&db_path).await.unwrap(); + assert_eq!( + scalar_i64(&fresh, "SELECT COUNT(*) FROM memory_facts").await, + 0, + "a fresh peer must open after repeated fact deletion" + ); + assert!( + size_after_delete >= size_after_insert, + "online deletion must not compact a live peer store; before={size_after_insert}, after={size_after_delete}" ); } diff --git a/tests/session_suite/lcm_compression.rs b/tests/session_suite/lcm_compression.rs index 7864a5c4f..1b1511b15 100644 --- a/tests/session_suite/lcm_compression.rs +++ b/tests/session_suite/lcm_compression.rs @@ -1187,6 +1187,71 @@ async fn active_structured_content_survives_preflight_and_noop_compress_replay() ); } +#[tokio::test] +async fn idless_compression_replay_does_not_reingest_existing_raw_messages() { + let tmp = TempDir::new().unwrap(); + let db = open_lcm_db(&tmp).await; + insert_session(&db, "cursor", "session-idless-replay").await; + + let initial = db + .lcm_preflight(preflight_request( + "cursor", + "session-idless-replay", + vec![ + json!({"role": "user", "content": "old user context"}), + json!({"role": "assistant", "content": "old assistant context"}), + json!({"role": "user", "content": "fresh user context"}), + json!({"role": "assistant", "content": "fresh assistant context"}), + ], + Some(100), + )) + .await + .unwrap(); + assert_eq!( + db.lcm_status("cursor", Some("session-idless-replay")) + .await + .unwrap() + .raw_message_count, + 4 + ); + + let mut request = compress_request( + "cursor", + "session-idless-replay", + LcmSummarizerMode::Fake { + summary_text: "condensed old context".into(), + }, + ); + request.messages = initial.replay_messages; + let compressed = db.lcm_compress(request).await.unwrap(); + assert_eq!(compressed.summary_nodes_created, 1); + assert!(compressed.replay_messages[0]["lcm_summary_node_id"].is_string()); + assert!( + compressed + .replay_messages + .iter() + .skip(1) + .all(|message| message["store_id"].is_number()) + ); + + db.lcm_preflight(preflight_request( + "cursor", + "session-idless-replay", + compressed.replay_messages, + Some(100), + )) + .await + .unwrap(); + assert_eq!( + db.lcm_status("cursor", Some("session-idless-replay")) + .await + .unwrap() + .raw_message_count, + 4, + "replaying TraceDecay's own summary/tail must not duplicate raw history" + ); +} + #[tokio::test] async fn active_replay_preserves_top_level_fields_that_collide_with_storage_metadata() { let tmp = TempDir::new().unwrap();