Skip to content
Merged
1 change: 1 addition & 0 deletions scripts/hermes_plugin_unit_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
13 changes: 9 additions & 4 deletions scripts/hermes_stock_integration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/agents/hermes/templates/plugin_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions src/agents/hermes/templates/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> --help` before invoking the fallback.

## Storage and project identity

Hermes may keep its own host files under its Hermes home, but that path never
Expand Down
1 change: 1 addition & 0 deletions src/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> --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."
);

Expand Down
9 changes: 4 additions & 5 deletions src/hooks/steering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
7 changes: 6 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
37 changes: 2 additions & 35 deletions src/memory/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -562,13 +562,8 @@ impl<'a> MemoryStore<'a> {
}

pub async fn remove_fact(&self, fact_id: i64) -> Result<bool> {
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<bool> {
Expand Down Expand Up @@ -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::<i64>(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
Expand Down
51 changes: 46 additions & 5 deletions src/sessions/lcm/compression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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
Expand Down Expand Up @@ -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<Option<String>, 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")
Expand Down
10 changes: 9 additions & 1 deletion tests/agent_suite/agent_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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"
);
}
Expand Down
5 changes: 5 additions & 0 deletions tests/agent_suite/cli_args_contract_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ fn prompt_rules_teach_the_json_args_contract() {
!rules.contains("<name> --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]
Expand Down
6 changes: 3 additions & 3 deletions tests/hermes_suite/lcm_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 19 additions & 3 deletions tests/memory_suite/memory_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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());
Expand All @@ -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}"
);
}

Expand Down
Loading
Loading