Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ec49a5e
fix(hermes): guard plugin nudge and memory retrieval
ScriptedAlchemy Jul 11, 2026
b55674b
fix(hermes): route memory by session workspace
ScriptedAlchemy Jul 11, 2026
f269520
fix(hermes): isolate memory routes and context clones
ScriptedAlchemy Jul 11, 2026
cdb8cd9
test(hermes): harden stock project isolation check
ScriptedAlchemy Jul 11, 2026
152bfac
test(hermes): refresh plugin template snapshot
ScriptedAlchemy Jul 11, 2026
2662669
fix(hermes): configure all profile providers
ScriptedAlchemy Jul 11, 2026
26acd9b
feat(memory): add profile-level user scope
ScriptedAlchemy Jul 11, 2026
6754d3d
fix(mcp): avoid panic in memory status schema
ScriptedAlchemy Jul 11, 2026
b225714
fix(hermes): correlate live turns with projects
ScriptedAlchemy Jul 11, 2026
2b16739
fix(sessions): backfill Hermes turns by project evidence
ScriptedAlchemy Jul 11, 2026
d7de198
feat(automation): wake reviews from fresh session activity
ScriptedAlchemy Jul 11, 2026
3feb159
feat(hermes): export managed skills through plugin discovery
ScriptedAlchemy Jul 11, 2026
f7af1ac
fix(storage): snapshot live branch databases safely
ScriptedAlchemy Jul 11, 2026
7ed100c
feat(automation): apply safe skill consolidations
ScriptedAlchemy Jul 11, 2026
eb23478
feat(automation): review Hermes terminal receipts
ScriptedAlchemy Jul 11, 2026
882e94c
feat(memory): add transactional graph grooming
ScriptedAlchemy Jul 11, 2026
b4d9d5f
revert(automation): keep skill consolidations staged
ScriptedAlchemy Jul 11, 2026
b6b66f6
fix(hermes): gate receipt reviews on ingested turns
ScriptedAlchemy Jul 11, 2026
dd40693
fix(storage): preserve branch snapshot recovery files
ScriptedAlchemy Jul 11, 2026
6e01c82
fix(memory): bound and validate graph grooming
ScriptedAlchemy Jul 11, 2026
99af962
revert(automation): restore guarded skill consolidation
ScriptedAlchemy Jul 11, 2026
01a6342
feat(memory): add autonomous user-scoped learning
ScriptedAlchemy Jul 11, 2026
4281500
fix(storage): preserve fact relations in consolidation
ScriptedAlchemy Jul 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion docs/USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -793,7 +793,16 @@ tracedecay sync --force

## Configuration Files

TraceDecay stores data in two local store classes.
TraceDecay stores data in three local store classes.

### User memory store

`~/.tracedecay/user-memory.db` stores durable user preferences and memory from
chat sessions that are not attached to an initialized TraceDecay project. Use
`memory_scope=user` with `tracedecay_fact_store`,
`tracedecay_fact_feedback`, or `tracedecay_memory_status`. The CLI can access
this scope outside any project. Hermes routes untethered chat and explicit
user-preference writes here; projectless Codex and Cursor hooks recall from it.

### Active project store

Expand All @@ -802,6 +811,15 @@ Repo-local projects create `.tracedecay/` inside each project you index. Profile
- `tracedecay.db` — the libSQL database with all symbols, edges, files, and vector embeddings
- `sessions.db` and sidecar directories such as response handles, LCM payloads, branch metadata, and dashboard artifacts when those features are used

Project holographic memory remains sharded: project `memory_facts`, entities,
feedback, and derived holographic banks live in that project's
`tracedecay.db`. The user-level `global.db` tracks the project registry and
cross-project usage; it is not a single fact table with a `project_id` tag.
Tools select either the profile-level user store or a registered project store
before reading or writing facts. Read-only tools can explicitly select another
registered project, while project-scoped mutations write only to the active
project.

Add `.tracedecay` to your `.gitignore` so enrollment markers are not committed.

Projects indexed before the TraceDecay rename should be migrated into the user-level profile store; runtime storage no longer falls back to `.tracedecay/`.
Expand Down
254 changes: 240 additions & 14 deletions scripts/hermes_plugin_unit_check.py

Large diffs are not rendered by default.

171 changes: 168 additions & 3 deletions scripts/hermes_stock_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@
Everything runs offline: no model calls (compress stays below threshold).
"""

import copy
import json
import os
import subprocess
import sys
import time
from pathlib import Path

PASS = 0

Expand All @@ -49,6 +52,25 @@ def main():
hermes_home = os.path.join(os.environ["HOME"], ".hermes")
project_root = os.getcwd()

# The installer projects approved managed skills beside the bundled
# plugin skill. Add one fixture before discovery to verify that stock
# Hermes registers the complete plugin-native skill overlay.
managed_skill = (
Path(hermes_home)
/ "plugins"
/ "tracedecay"
/ "skills"
/ "agent-managed"
/ "managed-check"
/ "SKILL.md"
)
managed_skill.parent.mkdir(parents=True, exist_ok=True)
managed_skill.write_text(
"---\nname: managed-check\ndescription: Managed skill registration check.\n---\n\n"
"# Managed check\n\nHermes can load this exported managed skill.\n",
encoding="utf-8",
)

# 1. Stock general plugin manager: discovery, enablement, registrations.
from hermes_cli.plugins import get_plugin_manager, get_plugin_context_engine

Expand All @@ -63,6 +85,16 @@ def main():
ok("pre_llm_call hook registered")
assert "tracedecay_status" in loaded.commands_registered, loaded.commands_registered
ok("/tracedecay_status command registered")
assert manager.list_plugin_skills("tracedecay") == [
"managed-check",
"tracedecay",
], manager.list_plugin_skills("tracedecay")
from tools.skills_tool import skill_view

managed_view = json.loads(skill_view("tracedecay:managed-check"))
assert managed_view.get("success") is True, managed_view
assert "Hermes can load this exported managed skill" in managed_view.get("content", "")
ok("plugin-native managed skills resolve through qualified skill_view")
# Code-graph / memory / transcript tools register unconditionally; only
# the live-ingest LCM verbs (whose schemas take the in-memory messages
# list) depend on the context_engine_tool_handlers_receive_messages
Expand All @@ -82,6 +114,62 @@ def main():
"code-graph tools register on stock; LCM + provider-owned tools stay gated",
f"{len(registered)} tools",
)
from model_tools import get_tool_definitions
from tools.tool_search import (
ToolSearchConfig,
assemble_tool_defs,
dispatch_tool_describe,
dispatch_tool_search,
resolve_underlying_call,
)

raw_tool_defs = get_tool_definitions(
enabled_toolsets=["tracedecay"],
quiet_mode=True,
skip_tool_search_assembly=True,
)
raw_names = {(item.get("function") or {}).get("name") for item in raw_tool_defs}
assert "tracedecay_search" in raw_names, sorted(raw_names)
forced_search = ToolSearchConfig(
enabled="on",
threshold_pct=10.0,
search_default_limit=5,
max_search_limit=20,
)
assembled = assemble_tool_defs(raw_tool_defs, config=forced_search)
visible_names = {
(item.get("function") or {}).get("name") for item in assembled.tool_defs
}
assert assembled.activated is True, assembled
assert "tracedecay_search" not in visible_names, sorted(visible_names)
assert {"tool_search", "tool_describe", "tool_call"}.issubset(visible_names)
search_result = json.loads(
dispatch_tool_search(
{"query": "semantic code search"},
current_tool_defs=raw_tool_defs,
config=forced_search,
)
)
match_names = {item["name"] for item in search_result.get("matches", [])}
assert "tracedecay_search" in match_names, search_result
described = json.loads(
dispatch_tool_describe(
{"name": "tracedecay_search"}, current_tool_defs=raw_tool_defs
)
)
assert described.get("name") == "tracedecay_search", described
underlying, arguments, error = resolve_underlying_call(
{
"name": "tracedecay_search",
"arguments": {"query": "register skill"},
}
)
assert (underlying, arguments, error) == (
"tracedecay_search",
{"query": "register skill"},
None,
)
ok("TraceDecay tools participate in stock progressive tool discovery")

# 2. Context engine: registered through the plugin and selected the way
# stock agent/agent_init.py selects it (config-driven, plugin fallback).
Expand All @@ -108,6 +196,19 @@ def main():
assert engine.last_total_tokens == 150
ok("stock ContextEngine ABC surface works", "update_from_response")

# Stock Hermes deep-copies the registered plugin singleton for every
# AIAgent. The copy must retain routing/budget state without sharing locks
# or live agent references, otherwise Hermes falls back to its compressor.
engine.agent = object()
cloned_engine = copy.deepcopy(engine)
assert cloned_engine is not engine
assert cloned_engine._state_lock is not engine._state_lock
assert cloned_engine.project_root == project_root
assert cloned_engine.context_length == 128000
assert cloned_engine.last_total_tokens == 150
assert cloned_engine.agent is None
ok("context engine deep-copies through the stock Hermes agent contract")

assert engine.should_compress(1000) is False
ok("should_compress gates locally below the tracked threshold")
assert engine.should_compress_preflight([], current_tokens=1000) is False
Expand Down Expand Up @@ -168,12 +269,71 @@ def main():
assert fact.get("content") == "stock hermes integration verified", added
found = unwrap_tool_json(
provider.handle_tool_call(
"fact_store", {"action": "search", "query": "stock hermes integration"}
"fact_store",
{
"action": "search",
"query": "stock hermes integration",
"limit": 1,
},
)
)
assert found.get("count", 0) >= 1, found
ok("memory fact add/search round-trips through the binary")

# A second Hermes session rooted in another registered project must get a
# distinct provider instance and fact shard. This is the gateway/Desktop
# routing invariant that prevents one project's memories leaking into
# another project.
other_project = os.path.join(os.path.dirname(project_root), "project-two")
os.makedirs(other_project, exist_ok=True)
with open(os.path.join(other_project, "README.md"), "w", encoding="utf-8") as handle:
handle.write("# project two\n")
init_result = subprocess.run(
[loaded.module.tools.TRACEDECAY_BIN, "init"],
cwd=other_project,
check=False,
capture_output=True,
text=True,
)
assert init_result.returncode == 0 or "already initialized" in (
init_result.stdout + init_result.stderr
).lower(), init_result.stderr
other_provider = load_memory_provider("tracedecay")
assert other_provider is not None and other_provider is not provider
other_provider.initialize("stock-check-session-two", cwd=other_project)
assert provider.project_root != other_provider.project_root, (
provider.project_root,
other_provider.project_root,
)
isolation_marker = "stock hermes project two isolated"
unwrap_tool_json(
other_provider.handle_tool_call(
"fact_add",
{"content": isolation_marker, "fact_type": "decision"},
)
)
first_project_result = unwrap_tool_json(
provider.handle_tool_call(
"fact_store",
{"action": "list", "limit": 200},
)
)
second_project_result = unwrap_tool_json(
other_provider.handle_tool_call(
"fact_store",
{"action": "list", "limit": 200},
)
)
first_contents = {
item.get("fact", item).get("content") for item in first_project_result.get("facts", [])
}
second_contents = {
item.get("fact", item).get("content") for item in second_project_result.get("facts", [])
}
assert isolation_marker not in first_contents, first_project_result
assert isolation_marker in second_contents, second_project_result
ok("memory facts remain isolated between Hermes session projects")

# Passive-ingest / recall hooks (sync_turn, queue_prefetch, on_memory_write).
# prefetch() is the fast inline half: recall happens in queue_prefetch's
# background thread and is consumed on the next turn.
Expand All @@ -199,7 +359,12 @@ def main():
)
mirrored = unwrap_tool_json(
provider.handle_tool_call(
"fact_store", {"action": "search", "query": "on-memory-write mirror"}
"fact_store",
{
"action": "search",
"query": "on-memory-write mirror",
"limit": 1,
},
)
)
assert mirrored.get("count", 0) >= 1, mirrored
Expand All @@ -208,7 +373,7 @@ def main():
# 4. Graph tool dispatch through generated tools.py against the real cwd,
# never the Hermes plugin/config directory.
plugin = loaded.module
graph_status = unwrap_tool_json(plugin.tools.call_tracedecay_tool("tracedecay_status", {}))
graph_status = plugin.call_tracedecay_json("tracedecay_status", {})
assert graph_status.get("file_count", 0) >= 1, graph_status
assert graph_status.get("node_count", 0) >= 1, graph_status
ok(
Expand Down
Loading
Loading