diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 4ac0c00f1..8f37cd554 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -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 @@ -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/`. diff --git a/scripts/hermes_plugin_unit_check.py b/scripts/hermes_plugin_unit_check.py index c773a2b57..3938752f4 100644 --- a/scripts/hermes_plugin_unit_check.py +++ b/scripts/hermes_plugin_unit_check.py @@ -14,7 +14,8 @@ flag; the messages-dependent LCM verbs stay gated. 4. The memory provider implements sync_turn / prefetch / on_memory_write and calls the right subprocess verbs. - 5. pre_llm_call returns None on non-first turns (prompt-cache safety). + 5. pre_llm_call returns None on non-first turns and on first-turn greetings + (prompt-cache safety without hijacking small talk). 6. Hermes host-home state cannot redirect the TraceDecay install, store, or project, and removed storage-routing fields stay out of tool schemas. @@ -25,6 +26,7 @@ tracedecay binary named by $TRACEDECAY_BIN (default: target/debug/tracedecay). """ +import copy import json import os import shutil @@ -56,6 +58,11 @@ def generate_plugin(work: Path) -> Path: env = dict(os.environ) env["HOME"] = str(home) env["USERPROFILE"] = str(home) + # HOME alone does not isolate TraceDecay when the invoking Hermes process + # exports an explicit profile root. Keep the generated-install fixture on + # its throwaway profile so it cannot wait on a live profile's skill-store + # lock or inspect its managed skills. + env["TRACEDECAY_DATA_DIR"] = str(home / ".tracedecay") env["PATH"] = f"{bin_path.parent}{os.pathsep}{env.get('PATH', '')}" env["HERMES_HOME"] = str(work / "ignored-hermes-host-home") subprocess.run( @@ -82,6 +89,7 @@ def __init__(self): self.provider = None self.engine = None self.config = None + self.skills = {} def register_hook(self, name, fn): self.hooks[name] = fn @@ -96,7 +104,7 @@ def register_context_engine(self, engine): self.engine = engine def register_skill(self, name, path): - pass + self.skills[name] = Path(path) def register_config_defaults(self, defaults): pass @@ -167,12 +175,63 @@ def run_checks(work: Path): assert "current schemas and are rejected" in skill, skill ok("provenance stamp + cli passthrough + storage guidance generated") + managed_skill = ( + plugin_dir / "skills" / "agent-managed" / "managed-test" / "SKILL.md" + ) + managed_skill.parent.mkdir(parents=True) + managed_skill.write_text("# Managed test\n", encoding="utf-8") + managed_collision = ( + plugin_dir / "skills" / "agent-managed" / "tracedecay" / "SKILL.md" + ) + managed_collision.parent.mkdir(parents=True) + managed_collision.write_text("# Must not replace bundled skill\n", encoding="utf-8") + + # The stock Hermes runtime exposes the logical session workspace through + # TERMINAL_CWD (and, when importable, agent.runtime_cwd). Memory/LCM + # routing must follow that workspace rather than the gateway process cwd. + runtime_project = work / "runtime-project" + runtime_project.mkdir() + previous_terminal_cwd = os.environ.get("TERMINAL_CWD") + os.environ["TERMINAL_CWD"] = str(runtime_project) + try: + assert plugin._code_project_root() == str(runtime_project) + runtime_provider = plugin.TracedecayMemoryProvider() + runtime_provider.initialize(session_id="runtime-cwd") + assert runtime_provider.project_root is None + finally: + if previous_terminal_cwd is None: + os.environ.pop("TERMINAL_CWD", None) + else: + os.environ["TERMINAL_CWD"] = previous_terminal_cwd + ok("unindexed runtime workspace uses profile-level user memory") + + registered_root = work / "registered-project" + registered_child = registered_root / "src" / "nested" + unrelated_root = work / "unrelated-project" + registered_child.mkdir(parents=True) + unrelated_root.mkdir() + real_json = plugin.call_tracedecay_json + try: + plugin.call_tracedecay_json = lambda *_args, **_kwargs: { + "project_root": str(registered_root) + } + assert plugin._resolved_project_scope(str(registered_child)) == str(registered_root) + assert plugin._resolved_project_scope(str(unrelated_root)) is None + finally: + plugin.call_tracedecay_json = real_json + ok("registered project resolution accepts descendants but rejects siblings") + # ── 3. Registration split + provider dedup ────────────────────────── # The installer wrote memory.provider: tracedecay into the temp profile # config, so the provider-owned fact trio must NOT register as direct # duplicates; transcript search has no provider twin and stays. ctx = StubCtx() plugin.register(ctx) + assert ctx.skills == { + "managed-test": managed_skill, + "tracedecay": plugin_dir / "skills" / "tracedecay" / "SKILL.md", + }, ctx.skills + ok("bundled and managed skills register through Hermes discovery") assert "tracedecay_search" in ctx.tools, sorted(ctx.tools) assert "tracedecay_context" in ctx.tools, sorted(ctx.tools) assert "tracedecay_message_search" in ctx.tools, sorted(ctx.tools) @@ -207,31 +266,141 @@ class ForwardingCtx(StubCtx): assert "lcm_grep" in fwd.tools, sorted(fwd.tools) ok("LCM live-ingest verbs register when the host forwards messages") - # ── 5. pre_llm_call cache safety ───────────────────────────────────── + # ── 5. pre_llm_call cache safety + small-talk guard ─────────────────── hook = ctx.hooks["pre_llm_call"] - assert hook(is_first_turn=False) is None + assert hook(is_first_turn=False, user_message="Can you debug src/main.rs?") is None assert hook() is None - first = hook(is_first_turn=True) + assert hook(is_first_turn=True) is None + assert hook(is_first_turn=True, user_message="Hi") is None + assert hook(is_first_turn=True, user_message="hello!") is None + assert hook(is_first_turn=True, user_message="What time is it?") is None + first = hook(is_first_turn=True, user_message="Can you debug this bug in src/main.rs?") assert isinstance(first, str) and "tracedecay" in first - ok("pre_llm_call injects on first turn only") + path_first = hook(is_first_turn=True, user_message="Please review scripts/check.py") + assert isinstance(path_first, str) and "tracedecay" in path_first + ok("pre_llm_call only nudges first-turn code/project requests") saved_names = set(plugin._REGISTERED_TOOL_NAMES) plugin._REGISTERED_TOOL_NAMES.clear() assert hook(is_first_turn=True) is None plugin._REGISTERED_TOOL_NAMES.update(saved_names) ok("pre_llm_call stays silent when no tools registered") + receipt_hook = ctx.hooks["post_tool_call"] + notifications = [] + real_run = plugin.subprocess.run + real_thread = plugin.threading.Thread + class ImmediateThread: + def __init__(self, target, **_kwargs): + self.target = target + def start(self): + self.target() + try: + plugin.threading.Thread = ImmediateThread + plugin.subprocess.run = lambda argv, **kwargs: notifications.append((argv, kwargs)) + assert receipt_hook(tool_name="web_search", cwd=str(runtime_project)) is None + assert notifications == [] + assert receipt_hook( + tool_name="terminal", + args={"command": "secret output is deliberately absent"}, + cwd=str(runtime_project), + session_id="session-1", + turn_id="turn-1", + tool_call_id="call-1", + status="success", + duration_ms=9, + ) is None + assert len(notifications) == 1 + argv, call = notifications[0] + assert argv[-1] == "hook-hermes-terminal-receipt" + event = json.loads(call["input"]) + assert event["route"]["session_id"] == "session-1" + assert event["receipt"]["tool_call_id"] == "call-1" + assert "command" not in call["input"] and "output" not in call["input"] + finally: + plugin.subprocess.run = real_run + plugin.threading.Thread = real_thread + ok("post_tool_call emits bounded asynchronous terminal receipts") + real_block = plugin.tools.plugin_config_block try: plugin.tools.plugin_config_block = lambda *_args, **_kwargs: {"nudge": False} - assert hook(is_first_turn=True) is None + assert hook(is_first_turn=True, user_message="Can you debug src/main.rs?") is None finally: plugin.tools.plugin_config_block = real_block ok("plugins.tracedecay.nudge kill switch silences the nudge") + # Response handles are a generic MCP transport feature, not LCM-only. + # Large fact-store searches must dereference their handle before the + # Hermes memory provider tries to read count/facts from the payload. + real_tool = plugin.tools.call_tracedecay_tool + bridge_calls = [] + try: + def _handled_response(name, args, **kwargs): + bridge_calls.append((name, args, kwargs)) + if name == "tracedecay_retrieve": + payload = { + "count": 1, + "facts": [{"fact": {"fact_id": 7, "content": "remember me"}}], + } + else: + payload = { + "truncated": True, + "handle": "rh_fact_search", + "retrieve_tool": "tracedecay_retrieve", + "preview": "{\"count\":1", + } + return json.dumps({"content": [{"type": "text", "text": json.dumps(payload)}]}) + + plugin.tools.call_tracedecay_tool = _handled_response + resolved = plugin.call_tracedecay_json( + "tracedecay_fact_store", + { + "action": "search", + "query": "remember", + "project_selector": {"path": "/tmp/selected-project"}, + }, + ) + assert resolved.get("count") == 1, resolved + assert [call[0] for call in bridge_calls] == [ + "tracedecay_fact_store", + "tracedecay_retrieve", + ], bridge_calls + assert bridge_calls[1][1]["project_selector"] == { + "path": "/tmp/selected-project" + }, bridge_calls + ok("generic response handles dereference for memory-provider results") + finally: + plugin.tools.call_tracedecay_tool = real_tool + # ── 1. compress() message-list contract ────────────────────────────── engine = ctx.engine assert engine is not None engine.initialize(session_id="check-session", hermes_home=str(host_home)) + engine.project_root = str(runtime_project) + engine.context_length = 200_000 + engine.threshold_tokens = 150_000 + 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 == str(runtime_project) + assert cloned_engine.context_length == 200_000 + assert cloned_engine.threshold_tokens == 150_000 + assert cloned_engine.agent is None + cloned_engine.context_length = 100_000 + assert engine.context_length == 200_000 + ok("context engine safely deep-copies per-agent budget state") + + engine.initialize(session_id="project-session", project_root=str(runtime_project)) + assert engine.project_root == str(runtime_project) + engine.initialize(session_id="untethered-session", cwd=str(host_home)) + assert engine.project_root is None + engine.initialize(session_id="project-session") + assert engine.project_root == str(runtime_project) + engine.initialize(session_id="untethered-session") + assert engine.project_root is None + ok("context engine isolates project routing per Hermes session") + messages = [ {"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}, @@ -373,9 +542,10 @@ def call_llm(self, **kwargs): provider = ctx.provider assert provider is not None provider.initialize("check-session", hermes_home=str(host_home)) - expected_project_root = os.getcwd() - assert provider.project_root == expected_project_root + expected_project_root = plugin._runtime_working_directory() + assert provider.project_root is None assert provider.project_root != str(host_home) + provider.project_root = expected_project_root schema_names = [schema["name"] for schema in provider.get_tool_schemas()] assert schema_names == ["fact_store", "fact_feedback", "memory_status"], schema_names ok("memory schemas collapsed to 3") @@ -386,14 +556,26 @@ def call_llm(self, **kwargs): plugin.tools.call_tracedecay_tool = lambda name, args, **kw: ( calls.append((name, args, kw)) or "{}" ) + + provider.handle_tool_call("fact_store", {"action": "search", "query": "rust"}) + name, args, kwargs = calls[-1] + assert name == "tracedecay_fact_store", calls + assert "project_root" not in args, args + assert args["memory_scope"] == "project", args + assert kwargs["project_root"] == expected_project_root, kwargs + ok("memory tool calls stay bound to the provider's session project") + provider.sync_turn("u", "a", session_id="other-session", messages=messages) name, args, kwargs = calls[-1] assert name == "tracedecay_lcm_preflight", calls assert args["session_id"] == "other-session" - assert args["messages"] == messages + assert [message["content"] for message in args["messages"]] == ["u", "a"] + assert all(message.get("id") for message in args["messages"]) + assert args["transcript_projection"] is True assert "project_root" not in args, args assert kwargs["project_root"] == expected_project_root, kwargs - ok("sync_turn ingests via tracedecay_lcm_preflight") + + ok("sync_turn projects only the completed turn into project LCM") provider.sync_turn("only user", "and assistant", session_id="s2", messages=None) name, args, kwargs = calls[-1] @@ -408,14 +590,57 @@ def call_llm(self, **kwargs): name, args, kwargs = calls[-1] assert name == "tracedecay_fact_store", calls assert args["action"] == "add" and args["category"] == "user_pref" + assert args["memory_scope"] == "user" assert args["metadata"]["hermes_action"] == "add" assert "project_root" not in args, args - assert kwargs["project_root"] == expected_project_root, kwargs + assert "project_root" not in kwargs, kwargs before = len(calls) provider.on_memory_write("remove", "memory", "anything") assert len(calls) == before ok("on_memory_write mirrors adds and skips removals") + provider.project_root = None + before = len(calls) + provider.sync_turn("u", "a", session_id="untethered", messages=messages) + assert len(calls) == before + 1 + name, args, kwargs = calls[-1] + assert name == "tracedecay_lcm_preflight" + assert args["storage_scope"] == "user", args + assert args["transcript_projection"] is True + assert "project_root" not in kwargs, kwargs + provider.handle_tool_call("fact_store", {"action": "add", "content": "pref"}) + name, args, kwargs = calls[-1] + assert args["memory_scope"] == "user", args + assert "project_root" not in kwargs, kwargs + ok("untethered memory and LCM use profile-level user scope") + + real_resolver = plugin._resolved_project_scope + plugin._resolved_project_scope = lambda path: expected_project_root + provider.sync_turn( + "project task", + "done", + session_id="tool-routed", + messages=[ + {"role": "user", "content": "work on the repo"}, + { + "role": "assistant", + "tool_calls": [{ + "function": { + "name": "tracedecay_grep", + "arguments": json.dumps({"project_path": expected_project_root}), + } + }], + }, + ], + ) + name, args, kwargs = calls[-1] + assert name == "tracedecay_lcm_preflight" + assert kwargs["project_root"] == expected_project_root + assert args["transcript_projection"] is True + plugin._resolved_project_scope = real_resolver + ok("structured tool activity correlates an untethered turn to its project") + provider.project_root = expected_project_root + # Non-primary execution contexts must not write turn state. before = len(calls) provider.agent_context = "cron" @@ -426,6 +651,7 @@ def call_llm(self, **kwargs): finally: plugin.tools.call_tracedecay_tool = real_tool + real_json = plugin.call_tracedecay_json try: # Real search responses nest each row under "fact" (with scores @@ -448,8 +674,8 @@ def call_llm(self, **kwargs): if text: break time.sleep(0.05) - assert "zack prefers rust" in text and "[fact 7]" in text, text - assert "flat row" in text and "[fact 8]" in text, text + assert "zack prefers rust" in text and "[user fact 7]" in text, text + assert "flat row" in text and "[user fact 8]" in text, text # Consumed on read: the next prefetch starts empty again. assert provider.prefetch("rust preferences", session_id="check-session") == "" plugin.call_tracedecay_json = lambda name, args, **kw: {"error": "nope"} diff --git a/scripts/hermes_stock_check.py b/scripts/hermes_stock_check.py index fdba8d248..94fc730a2 100644 --- a/scripts/hermes_stock_check.py +++ b/scripts/hermes_stock_check.py @@ -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 @@ -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 @@ -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 @@ -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). @@ -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 @@ -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. @@ -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 @@ -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( diff --git a/src/agents/hermes.rs b/src/agents/hermes.rs index c4c8b29be..4128b6b39 100644 --- a/src/agents/hermes.rs +++ b/src/agents/hermes.rs @@ -34,11 +34,16 @@ impl AgentIntegration for HermesIntegration { fn install(&self, ctx: &InstallContext) -> Result<()> { lifecycle::install(ctx)?; + self.reconcile_managed_skills(ctx)?; Ok(()) } fn update_plugin(&self, ctx: &InstallContext) -> Result { - lifecycle::update_plugin(ctx) + let outcome = lifecycle::update_plugin(ctx)?; + if matches!(outcome, UpdatePluginOutcome::Refreshed(_)) { + self.reconcile_managed_skills(ctx)?; + } + Ok(outcome) } fn uninstall(&self, ctx: &InstallContext) -> Result<()> { @@ -60,16 +65,38 @@ impl AgentIntegration for HermesIntegration { } fn has_tracedecay(&self, home: &Path) -> bool { - default_plugin_dir(home).join("plugin.yaml").is_file() + detected_plugin_dirs(home) + .into_iter() + .any(|dir| dir.is_dir()) + } + + fn export_managed_skills( + &self, + home: &Path, + profile_root: &Path, + ) -> Result> { + let mut exports = Vec::new(); + for plugin_dir in detected_plugin_dirs(home) { + exports.push(crate::automation::skill_targets::install_managed_skills( + profile_root, + crate::automation::skill_targets::SkillInstallTarget::Hermes, + &plugin_dir, + )?); + } + Ok(exports) } } -fn hermes_home(home: &Path) -> PathBuf { - home.join(".hermes") +impl HermesIntegration { + fn reconcile_managed_skills(&self, ctx: &InstallContext) -> Result<()> { + let profile_root = crate::automation::skill_targets::profile_root_for_agent_home(&ctx.home); + self.export_managed_skills(&ctx.home, &profile_root)?; + Ok(()) + } } -fn default_plugin_dir(home: &Path) -> PathBuf { - hermes_home(home).join("plugins/tracedecay") +fn hermes_home(home: &Path) -> PathBuf { + home.join(".hermes") } fn doctor_check_plugin(dc: &mut DoctorCounters, home: &Path) { @@ -178,69 +205,36 @@ pub(super) fn write_plugin_files(plugin_dir: &Path, tracedecay_bin: &str) -> Res ) } -/// The one supported user-level generated plugin directory, when installed. -pub(super) fn detected_plugin_dirs(home: &Path) -> Vec { - let plugin_dir = default_plugin_dir(home); - plugin_dir - .join("plugin.yaml") - .is_file() - .then_some(plugin_dir) +/// Generated plugin locations for the default Hermes profile and every named +/// profile that already exists. Hermes resolves each profile to an independent +/// `HERMES_HOME`, so each one needs the same stock plugin package and provider +/// selections in its own config.yaml. +pub(super) fn profile_plugin_dirs(home: &Path) -> Vec { + let root = hermes_home(home); + let mut profile_roots = vec![root.clone()]; + if let Ok(entries) = std::fs::read_dir(root.join("profiles")) { + let mut profiles = entries + .filter_map(|entry| { + let entry = entry.ok()?; + entry.file_type().ok()?.is_dir().then(|| entry.path()) + }) + .collect::>(); + profiles.sort(); + profile_roots.extend(profiles); + } + profile_roots .into_iter() + .map(|profile_root| profile_root.join("plugins/tracedecay")) .collect() } -/// Historical generated plugin locations. These are consulted only during -/// the one-time cutover; they never select `TraceDecay` storage or a live Hermes -/// install target. -pub(super) fn legacy_plugin_dirs(home: &Path) -> Vec { - let default = default_plugin_dir(home); - let roots = [hermes_home(home)]; - let mut profile_roots = Vec::new(); - for root in roots { - profile_roots.push(root.clone()); - if let Ok(entries) = std::fs::read_dir(root.join("profiles")) { - let mut profiles = entries - .filter_map(|entry| { - let entry = entry.ok()?; - entry.file_type().ok()?.is_dir().then(|| entry.path()) - }) - .collect::>(); - profiles.sort(); - profile_roots.extend(profiles); - } - } - let mut seen = std::collections::BTreeSet::new(); - profile_roots +pub(super) fn detected_plugin_dirs(home: &Path) -> Vec { + profile_plugin_dirs(home) .into_iter() - .map(|root| root.join("plugins/tracedecay")) - .filter(|plugin_dir| !paths_equal(plugin_dir, &default)) .filter(|plugin_dir| plugin_dir.join("plugin.yaml").is_file()) - .filter(|plugin_dir| seen.insert(canonical_or_original(plugin_dir))) .collect() } -#[doc(hidden)] -pub fn has_legacy_plugin_install(home: &Path) -> bool { - !legacy_plugin_dirs(home).is_empty() -} - -#[doc(hidden)] -pub fn cleanup_legacy_plugin_dirs(home: &Path) -> Result> { - let plugin_dirs = legacy_plugin_dirs(home); - for plugin_dir in &plugin_dirs { - uninstall_plugin(plugin_dir)?; - } - Ok(plugin_dirs) -} - -fn paths_equal(left: &Path, right: &Path) -> bool { - canonical_or_original(left) == canonical_or_original(right) -} - -fn canonical_or_original(path: &Path) -> PathBuf { - path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) -} - pub(super) fn uninstall_plugin(plugin_dir: &Path) -> Result<()> { if let Some(profile_dir) = plugin_dir.parent().and_then(Path::parent) { disable_plugin(&profile_dir.join("config.yaml"))?; @@ -262,6 +256,18 @@ pub(super) fn remove_generated_plugin_files(plugin_dir: &Path) -> Result<()> { remove_generated_file(&plugin_dir.join("cli.py"))?; remove_generated_file(&plugin_dir.join("skills/tracedecay/SKILL.md"))?; remove_empty_dir(&plugin_dir.join("skills/tracedecay"))?; + let managed_overlay = plugin_dir.join("skills/agent-managed"); + if managed_overlay + .join(".tracedecay-managed-skills.json") + .is_file() + { + std::fs::remove_dir_all(&managed_overlay).map_err(|e| TraceDecayError::Config { + message: format!( + "failed to remove generated Hermes skill overlay {}: {e}", + managed_overlay.display() + ), + })?; + } 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 fe6aa9528..d0f49c40e 100644 --- a/src/agents/hermes/lifecycle.rs +++ b/src/agents/hermes/lifecycle.rs @@ -23,8 +23,9 @@ pub(super) struct UninstallOutcome { pub(super) fn install(ctx: &InstallContext) -> Result { let plugin_dir = ctx.home.join(".hermes/plugins/tracedecay"); - install_supported_plugin(&plugin_dir, &ctx.tracedecay_bin, ctx.dashboard)?; - super::cleanup_legacy_plugin_dirs(&ctx.home)?; + for profile_plugin_dir in super::profile_plugin_dirs(&ctx.home) { + install_supported_plugin(&profile_plugin_dir, &ctx.tracedecay_bin, ctx.dashboard)?; + } eprintln!(); eprintln!("Setup complete. Next steps:"); @@ -63,7 +64,6 @@ pub(super) fn update_plugin(ctx: &InstallContext) -> Result /// Refreshes the generated user-level plugin without rewriting config.yaml. fn refresh_installed_plugins(home: &Path, tracedecay_bin: &str) -> Result> { - let legacy_plugins = super::legacy_plugin_dirs(home); let mut refreshed = Vec::new(); for plugin_dir in super::detected_plugin_dirs(home) { let had_dashboard = super::dashboard_wrapper::is_deployed(&plugin_dir); @@ -80,23 +80,15 @@ fn refresh_installed_plugins(home: &Path, tracedecay_bin: &str) -> Result Result { let plugin_dir = ctx.home.join(".hermes/plugins/tracedecay"); - super::uninstall_plugin(&plugin_dir)?; - super::cleanup_legacy_plugin_dirs(&ctx.home)?; + for profile_plugin_dir in super::profile_plugin_dirs(&ctx.home) { + super::uninstall_plugin(&profile_plugin_dir)?; + } eprintln!(); eprintln!("Uninstall complete. Tracedecay has been removed from Hermes."); @@ -208,7 +200,7 @@ mod tests { } #[test] - fn install_cutover_ignores_hermes_home_redirect() { + fn install_configures_every_existing_hermes_profile() { let home = TempDir::new().unwrap(); let redirected = TempDir::new().unwrap(); let named = home.path().join(".hermes/profiles/work/plugins/tracedecay"); @@ -228,18 +220,15 @@ mod tests { .unwrap(); } - let retired = with_hermes_home(redirected.path(), || { - crate::agents::hermes::cleanup_legacy_plugin_dirs(home.path()).unwrap() - }); let outcome = install(&ctx(home.path(), NEW_BIN)).unwrap(); - assert_eq!(retired, vec![named.clone()]); assert!(outcome.plugin_dir.join("plugin.yaml").is_file()); - assert!(!named.join("plugin.yaml").exists()); + assert!(named.join("plugin.yaml").exists()); assert!(redirected_plugin.join("plugin.yaml").exists()); - assert!( - !text(&home.path().join(".hermes/profiles/work/config.yaml")).contains("tracedecay") - ); + let named_config = text(&home.path().join(".hermes/profiles/work/config.yaml")); + assert!(named_config.contains("- tracedecay")); + assert!(named_config.contains("provider: tracedecay")); + assert!(named_config.contains("engine: tracedecay")); assert!(text(&redirected.path().join("config.yaml")).contains("tracedecay")); } diff --git a/src/agents/hermes/templates.rs b/src/agents/hermes/templates.rs index 8174fbbba..07262e7f7 100644 --- a/src/agents/hermes/templates.rs +++ b/src/agents/hermes/templates.rs @@ -25,6 +25,7 @@ pub(super) fn plugin_manifest() -> String { provides_tools:\n{tools}\n\ provides_hooks:\n\ - pre_llm_call\n\ + - post_tool_call\n\ provides_commands:\n\ - /tracedecay_status\n", version = env!("CARGO_PKG_VERSION"), diff --git a/src/agents/hermes/templates/plugin_init.py b/src/agents/hermes/templates/plugin_init.py index c83c833c2..25d2b45ec 100644 --- a/src/agents/hermes/templates/plugin_init.py +++ b/src/agents/hermes/templates/plugin_init.py @@ -1,12 +1,16 @@ """tracedecay Hermes plugin registration.""" +import copy import json import hashlib import logging import os import re +import shlex import shutil +import subprocess import threading import time +from collections import deque from pathlib import Path from . import schemas, tools @@ -53,6 +57,14 @@ class ContextEngine: except Exception: _hermes_auxiliary_client = None +# Stock Hermes' single source of truth for the logical session workspace. +# Multi-session gateways keep this in a ContextVar, so os.getcwd() alone would +# incorrectly route every session through the gateway process directory. +try: + from agent.runtime_cwd import resolve_agent_cwd as _hermes_resolve_agent_cwd # type: ignore[import-not-found] +except Exception: + _hermes_resolve_agent_cwd = None + def _resolve_auxiliary_client(agent=None): """Best auxiliary LLM client: an agent-attached one, else hermes' module-level facade.""" client = getattr(agent, "auxiliary_client", None) @@ -374,23 +386,180 @@ def _host_forwards_registered_tool_messages(ctx) -> bool: return False return bool(capability) +_NUDGE_CODE_REQUEST_RE = re.compile( + r"\b(" + r"codebase|repo(?:sitory)?|project|workspace|symbol|function|method|class|" + r"call\s*graph|caller|callee|impact|architecture|module|file|path|diff|" + r"git|branch|commit|pr|pull\s*request|ci|test|build|compile|lint|" + r"bug|fix|debug|implement|refactor|review|traceback|stack\s*trace|error" + r")\b", + re.IGNORECASE, +) + +_NUDGE_PATH_OR_CODE_RE = re.compile( + r"(```|`[^`]+`|(?:^|\s)[\w./-]+\.(?:py|rs|ts|tsx|js|jsx|go|java|kt|rb|php|c|cc|cpp|h|hpp|cs|swift|toml|ya?ml|json|md)\b|(?:^|\s)[\w.-]+/[\w./-]+)", + re.IGNORECASE, +) + +_NUDGE_GREETING_RE = re.compile( + r"^\s*(?:hi|hello|hey|yo|sup|howdy|good\s+(?:morning|afternoon|evening)|thanks?|thank\s+you|ok(?:ay)?|yes|no)[\s!.?,~-]*$", + re.IGNORECASE, +) + +def _should_emit_tracedecay_nudge(user_message) -> bool: + """Return true only when the first user turn is actually code/project work. + + The hook text is appended to the user's first message. For greetings like + "Hi", an unconditional nudge becomes the most salient content and the model + may answer by talking about tracedecay instead of simply greeting back. + """ + text = str(user_message or "").strip() + if not text or _NUDGE_GREETING_RE.match(text): + return False + return bool(_NUDGE_CODE_REQUEST_RE.search(text) or _NUDGE_PATH_OR_CODE_RE.search(text)) + def _pre_llm_call(*args, **kwargs): - # Inject guidance ONLY on the first turn: the hook result is appended to - # the user message, so emitting it every turn would change every turn - # boundary and break the conversation's prompt-cache prefix. Skip it - # entirely when no tracedecay tools actually registered on this host — - # advertising unregistered tools invites hallucinated calls. + # Inject guidance only for first-turn code/project requests. The hook result + # is appended to the user message, so unconditional text on a greeting ("Hi") + # can hijack the assistant's response and surface tracedecay when the user + # did not ask for code work. Keep it first-turn-only for prompt-cache + # stability, and skip it entirely when no tracedecay tools registered on + # this host — advertising unregistered tools invites hallucinated calls. if not kwargs.get("is_first_turn"): return None if not _REGISTERED_TOOL_NAMES: return None if not _plugin_toggle("nudge", True): return None + if not _should_emit_tracedecay_nudge(kwargs.get("user_message")): + return None return ( - "Prefer tracedecay tools for codebase exploration, symbol lookup, call graphs, " + "For this codebase request, prefer tracedecay tools for symbol lookup, call graphs, " "impact analysis, affected files, and architectural navigation before broad file reads." ) +_TERMINAL_TOOL_NAMES = frozenset(( + "terminal", "bash", "shell", "exec_command", "run_command", "terminal.exec", +)) +_HOST_RECEIPT_QUEUE = deque() +_HOST_RECEIPT_QUEUE_LOCK = threading.Lock() +_HOST_RECEIPT_WORKER_ACTIVE = False + +def _notify_host_receipt(event, thread_name): + global _HOST_RECEIPT_WORKER_ACTIVE + with _HOST_RECEIPT_QUEUE_LOCK: + _HOST_RECEIPT_QUEUE.append(event) + if _HOST_RECEIPT_WORKER_ACTIVE: + return + _HOST_RECEIPT_WORKER_ACTIVE = True + + def _drain(): + global _HOST_RECEIPT_WORKER_ACTIVE + while True: + with _HOST_RECEIPT_QUEUE_LOCK: + if not _HOST_RECEIPT_QUEUE: + _HOST_RECEIPT_WORKER_ACTIVE = False + return + queued = _HOST_RECEIPT_QUEUE.popleft() + try: + subprocess.run( + [tools.TRACEDECAY_BIN, "hook-hermes-terminal-receipt"], + input=json.dumps(queued), + text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=( + tools.TRACEDECAY_LONG_TIMEOUT_SECONDS + if queued.get("event") == "turnIngested" and not queued.get("cwd") + else 2 + ), + check=False, + ) + except Exception as exc: + logger.debug("tracedecay host receipt notification failed: %s", exc) + + threading.Thread(target=_drain, name=thread_name, daemon=True).start() + +def _post_tool_call(*args, **kwargs): + """Send a bounded, fail-open terminal receipt to TraceDecay.""" + payload = {} + if args and isinstance(args[0], dict): + payload.update(args[0]) + payload.update(kwargs) + tool_name = str(payload.get("tool_name") or payload.get("name") or "").lower() + if tool_name not in _TERMINAL_TOOL_NAMES: + return None + tool_args = payload.get("args") if isinstance(payload.get("args"), dict) else {} + cwd = _code_project_root( + explicit=payload.get("project_root") or payload.get("project_path"), + cwd=payload.get("cwd") or tool_args.get("cwd") or tool_args.get("workdir"), + ) + if not cwd: + return None + session_id = payload.get("session_id") or payload.get("thread_id") + turn_id = payload.get("turn_id") + tool_call_id = payload.get("tool_call_id") or payload.get("call_id") + status = str(payload.get("status") or ("error" if payload.get("error") else "success")) + duration = payload.get("duration_ms") + try: + duration = max(0, min(int(duration), 86_400_000)) if duration is not None else None + except (TypeError, ValueError): + duration = None + event = { + "agent": "hermes", + "event": "terminalReceipt", + "cwd": str(cwd), + "route": { + "session_id": str(session_id)[:256] if session_id else None, + "thread_id": str(payload.get("thread_id"))[:256] if payload.get("thread_id") else None, + "cwd": str(cwd), + }, + "receipt": { + "tool_call_id": str(tool_call_id)[:256] if tool_call_id else None, + "turn_id": str(turn_id)[:256] if turn_id else None, + "status": status[:32], + "duration_ms": duration, + "transcript_watermark": str( + payload.get("transcript_watermark") or turn_id or tool_call_id or "" + )[:256] or None, + }, + } + + _notify_host_receipt(event, "tracedecay-terminal-receipt") + return None + +def _turn_receipt_event(event_name, session_id, project_root, transcript_watermark): + route = {"session_id": str(session_id)[:256]} + event = { + "agent": "hermes", + "event": event_name, + "route": route, + "receipt": { + "status": "success", + "transcript_watermark": str(transcript_watermark)[:256], + }, + } + if project_root: + event["cwd"] = str(project_root) + route["cwd"] = str(project_root) + return event + +def _notify_turn_completed(session_id, project_root, transcript_watermark): + _notify_host_receipt( + _turn_receipt_event( + "turnCompleted", session_id, project_root, transcript_watermark + ), + "tracedecay-turn-completed", + ) + +def _notify_turn_ingested(session_id, project_root, transcript_watermark): + _notify_host_receipt( + _turn_receipt_event( + "turnIngested", session_id, project_root, transcript_watermark + ), + "tracedecay-turn-ingested", + ) + def _tracedecay_status(raw_args: str = ""): raw = tools.call_tracedecay_tool("tracedecay_status", {}) try: @@ -504,6 +673,14 @@ def _lcm_retrieve_kwargs(args: dict, kwargs: dict) -> dict: retrieve_kwargs["project_root"] = root.strip() return retrieve_kwargs +def _retrieve_args(handle: str, args: dict) -> dict: + retrieve_args = {"handle": handle} + if isinstance(args, dict): + for key in ("project_id", "project_path", "project_selector"): + if args.get(key) is not None: + retrieve_args[key] = args[key] + return retrieve_args + def _decode_tool_payload(value, name: str, args: dict, kwargs: dict, depth: int = 0, seen_handles=None): if depth > 8: return value @@ -518,13 +695,13 @@ def _decode_tool_payload(value, name: str, args: dict, kwargs: dict, depth: int return value handle = _retrieval_handle(value) - if handle and name.startswith("tracedecay_lcm_"): + if handle and name != "tracedecay_retrieve": if handle in seen_handles: return value seen_handles.add(handle) retrieved = call_tracedecay_json( "tracedecay_retrieve", - {"handle": handle}, + _retrieve_args(handle, args), **_lcm_retrieve_kwargs(args, kwargs), ) if isinstance(retrieved, dict) and not retrieved.get("error"): @@ -608,6 +785,15 @@ def _memory_schema(tracedecay_name: str, hermes_name: str, action: str = None) - "parameters": {"type": "object", "properties": {}}, } +def _agent_visible_schema(schema: dict) -> dict: + """Hide routing fields selected by the Hermes session integration.""" + visible = json.loads(json.dumps(schema)) + properties = (visible.get("parameters") or {}).get("properties") + if isinstance(properties, dict): + properties.pop("storage_scope", None) + properties.pop("hermes_home", None) + return visible + def _lcm_tool_schemas() -> list: return list(LCM_NATIVE_SCHEMAS) @@ -645,6 +831,105 @@ def _project_call_kwargs(project_root=None, kwargs=None): routed["project_root"] = str(project_root) return routed +def _lcm_store_args(args, project_root): + """Select the project shard or the profile-level user session store.""" + routed = dict(args or {}) + if not project_root: + routed.setdefault("storage_scope", "user") + return routed + +def _resolved_project_scope(project_root): + if not project_root: + return None + try: + status = call_tracedecay_json( + "tracedecay_active_project", + {}, + **_project_call_kwargs(project_root), + ) + except Exception: + return None + if not isinstance(status, dict) or status.get("error"): + return None + resolved = status.get("project_root") or status.get("root") + if not resolved: + return None + try: + resolved_real = os.path.realpath(str(resolved)) + candidate_real = os.path.realpath(str(project_root)) + if os.path.commonpath((resolved_real, candidate_real)) != resolved_real: + return None + except (OSError, TypeError, ValueError): + return None + return str(resolved) + +def _project_scope_available(project_root) -> bool: + return _resolved_project_scope(project_root) is not None + +def _decoded_tool_arguments(call): + if not isinstance(call, dict): + return None, {} + function = call.get("function") if isinstance(call.get("function"), dict) else call + name = str(function.get("name") or call.get("name") or "") + arguments = function.get("arguments", call.get("arguments", {})) + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except Exception: + arguments = {} + return name, arguments if isinstance(arguments, dict) else {} + +def _terminal_cd_candidates(command): + try: + tokens = shlex.split(str(command or ""), posix=os.name != "nt") + except Exception: + return [] + candidates = [] + for index, token in enumerate(tokens[:-1]): + if token == "cd": + candidates.append(tokens[index + 1]) + return candidates + +def _tool_project_candidates(messages): + if not isinstance(messages, list): + return [] + start = 0 + for index, message in enumerate(messages): + if isinstance(message, dict) and message.get("role") == "user": + start = index + candidates = [] + for message in messages[start:]: + if not isinstance(message, dict): + continue + calls = message.get("tool_calls") or [] + if isinstance(calls, dict): + calls = [calls] + for call in calls: + name, arguments = _decoded_tool_arguments(call) + selector = arguments.get("project_selector") + for candidate in ( + arguments.get("project_root"), + arguments.get("project_path"), + selector.get("path") if isinstance(selector, dict) else None, + arguments.get("cwd"), + arguments.get("workdir"), + ): + if isinstance(candidate, str) and os.path.isabs(os.path.expanduser(candidate)): + candidates.append(candidate) + if name in ("terminal", "bash", "shell", "exec_command"): + candidates.extend(_terminal_cd_candidates(arguments.get("command") or arguments.get("cmd"))) + return candidates + +def _turn_project_root(messages): + for candidate in reversed(_tool_project_candidates(messages)): + expanded = os.path.abspath(os.path.expanduser(candidate)) + if os.path.isfile(expanded): + expanded = os.path.dirname(expanded) + resolved = _resolved_project_scope(expanded) + if resolved: + return resolved + return None + # Conventional config home: a `plugins.tracedecay` block in the profile # config.yaml (the same `plugins.` convention bundled Hermes plugins # use). Keys are flat and mirror the host-config attribute names the @@ -653,7 +938,7 @@ def _project_call_kwargs(project_root=None, kwargs=None): # register_config_defaults()/get_config_field_meta() expose the real # surface instead of just the install pin. PLUGIN_CONFIG_FIELDS = { - "nudge": (True, "Inject the first-turn tracedecay tool guidance nudge."), + "nudge": (True, "Inject a first-turn tracedecay guidance nudge only for codebase/navigation requests."), "sync_turn": (True, "Mirror each completed turn into the LCM raw store."), "prefetch": (True, "Background fact recall injected at turn start."), "context_threshold": ("", "Compression trigger as a fraction of the context window (default: hermes compression.threshold)."), @@ -780,10 +1065,27 @@ def _has_tracedecay_index(path): return False return os.path.isdir(os.path.join(path, ".tracedecay")) +def _runtime_working_directory(): + if _hermes_resolve_agent_cwd is not None: + try: + resolved = _hermes_resolve_agent_cwd() + if resolved: + candidate = os.path.abspath(os.path.expanduser(str(resolved))) + if os.path.isdir(candidate): + return candidate + except Exception: + pass + raw = os.environ.get("TERMINAL_CWD", "").strip() + if raw: + candidate = os.path.abspath(os.path.expanduser(raw)) + if os.path.isdir(candidate): + return candidate + return os.getcwd() + def _code_project_root(explicit=None, cwd=None, configured=None): if explicit: return str(explicit) - candidate = cwd or configured or os.getcwd() + candidate = cwd or configured or _runtime_working_directory() if isinstance(candidate, str) and candidate.strip() and os.path.isabs(candidate): return candidate.strip() return None @@ -1962,6 +2264,7 @@ class _EngineSessionState: """ _FIELDS = ( + "project_root", "agent", "model", "last_prompt_tokens", @@ -1980,6 +2283,7 @@ class _EngineSessionState: ) def __init__(self): + self.project_root = None self.agent = None self.model = "" self.last_prompt_tokens = 0 @@ -2044,6 +2348,31 @@ def __init__(self, config=None, hermes_home=None): self._route_failures = {} self._cooldown_until = {} + def __deepcopy__(self, memo): + """Create an agent-local engine without copying locks or live agents.""" + clone = type(self)( + config=copy.deepcopy(self._host_config, memo), + hermes_home=self.hermes_home, + ) + memo[id(self)] = clone + clone.config = copy.deepcopy(self.config, memo) + clone.project_root = self.project_root + # Auxiliary-route circuit breakers intentionally remain process-wide. + clone._route_failures = self._route_failures + clone._cooldown_until = self._cooldown_until + + with self._state_lock: + clone.active_session_id = self.active_session_id + for key, state in self._session_states.items(): + copied_state = _EngineSessionState() + for field in _EngineSessionState._FIELDS: + if field == "agent": + continue + setattr(copied_state, field, copy.deepcopy(getattr(state, field), memo)) + clone._session_states[key] = copied_state + return clone + + project_root = _engine_session_property("project_root") agent = _engine_session_property("agent") model = _engine_session_property("model") last_prompt_tokens = _engine_session_property("last_prompt_tokens") @@ -2091,8 +2420,12 @@ def _bind_session(self, session_id=None, hermes_home=None, project_root=None, ** # carry model/window/counters over from the predecessor # session's state (old_session_id on boundary starts, # else whatever this thread was bound to). - source_key = str(kwargs.get("old_session_id") or "") or self._session_key() - source = self._session_states.get(source_key) + source_key = str(kwargs.get("old_session_id") or "") + source = self._session_states.get(source_key) if source_key else None + if source is None and kwargs.get("boundary_reason") == "compression": + source = self._session_states.get(self._session_key()) + if source is None: + source = self._session_states.get(_ENGINE_DEFAULT_SESSION) if source is not None: state.adopt(source) state._last_preflight_signature = None @@ -2121,13 +2454,26 @@ def _bind_session(self, session_id=None, hermes_home=None, project_root=None, ** # Re-layer the profile's plugins.tracedecay block now that the host # config and hermes_home are settled for this session. self.config = _with_plugin_block(self._host_config, self.hermes_home) - next_project_root = _code_project_root( - explicit=project_root or kwargs.get("project_root"), - cwd=kwargs.get("cwd"), - configured=_configured_project_root(self.config) or self.project_root, - ) + explicit_project_root = project_root or kwargs.get("project_root") + runtime_cwd = kwargs.get("cwd") + configured_project_root = _configured_project_root(self.config) + routing_supplied = bool(explicit_project_root or runtime_cwd) + if explicit_project_root: + next_project_root = str(explicit_project_root) + elif runtime_cwd: + next_project_root = _resolved_project_scope(str(runtime_cwd)) + elif self.project_root: + next_project_root = self.project_root + elif configured_project_root: + next_project_root = str(configured_project_root) + elif session_id is not None and not self.project_root: + next_project_root = _resolved_project_scope(_runtime_working_directory()) + else: + next_project_root = None if next_project_root: self.project_root = next_project_root + elif routing_supplied: + self.project_root = None def initialize(self, session_id=None, hermes_home=None, project_root=None, **kwargs): self._bind_session(session_id, hermes_home, project_root, **kwargs) @@ -2226,12 +2572,12 @@ def _report_compression_boundary(self, session_id, bound_session_id, kwargs): or old_session_id == session_id ): return - args = { + args = _lcm_store_args({ "provider": STANDARD_HERMES_LCM_PROVIDER, "session_id": session_id, "old_session_id": old_session_id, "boundary_reason": boundary_reason, - } + }, self.project_root) if bound_session_id: args["bound_session_id"] = bound_session_id try: @@ -2295,6 +2641,7 @@ def _preflight_probe(self, messages, current_tokens=None, **kwargs): "stateless_session_patterns", "ignore_message_patterns", )) + args = _lcm_store_args(args, kwargs.get("project_root") or self.project_root) return call_tracedecay_json( "tracedecay_lcm_preflight", args, @@ -2360,6 +2707,7 @@ def carry_over_new_session_context(self, old_session_id, new_session_id): def status(self, session_id=None, **kwargs): args = self._tool_args(session_id) args.update(_lcm_gc_config_args(self.config)) + args = _lcm_store_args(args, kwargs.get("project_root") or self.project_root) return call_tracedecay_json( "tracedecay_lcm_status", args, @@ -2452,6 +2800,7 @@ def _current_turn_preflight(self, messages, **kwargs): "stateless_session_patterns", "ignore_message_patterns", )) + args = _lcm_store_args(args, kwargs.get("project_root") or self.project_root) try: tools.call_tracedecay_tool( "tracedecay_lcm_preflight", @@ -2488,6 +2837,10 @@ def handle_tool_call(self, name, arguments=None, **kwargs) -> str: tool_args.update(_lcm_gc_config_args(self.config)) if self.active_session_id: tool_args.setdefault("session_id", self.active_session_id) + tool_args = _lcm_store_args( + tool_args, + preflight_kwargs.get("project_root") or self.project_root, + ) if tracedecay_name == "tracedecay_lcm_expand_query": expand_kwargs = dict(preflight_kwargs) @@ -2521,6 +2874,7 @@ def expand_query(self, prompt, query=None, node_ids=None, **kwargs): args[key] = kwargs[key] if "context_max_tokens" not in args: args["context_max_tokens"] = _lcm_expansion_context_tokens(self.config) + args = _lcm_store_args(args, kwargs.get("project_root") or self.project_root) retrieval = call_tracedecay_json( "tracedecay_lcm_expand_query", args, @@ -3077,11 +3431,12 @@ def is_available(self) -> bool: 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( + candidate_root = _code_project_root( explicit=kwargs.get("project_root"), cwd=kwargs.get("cwd"), configured=_configured_project_root(config), ) + self.project_root = candidate_root if _project_scope_available(candidate_root) else None self.session_id = session_id # Execution context ("", "cron", "flush", ...): cron/flush runs are # not primary conversations and must not write turn state (cron @@ -3109,7 +3464,7 @@ def post_setup(self, hermes_home, config): ) status = call_tracedecay_json( "tracedecay_memory_status", - {}, + {} if project_root else {"memory_scope": "user"}, **_project_call_kwargs(project_root), ) if isinstance(status, dict) and not status.get("error"): @@ -3182,34 +3537,43 @@ def _recall_facts(self, query): text = str(query or "").strip() if not text: return "" - try: - args = {"action": "search", "query": text[:512], "limit": 3} - payload = call_tracedecay_json( - "tracedecay_fact_store", - args, - **_project_call_kwargs(self.project_root), - ) - except Exception as exc: - logger.debug("tracedecay memory prefetch failed: %s", exc) - return "" - if not isinstance(payload, dict) or payload.get("error"): - return "" - facts = payload.get("facts") or payload.get("results") or [] - lines = [] - for item in facts: - if not isinstance(item, dict): - continue - # Search results nest the row under "fact" (with match scores - # beside it); list results are flat fact rows. - fact = item.get("fact") if isinstance(item.get("fact"), dict) else item - content = str(fact.get("content") or "").strip() - if not content: + payloads = [] + scopes = ["user"] + (["project"] if self.project_root else []) + for scope in scopes: + try: + args = { + "action": "search", + "query": text[:512], + "limit": 3, + "memory_scope": scope, + } + payload = call_tracedecay_json( + "tracedecay_fact_store", + args, + **_project_call_kwargs(self.project_root if scope == "project" else None), + ) + except Exception as exc: + logger.debug("tracedecay %s memory prefetch failed: %s", scope, exc) continue - if len(content) > 600: - content = content[:600].rstrip() + "..." - fact_id = fact.get("fact_id") - prefix = f"[fact {fact_id}] " if fact_id is not None else "" - lines.append(f"- {prefix}{content}") + if isinstance(payload, dict) and not payload.get("error"): + payloads.append((scope, payload)) + lines = [] + seen_content = set() + for scope, payload in payloads: + facts = payload.get("facts") or payload.get("results") or [] + for item in facts: + if not isinstance(item, dict): + continue + fact = item.get("fact") if isinstance(item.get("fact"), dict) else item + content = str(fact.get("content") or "").strip() + if not content or content in seen_content: + continue + seen_content.add(content) + if len(content) > 600: + content = content[:600].rstrip() + "..." + fact_id = fact.get("fact_id") + prefix = f"[{scope} fact {fact_id}] " if fact_id is not None else f"[{scope}] " + lines.append(f"- {prefix}{content}") return "\n".join(lines) def sync_turn(self, user_content, assistant_content, *, session_id="", messages=None): @@ -3219,6 +3583,7 @@ def sync_turn(self, user_content, assistant_content, *, session_id="", messages= same content-cursored path the context engine uses), so the raw store grows every turn instead of only when compression fires. """ + project_root = _turn_project_root(messages) or self.project_root if not _plugin_toggle("sync_turn", True): return if self.agent_context in ("cron", "flush"): @@ -3227,34 +3592,38 @@ def sync_turn(self, user_content, assistant_content, *, session_id="", messages= sid = session_id or self.session_id if not sid: return - forwarded_messages = isinstance(messages, list) and bool(messages) - turn_messages = messages if forwarded_messages else None - if not turn_messages: - turn_messages = [] - if user_content: - turn_messages.append({"role": "user", "content": str(user_content)}) - if assistant_content: - turn_messages.append({"role": "assistant", "content": str(assistant_content)}) + turn_messages = [] + if user_content: + turn_messages.append({"role": "user", "content": str(user_content)}) + if assistant_content: + turn_messages.append({"role": "assistant", "content": str(assistant_content)}) if not turn_messages: return - if not forwarded_messages: - self._sync_turn_sequence += 1 - batch_id = self._sync_turn_sequence - timestamp_ns = time.time_ns() - 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 = { + self._sync_turn_sequence += 1 + batch_id = self._sync_turn_sequence + timestamp_ns = time.time_ns() + timestamp = time.time() + 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}" + entry["timestamp"] = timestamp + args = _lcm_store_args({ "provider": STANDARD_HERMES_LCM_PROVIDER, "session_id": sid, "messages": turn_messages, - } + "transcript_projection": True, + }, project_root) try: - tools.call_tracedecay_tool( + result = call_tracedecay_json( "tracedecay_lcm_preflight", args, - **_project_call_kwargs(self.project_root), + **_project_call_kwargs(project_root), ) + if not result.get("error"): + _notify_turn_completed(sid, project_root, turn_messages[-1]["id"]) + _notify_turn_ingested(sid, project_root, turn_messages[-1]["id"]) + else: + logger.debug("tracedecay sync_turn ingest rejected: %s", result.get("error")) except Exception as exc: logger.debug("tracedecay sync_turn ingest failed: %s", exc) @@ -3276,12 +3645,15 @@ def on_memory_write(self, action, target, content, metadata=None): "content": text, "category": "user_pref" if target == "user" else "general", "metadata": fact_metadata, + "memory_scope": "user" if target == "user" or not self.project_root else "project", } try: tools.call_tracedecay_tool( "tracedecay_fact_store", fact_args, - **_project_call_kwargs(self.project_root), + **_project_call_kwargs( + self.project_root if fact_args["memory_scope"] == "project" else None + ), ) except Exception as exc: logger.debug("tracedecay on_memory_write mirror failed: %s", exc) @@ -3377,11 +3749,30 @@ def handle_tool_call(self, name, arguments=None, **kwargs) -> str: if fixed_args: tool_args = dict(tool_args) tool_args.update(fixed_args) - return tools.call_tracedecay_tool(tracedecay_name, tool_args, **kwargs) + if "memory_scope" not in tool_args: + action = str(tool_args.get("action") or "") + category = str(tool_args.get("category") or "") + if not self.project_root or (action in ("add", "update") and category == "user_pref"): + tool_args["memory_scope"] = "user" + else: + tool_args["memory_scope"] = "project" + routed_project = self.project_root if tool_args["memory_scope"] == "project" else None + routed_kwargs = dict(kwargs) + if routed_project is None: + routed_kwargs.pop("project_root", None) + return tools.call_tracedecay_tool( + tracedecay_name, + tool_args, + **_project_call_kwargs(routed_project, routed_kwargs), + ) def register(ctx): global _HOST_FORWARDS_MESSAGES ctx.register_hook("pre_llm_call", _pre_llm_call) + try: + ctx.register_hook("post_tool_call", _post_tool_call) + except Exception as exc: + logger.debug("tracedecay post_tool_call hook unavailable: %s", exc) # Declare the plugins.tracedecay config block so its keys exist in # load_config() even before the user edits config.yaml. register_config_defaults = getattr(ctx, "register_config_defaults", None) @@ -3455,11 +3846,12 @@ def register(ctx): # prefixed twins would double the schema footprint. continue handler = _handle_lcm_expand_query if name == "tracedecay_lcm_expand_query" else tools.make_handler(name) + visible_schema = _agent_visible_schema(schema) try: register_tool( name=name, toolset="tracedecay", - schema=schema, + schema=visible_schema, handler=handler, ) except Exception as exc: @@ -3473,11 +3865,12 @@ def register(ctx): if host_forwards_messages: for schema in context_engine.get_tool_schemas(): name = schema["name"] + visible_schema = _agent_visible_schema(schema) try: register_tool( name=name, toolset="context_engine", - schema=schema, + schema=visible_schema, handler=_make_wrapped_lcm_handler(name, context_engine), description=schema.get("description", ""), ) @@ -3498,13 +3891,44 @@ def register(ctx): "tracedecay direct tool registration unavailable on this Hermes host; continuing with context-engine schemas" ) - skills_dir = Path(__file__).parent / "skills" - skill_path = skills_dir / "tracedecay" / "SKILL.md" register_skill = getattr(ctx, "register_skill", None) - if skill_path.exists() and callable(register_skill): - # Newer Hermes derives the namespace from the plugin name and - # rejects ':' in skill names, so register the bare name. - try: - register_skill("tracedecay", skill_path) - except Exception as exc: - logger.warning("tracedecay skill registration failed: %s", exc) + skills_dir = Path(__file__).parent / "skills" + if callable(register_skill) and skills_dir.is_dir(): + direct_skills = [ + path + for path in sorted(skills_dir.iterdir(), key=lambda path: path.name) + if path.name != "agent-managed" + ] + managed_dir = skills_dir / "agent-managed" + managed_skills = ( + sorted(managed_dir.iterdir(), key=lambda path: path.name) + if managed_dir.is_dir() + else [] + ) + registered_skills = set() + for skill_dir in direct_skills + managed_skills: + if not skill_dir.is_dir(): + logger.debug("tracedecay skill entry is not a directory; skipping: %s", skill_dir) + continue + skill_name = skill_dir.name + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", skill_name): + logger.warning("tracedecay skill name is invalid; skipping: %s", skill_name) + continue + if skill_name in registered_skills: + logger.warning("tracedecay skill name collides; skipping: %s", skill_name) + continue + skill_path = skill_dir / "SKILL.md" + if not skill_path.is_file(): + logger.debug("tracedecay skill has no SKILL.md; skipping: %s", skill_dir) + continue + registered_skills.add(skill_name) + # Hermes derives the plugin namespace and rejects ':' in skill + # names, so register every bundled/exported skill by bare name. + try: + register_skill(skill_name, skill_path) + except Exception as exc: + logger.warning( + "tracedecay skill registration failed for %s: %s", + skill_name, + exc, + ) diff --git a/src/agents/hermes/templates/skill.md b/src/agents/hermes/templates/skill.md index 8dc8b2672..50a14dda9 100644 --- a/src/agents/hermes/templates/skill.md +++ b/src/agents/hermes/templates/skill.md @@ -27,13 +27,27 @@ selects a TraceDecay installation, store, or project. TraceDecay always uses the normal user-profile installation and the same profile-sharded project store used by every other host. +Project facts remain sharded: each registered project's `tracedecay.db` owns +its `memory_facts` and derived banks. Durable preferences and projectless chat +facts use the profile-level `~/.tracedecay/user-memory.db` store. +`~/.tracedecay/global.db` remains the cross-project registry/usage database, +not a shared project-fact table with a project tag. + Resolve the code project from an explicit runtime project root when the host -provides one; otherwise use the process working directory and its Git root. +provides one; otherwise use stock Hermes' logical session workspace +(`agent.runtime_cwd`, including its per-session gateway context) and its Git +root. Fall back to `TERMINAL_CWD` and only then the process working directory. Project routing belongs to the CLI transport (`--project`), not to MCP tool arguments. Do not pass `storage_scope`, `hermes_home`, a Hermes profile name, or a configured project pin: those legacy routing inputs are not part of the current schemas and are rejected rather than translated. +Use `memory_scope=user` for durable user preferences and untethered chat. +Use `memory_scope=project` for codebase decisions and project knowledge. In a +project, Hermes recall combines user facts with the active project's facts and +labels their provenance. Without an initialized project, Hermes uses only user +memory and does not write project LCM data into an arbitrary working directory. + ## Memory - **Recall before external search.** Run `fact_search` (and `lcm_grep` for past diff --git a/src/agents/kiro.rs b/src/agents/kiro.rs index 1bb05afa0..04a330272 100644 --- a/src/agents/kiro.rs +++ b/src/agents/kiro.rs @@ -644,7 +644,9 @@ have been tried. Delegation is still appropriate for long-running execution work such as builds, tests, generated reports, or independent implementation tasks.\n\n\ For durable project/user facts, prefer `tracedecay_fact_store`, \ `tracedecay_fact_feedback`, and `tracedecay_memory_status` over ad-hoc notes. Do not \ -store secrets, credentials, or unnecessary PII in persistent facts.\n\n\ +store secrets, credentials, or unnecessary PII in persistent facts. Use \ +`memory_scope=user` for durable preferences or projectless chat and \ +`memory_scope=project` for active-codebase facts.\n\n\ {cli_fallback}\n\n\ If you discover a gap where an extractor, schema, or tracedecay tool could answer a \ question natively, propose opening an issue at \ diff --git a/src/agents/prompt_rules.rs b/src/agents/prompt_rules.rs index b8d276de5..d377d003b 100644 --- a/src/agents/prompt_rules.rs +++ b/src/agents/prompt_rules.rs @@ -33,6 +33,8 @@ const STANDARD_PARAGRAPHS: &[&str] = &[ queries that go beyond what the built-in tools expose.", "For durable project/user facts, prefer `tracedecay_fact_store`, \ `tracedecay_fact_feedback`, and `tracedecay_memory_status` over ad-hoc notes. \ + Use `memory_scope=user` for durable preferences or projectless chat and \ + `memory_scope=project` for active-codebase facts. \ Use `tracedecay_message_search` for active-project transcript recall when \ prior conversation context matters. Do not store secrets, credentials, or \ unnecessary PII in persistent facts.", diff --git a/src/automation/apply_policy.rs b/src/automation/apply_policy.rs index bc848da74..6d3bf18e3 100644 --- a/src/automation/apply_policy.rs +++ b/src/automation/apply_policy.rs @@ -13,7 +13,7 @@ pub(crate) enum MemoryApplySubject { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum MemoryApplyDecision { AutoApplyAllowed, - DryRunOnly, + ApplyIncomplete, ProposalOnly, NoValidOps, NoValidFacts, @@ -23,7 +23,7 @@ impl MemoryApplyDecision { pub(crate) fn as_str(self) -> &'static str { match self { Self::AutoApplyAllowed => "auto_apply_allowed", - Self::DryRunOnly => "dry_run_only", + Self::ApplyIncomplete => "apply_incomplete", Self::ProposalOnly => "proposal_only", Self::NoValidOps => "no_valid_ops", Self::NoValidFacts => "no_valid_facts", @@ -39,9 +39,9 @@ impl MemoryApplySubject { } } - fn dry_run_decision(self) -> MemoryApplyDecision { + fn incomplete_decision(self) -> MemoryApplyDecision { match self { - Self::CurationOps => MemoryApplyDecision::DryRunOnly, + Self::CurationOps => MemoryApplyDecision::ApplyIncomplete, Self::SessionFacts => MemoryApplyDecision::ProposalOnly, } } @@ -124,7 +124,7 @@ impl MemoryApplyPolicy { { MemoryApplyDecision::AutoApplyAllowed } else { - self.subject.dry_run_decision() + self.subject.incomplete_decision() } } diff --git a/src/automation/artifact_optimizer.rs b/src/automation/artifact_optimizer.rs index 1037683c9..0e46fd79d 100644 --- a/src/automation/artifact_optimizer.rs +++ b/src/automation/artifact_optimizer.rs @@ -23,7 +23,7 @@ pub(super) fn optimizer_ranked_changes( "validation_required": [ "inspect generated eval definitions", "run listed handoff tests", - "preserve dashboard approval before mutation", + "preserve validation and reversibility safeguards", ], "ready_for_codex_handoff": !is_blocked_improvement_decision(improvement_decision), })] diff --git a/src/automation/config.rs b/src/automation/config.rs index cf57e4bc9..c9bc3a64b 100644 --- a/src/automation/config.rs +++ b/src/automation/config.rs @@ -83,7 +83,10 @@ pub struct AutomationConfig { pub timeout_secs: u64, #[serde(default = "default_scheduler_tick_secs")] pub scheduler_tick_secs: u64, - #[serde(default)] + /// Legacy compatibility setting. Autonomous memory curation always + /// validates and applies accepted operations; explicit preview APIs remain + /// read-only until their caller requests apply. + #[serde(default = "default_true")] pub auto_apply_memory_ops: bool, #[serde(default)] pub auto_enable_skills: bool, @@ -118,7 +121,7 @@ impl Default for AutomationConfig { host_mode: AutomationHostMode::Standalone, timeout_secs: default_timeout_secs(), scheduler_tick_secs: default_scheduler_tick_secs(), - auto_apply_memory_ops: false, + auto_apply_memory_ops: true, auto_enable_skills: false, export_memory_digest: true, combine_due_tasks: true, @@ -211,6 +214,7 @@ pub struct AutomationConfigPatch { /// (`explanation.effective_apply_policy`). #[serde(default, skip_serializing)] pub require_dashboard_approval: Option, + /// Legacy compatibility setting; autonomous memory curation ignores it. #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_apply_memory_ops: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -268,6 +272,47 @@ pub fn effective_config( Ok(config) } +/// Resolves the profile-level projectless automation policy. +/// +/// A missing global `[automation]` table opts into the self-improving user +/// session defaults. An explicitly configured global table remains +/// authoritative, and the isolated user-automation sidecar can override it. +pub async fn effective_user_automation_config( + profile_root: &Path, + global: &AutomationConfig, + global_configured: bool, +) -> Result { + let base = if global_configured { + global.clone() + } else { + default_user_automation_config() + }; + let dashboard_root = crate::automation::runner::user_automation_root(profile_root); + let profile_patch = load_project_config(&dashboard_root).await?; + effective_config(&base, profile_patch.as_ref()) +} + +fn default_user_automation_config() -> AutomationConfig { + let task = || AutomationTaskConfig { + enabled: true, + schedule: Some("manual".to_string()), + ..AutomationTaskConfig::default() + }; + AutomationConfig { + enabled: true, + backend: AutomationBackend::CodexAppServer, + host_mode: AutomationHostMode::Standalone, + auto_apply_memory_ops: true, + auto_enable_skills: true, + tasks: AutomationTaskSet { + memory_curator: task(), + session_reflector: task(), + skill_writer: task(), + }, + ..AutomationConfig::default() + } +} + pub fn merge_project_config( current: Option, patch: AutomationConfigPatch, diff --git a/src/automation/host_receipts.rs b/src/automation/host_receipts.rs new file mode 100644 index 000000000..594b08754 --- /dev/null +++ b/src/automation/host_receipts.rs @@ -0,0 +1,292 @@ +use std::collections::BTreeMap; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use fs2::FileExt; +use serde::{Deserialize, Serialize}; + +use crate::daemon::{HookRouteMetadata, HookTerminalReceipt}; +use crate::errors::{Result, TraceDecayError}; +use crate::storage::PrivateStoreIo; +use crate::tracedecay::current_timestamp; + +const STATE_FILE: &str = "host_receipts.json"; +const LOCK_FILE: &str = "host_receipts.lock"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PendingHostReceipt { + pub generation: u64, + pub session_key: String, + pub dedupe_key: String, + pub received_at: i64, + pub route: Option, + pub receipt: HookTerminalReceipt, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadyHostReceipt { + pub pending: PendingHostReceipt, + pub transcript_watermark: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct HostReceiptReadiness { + generation: u64, + transcript_watermark: String, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct HostReceiptState { + #[serde(default)] + generation: u64, + #[serde(default)] + sessions: BTreeMap, + #[serde(default)] + readiness: BTreeMap, + #[serde(default)] + recent_dedupe_keys: Vec, +} + +fn config_error(message: impl Into) -> TraceDecayError { + TraceDecayError::Config { + message: message.into(), + } +} + +fn session_key(route: Option<&HookRouteMetadata>) -> String { + route + .and_then(|route| route.session_id.as_deref().or(route.thread_id.as_deref())) + .filter(|value| !value.is_empty()) + .unwrap_or("unknown-session") + .to_string() +} + +fn dedupe_key(session: &str, receipt: &HookTerminalReceipt) -> String { + format!( + "{session}\u{1f}{}\u{1f}{}\u{1f}{}", + receipt.turn_id.as_deref().unwrap_or_default(), + receipt.tool_call_id.as_deref().unwrap_or_default(), + receipt.transcript_watermark.as_deref().unwrap_or_default(), + ) +} + +fn paths(dashboard_root: &Path) -> (PathBuf, PathBuf, PathBuf) { + ( + dashboard_root.join(STATE_FILE), + dashboard_root.join(format!(".{STATE_FILE}.tmp")), + dashboard_root.join(LOCK_FILE), + ) +} + +fn with_locked_state( + dashboard_root: &Path, + mutate: impl FnOnce(&mut HostReceiptState) -> Result, +) -> Result { + std::fs::create_dir_all(dashboard_root).map_err(|error| { + config_error(format!("failed to create host receipt directory: {error}")) + })?; + let (state_path, temp_path, lock_path) = paths(dashboard_root); + let mut lock = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&lock_path) + .map_err(|error| config_error(format!("failed to open host receipt lock: {error}")))?; + lock.lock_exclusive() + .map_err(|error| config_error(format!("failed to lock host receipts: {error}")))?; + let mut state = std::fs::read(&state_path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default(); + let output = mutate(&mut state)?; + let bytes = serde_json::to_vec_pretty(&state)?; + PrivateStoreIo::write_file_atomically(&state_path, &temp_path, &bytes) + .map_err(|error| config_error(format!("failed to persist host receipts: {error}")))?; + let _ = lock.flush(); + let _ = lock.unlock(); + Ok(output) +} + +pub async fn record( + dashboard_root: &Path, + route: Option, + receipt: HookTerminalReceipt, +) -> Result { + let root = dashboard_root.to_path_buf(); + tokio::task::spawn_blocking(move || { + with_locked_state(&root, |state| { + let session = session_key(route.as_ref()); + let key = dedupe_key(&session, &receipt); + if state.recent_dedupe_keys.contains(&key) { + return Ok(false); + } + state.recent_dedupe_keys.push(key.clone()); + if state.recent_dedupe_keys.len() > 256 { + state.recent_dedupe_keys.remove(0); + } + state.generation = state.generation.saturating_add(1); + state.sessions.insert( + session.clone(), + PendingHostReceipt { + generation: state.generation, + session_key: session, + dedupe_key: key, + received_at: current_timestamp(), + route, + receipt, + }, + ); + Ok(true) + }) + }) + .await + .map_err(|error| config_error(format!("host receipt task failed: {error}")))? +} + +pub async fn mark_turn_ingested( + dashboard_root: &Path, + route: Option, + transcript_watermark: &str, +) -> Result<()> { + let root = dashboard_root.to_path_buf(); + let watermark = transcript_watermark.to_string(); + tokio::task::spawn_blocking(move || { + with_locked_state(&root, |state| { + let session = session_key(route.as_ref()); + state.readiness.insert( + session, + HostReceiptReadiness { + generation: state.generation, + transcript_watermark: watermark, + }, + ); + Ok(()) + }) + }) + .await + .map_err(|error| config_error(format!("host receipt task failed: {error}")))? +} + +pub async fn oldest_ready(dashboard_root: &Path) -> Result> { + let root = dashboard_root.to_path_buf(); + tokio::task::spawn_blocking(move || { + with_locked_state(&root, |state| { + let pending = state + .sessions + .values() + .filter(|receipt| { + state + .readiness + .get(&receipt.session_key) + .is_some_and(|ready| ready.generation >= receipt.generation) + }) + .min_by_key(|receipt| receipt.generation) + .cloned(); + Ok(pending.and_then(|pending| { + state + .readiness + .get(&pending.session_key) + .map(|ready| ReadyHostReceipt { + pending, + transcript_watermark: ready.transcript_watermark.clone(), + }) + })) + }) + }) + .await + .map_err(|error| config_error(format!("host receipt task failed: {error}")))? +} + +pub async fn mark_consumed( + dashboard_root: &Path, + session_key: &str, + generation: u64, +) -> Result<()> { + let root = dashboard_root.to_path_buf(); + let session_key = session_key.to_string(); + tokio::task::spawn_blocking(move || { + with_locked_state(&root, |state| { + if state + .sessions + .get(&session_key) + .is_some_and(|receipt| receipt.generation <= generation) + { + state.sessions.remove(&session_key); + } + Ok(()) + }) + }) + .await + .map_err(|error| config_error(format!("host receipt task failed: {error}")))? +} + +#[cfg(test)] +mod tests { + use super::*; + + fn receipt(id: &str) -> HookTerminalReceipt { + HookTerminalReceipt { + tool_call_id: Some(id.to_string()), + turn_id: Some("turn-1".to_string()), + status: Some("success".to_string()), + duration_ms: Some(12), + transcript_watermark: Some("watermark-1".to_string()), + } + } + + #[tokio::test] + async fn deduplicates_and_consumes_receipts() { + let tmp = tempfile::tempdir().unwrap(); + let route = Some(HookRouteMetadata { + session_id: Some("session-1".to_string()), + thread_id: None, + cwd: None, + worktree: None, + branch: None, + }); + assert!( + record(tmp.path(), route.clone(), receipt("call-1")) + .await + .unwrap() + ); + assert!(!record(tmp.path(), route, receipt("call-1")).await.unwrap()); + mark_turn_ingested(tmp.path(), pending_route("session-1"), "message-1") + .await + .unwrap(); + let pending = oldest_ready(tmp.path()).await.unwrap().unwrap().pending; + mark_consumed(tmp.path(), &pending.session_key, pending.generation) + .await + .unwrap(); + assert!(oldest_ready(tmp.path()).await.unwrap().is_none()); + } + + #[tokio::test] + async fn serves_parallel_sessions_oldest_first() { + let tmp = tempfile::tempdir().unwrap(); + for session_id in ["session-1", "session-2"] { + let route = pending_route(session_id); + assert!( + record(tmp.path(), route, receipt(session_id)) + .await + .unwrap() + ); + mark_turn_ingested(tmp.path(), pending_route(session_id), session_id) + .await + .unwrap(); + } + let pending = oldest_ready(tmp.path()).await.unwrap().unwrap(); + assert_eq!(pending.pending.session_key, "session-1"); + } + + fn pending_route(session_id: &str) -> Option { + Some(HookRouteMetadata { + session_id: Some(session_id.to_string()), + thread_id: None, + cwd: None, + worktree: None, + branch: None, + }) + } +} diff --git a/src/automation/jobs.rs b/src/automation/jobs.rs index 43040af2d..856e2e34b 100644 --- a/src/automation/jobs.rs +++ b/src/automation/jobs.rs @@ -29,7 +29,7 @@ use super::backend::{ use super::config::{AutomationBackend, AutomationConfig, AutomationHostMode}; use super::job_webhook; use super::lifecycle::generated_run_id; -use super::managed_skills::load_managed_skill; +use super::managed_skills::{ManagedSkillState, load_managed_skill}; use super::run_ledger::{ AutomationRunLedgerRecord, AutomationRunStatus, AutomationTrigger, append_run_record, load_run_records, @@ -757,7 +757,7 @@ async fn attached_skill_sections( let mut missing = Vec::new(); for skill_id in skill_ids { match load_managed_skill(profile_root, skill_id).await { - Ok(skill) => { + Ok(skill) if skill.metadata.state == ManagedSkillState::Active => { sections.push(format!( "## Attached skill: {} ({})\n{}\n", skill.metadata.title, @@ -766,7 +766,7 @@ async fn attached_skill_sections( )); attached.push(skill_id.clone()); } - Err(_) => missing.push(skill_id.clone()), + Ok(_) | Err(_) => missing.push(skill_id.clone()), } } (sections, attached, missing) diff --git a/src/automation/lifecycle.rs b/src/automation/lifecycle.rs index e57d355af..41a40f0cf 100644 --- a/src/automation/lifecycle.rs +++ b/src/automation/lifecycle.rs @@ -166,7 +166,10 @@ pub(crate) async fn scheduler_gate( task: AgentTaskKind, trigger: AutomationTrigger, ) -> Result<(SchedulerGate, Option>)> { - if trigger != AutomationTrigger::Scheduler { + if !matches!( + trigger, + AutomationTrigger::Scheduler | AutomationTrigger::HostReceipt + ) { return Ok((SchedulerGate::Proceed(None), None)); } @@ -184,7 +187,11 @@ pub(crate) async fn scheduler_gate( }; let activity = load_session_activity(sessions_db_path).await; - let decision = schedule_decision(config, task, &records, activity, now_secs); + let decision = if trigger == AutomationTrigger::HostReceipt { + super::scheduler::host_receipt_decision(config, task, &records, activity, now_secs) + } else { + schedule_decision(config, task, &records, activity, now_secs) + }; if let Some(reason) = scheduler_skip_reason(&decision, task) { return Ok((SchedulerGate::Skip(reason), Some(records))); } diff --git a/src/automation/managed_skill_model.rs b/src/automation/managed_skill_model.rs index 9a28d8133..154800e1d 100644 --- a/src/automation/managed_skill_model.rs +++ b/src/automation/managed_skill_model.rs @@ -38,7 +38,7 @@ pub enum SkillInstallTarget { impl SkillInstallTarget { pub fn is_native_overlay(self) -> bool { - matches!(self, Self::Cursor | Self::Codex) + matches!(self, Self::Cursor | Self::Codex | Self::Hermes) } /// True for hosts that reconcile their managed-skill listing as a @@ -72,6 +72,7 @@ pub fn default_managed_skill_targets() -> Vec { SkillInstallTarget::OpenCode, SkillInstallTarget::Kimi, SkillInstallTarget::Kiro, + SkillInstallTarget::Hermes, ] } @@ -228,6 +229,8 @@ impl ManagedSkillDraft { created_at: now, updated_at: now, approved_at: None, + absorbed_into: None, + archived_reason: None, provenance: self.provenance, }, body_markdown: self.body_markdown, @@ -268,6 +271,12 @@ pub struct ManagedSkillMetadata { /// skills and records written before this field existed. #[serde(default, skip_serializing_if = "Option::is_none")] pub approved_at: Option, + /// Canonical managed-skill id that absorbed this archived skill. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub absorbed_into: Option, + /// Structured lifecycle reason retained when a skill is archived. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archived_reason: Option, pub provenance: ManagedSkillProvenance, } @@ -593,4 +602,32 @@ mod tests { Some(r#"Use when checking "quoted" paths like C:\tmp"#) ); } + + #[test] + fn legacy_skill_without_consolidation_metadata_deserializes() { + let skill = ManagedSkillDraft { + id: "legacy-skill".to_string(), + title: "Legacy skill".to_string(), + summary: "Read records written before consolidation metadata.".to_string(), + category: "testing".to_string(), + targets: vec![SkillInstallTarget::Codex], + body_markdown: "# Legacy\n".to_string(), + support_files: Vec::new(), + provenance: ManagedSkillProvenance { + source: ManagedSkillSource::AutomationRun, + actor: "legacy".to_string(), + run_id: None, + }, + } + .materialize() + .unwrap(); + let mut value = serde_json::to_value(skill).unwrap(); + let metadata = value["metadata"].as_object_mut().unwrap(); + metadata.remove("absorbed_into"); + metadata.remove("archived_reason"); + + let decoded: ManagedSkill = serde_json::from_value(value).unwrap(); + assert_eq!(decoded.metadata.absorbed_into, None); + assert_eq!(decoded.metadata.archived_reason, None); + } } diff --git a/src/automation/managed_skill_validation.rs b/src/automation/managed_skill_validation.rs index ae6b0f674..4c811db6e 100644 --- a/src/automation/managed_skill_validation.rs +++ b/src/automation/managed_skill_validation.rs @@ -255,12 +255,6 @@ fn validate_skill_targets(targets: &[SkillInstallTarget]) -> Result<()> { } let mut seen = BTreeSet::new(); for target in targets { - if *target == SkillInstallTarget::Hermes { - return Err(config_error( - "managed skill targets cannot include Hermes because Hermes owns profile skills" - .to_string(), - )); - } if !seen.insert(*target) { return Err(config_error(format!( "duplicate managed skill target '{}'", @@ -392,14 +386,11 @@ mod tests { } #[test] - fn managed_skill_validation_rejects_hermes_target() { + fn managed_skill_validation_accepts_hermes_plugin_target() { let mut draft = valid_draft(); draft.targets = vec![SkillInstallTarget::Hermes]; - let err = draft.materialize().unwrap_err().to_string(); - assert!( - err.contains("managed skill targets cannot include Hermes"), - "unexpected error: {err}" - ); + let skill = draft.materialize().unwrap(); + assert_eq!(skill.metadata.targets, vec![SkillInstallTarget::Hermes]); } #[test] diff --git a/src/automation/managed_skills.rs b/src/automation/managed_skills.rs index a008e24df..932b61182 100644 --- a/src/automation/managed_skills.rs +++ b/src/automation/managed_skills.rs @@ -1,8 +1,12 @@ use std::collections::BTreeSet; +use std::fs::{File, OpenOptions}; +use std::io::Write; use std::path::{Path, PathBuf}; use super::config_error; -use crate::errors::{Result, TraceDecayError}; +use crate::errors::Result; +use fs2::FileExt; +use serde::{Deserialize, Serialize}; use super::managed_skill_model::current_metadata_timestamp; pub use super::managed_skill_model::{ @@ -21,190 +25,435 @@ pub fn managed_skill_root(profile_root: &Path) -> PathBuf { profile_root.join("agent_managed").join("skills") } -pub fn managed_skill_dir(profile_root: &Path, id: &str) -> Result { - validate_skill_id(id)?; - Ok(managed_skill_root(profile_root).join(id)) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillConsolidationResult { + pub target: Option, + pub source: ManagedSkill, + pub target_before_checksum: Option, + pub target_after_checksum: Option, + pub source_before_checksum: String, + pub source_after_checksum: String, } -fn pending_update_path(profile_root: &Path, id: &str) -> Result { - Ok(managed_skill_dir(profile_root, id)?.join("pending_update.json")) +struct SkillStoreLock(File); + +impl Drop for SkillStoreLock { + fn drop(&mut self) { + let _ = FileExt::unlock(&self.0); + } } -pub async fn save_managed_skill(profile_root: &Path, skill: &ManagedSkill) -> Result<()> { - validate_managed_skill(skill)?; - let dir = managed_skill_dir(profile_root, &skill.metadata.id)?; - tokio::fs::create_dir_all(&dir) - .await - .map_err(|e| config_error(format!("failed to create managed skill dir: {e}")))?; - let record_path = dir.join("skill.json"); - let mut persisted = skill.clone(); - persisted.pending_update = None; - let record = serde_json::to_vec_pretty(&persisted).map_err(TraceDecayError::from)?; - tokio::fs::write(&record_path, record).await.map_err(|e| { +fn lock_skill_store(profile_root: &Path) -> Result { + let root = managed_skill_root(profile_root); + std::fs::create_dir_all(&root).map_err(|e| { config_error(format!( - "failed to write managed skill record '{}': {e}", - record_path.display() + "failed to create managed skill root '{}': {e}", + root.display() )) })?; - let skill_md = dir.join("SKILL.md"); - tokio::fs::write(&skill_md, skill.render_skill_markdown()) - .await + let path = root.join(".consolidation.lock"); + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&path) .map_err(|e| { config_error(format!( - "failed to write managed skill markdown '{}': {e}", - skill_md.display() - )) - })?; - remove_stale_support_files(&dir, &skill.support_files)?; - for support in &skill.support_files { - let path = dir.join(&support.path); - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await.map_err(|e| { - config_error(format!( - "failed to create managed skill support dir '{}': {e}", - parent.display() - )) - })?; - } - tokio::fs::write(&path, &support.bytes).await.map_err(|e| { - config_error(format!( - "failed to write managed skill support file '{}': {e}", + "failed to open skill lock '{}': {e}", path.display() )) })?; + file.lock_exclusive().map_err(|e| { + config_error(format!( + "failed to lock skill store '{}': {e}", + path.display() + )) + })?; + recover_skill_transaction(&root)?; + Ok(SkillStoreLock(file)) +} + +async fn lock_skill_store_async(profile_root: &Path) -> Result { + let profile_root = profile_root.to_path_buf(); + tokio::task::spawn_blocking(move || lock_skill_store(&profile_root)) + .await + .map_err(|error| config_error(format!("managed skill lock task failed: {error}")))? +} + +const SKILL_TRANSACTION_JOURNAL: &str = ".skill-transaction.json"; + +#[derive(Debug, Serialize, Deserialize)] +struct SkillTransactionEntry { + id: String, + stage: PathBuf, + backup: PathBuf, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SkillTransactionJournal { + entries: Vec, +} + +fn write_synced(path: &Path, bytes: &[u8]) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + config_error(format!("failed to create '{}': {error}", parent.display())) + })?; + } + let mut file = File::create(path) + .map_err(|error| config_error(format!("failed to create '{}': {error}", path.display())))?; + file.write_all(bytes) + .map_err(|error| config_error(format!("failed to write '{}': {error}", path.display())))?; + file.sync_all() + .map_err(|error| config_error(format!("failed to sync '{}': {error}", path.display()))) +} + +fn remove_owned_dir(path: &Path) { + if path.is_dir() { + let _ = std::fs::remove_dir_all(path); + } +} + +fn clean_staged_entries(entries: &[SkillTransactionEntry]) { + for entry in entries { + remove_owned_dir(&entry.stage); + remove_owned_dir(&entry.backup); + } +} + +fn sync_directory(path: &Path) -> Result<()> { + #[cfg(unix)] + { + File::open(path) + .and_then(|dir| dir.sync_all()) + .map_err(|error| config_error(format!("failed to sync '{}': {error}", path.display()))) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +fn validate_transaction_entry(root: &Path, entry: &SkillTransactionEntry) -> Result<()> { + validate_skill_id(&entry.id)?; + let valid_owned_path = |path: &Path, prefix: &str| { + path.parent() == Some(root) + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(prefix)) + }; + if !valid_owned_path(&entry.stage, &format!(".stage-{}-", entry.id)) + || !valid_owned_path(&entry.backup, &format!(".backup-{}-", entry.id)) + { + return Err(config_error(format!( + "skill transaction entry '{}' escapes its managed root", + entry.id + ))); } - super::skill_usage::sync_skill_usage_metadata(profile_root, skill).await?; Ok(()) } -async fn load_pending_update( - profile_root: &Path, - id: &str, -) -> Result> { - let path = pending_update_path(profile_root, id)?; - let bytes = match tokio::fs::read(&path).await { +fn recover_skill_transaction(root: &Path) -> Result<()> { + let journal_path = root.join(SKILL_TRANSACTION_JOURNAL); + let bytes = match std::fs::read(&journal_path) { Ok(bytes) => bytes, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { return Err(config_error(format!( - "failed to read managed skill pending update '{}': {e}", - path.display() + "failed to read skill transaction journal '{}': {error}", + journal_path.display() ))); } }; - let mut pending: ManagedSkillPendingUpdate = serde_json::from_slice(&bytes).map_err(|e| { + let journal: SkillTransactionJournal = serde_json::from_slice(&bytes).map_err(|error| { config_error(format!( - "failed to parse managed skill pending update '{}': {e}", - path.display() + "failed to parse skill transaction journal '{}': {error}", + journal_path.display() )) })?; - pending.normalize_timestamps(); - validate_managed_pending_update(id, &pending)?; - Ok(Some(pending)) -} - -async fn save_pending_update( - profile_root: &Path, - id: &str, - pending: &ManagedSkillPendingUpdate, -) -> Result<()> { - validate_managed_pending_update(id, pending)?; - let path = pending_update_path(profile_root, id)?; - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await.map_err(|e| { - config_error(format!( - "failed to create managed skill pending update dir '{}': {e}", - parent.display() - )) - })?; + for entry in &journal.entries { + validate_transaction_entry(root, entry)?; + let destination = root.join(&entry.id); + if entry.stage.exists() { + if destination.exists() && !entry.backup.exists() { + std::fs::rename(&destination, &entry.backup).map_err(|error| { + config_error(format!( + "failed to back up '{}' during skill transaction recovery: {error}", + destination.display() + )) + })?; + } + if destination.exists() { + remove_owned_dir(&destination); + } + std::fs::rename(&entry.stage, &destination).map_err(|error| { + config_error(format!( + "failed to publish '{}' during skill transaction recovery: {error}", + destination.display() + )) + })?; + } else if !destination.exists() && entry.backup.exists() { + std::fs::rename(&entry.backup, &destination).map_err(|error| { + config_error(format!( + "failed to restore '{}' during skill transaction recovery: {error}", + destination.display() + )) + })?; + } } - let bytes = serde_json::to_vec_pretty(pending).map_err(TraceDecayError::from)?; - tokio::fs::write(&path, bytes).await.map_err(|e| { + sync_directory(root)?; + clean_staged_entries(&journal.entries); + std::fs::remove_file(&journal_path).map_err(|error| { config_error(format!( - "failed to write managed skill pending update '{}': {e}", - path.display() + "failed to clear skill transaction journal '{}': {error}", + journal_path.display() )) })?; + sync_directory(root)?; Ok(()) } -async fn remove_pending_update(profile_root: &Path, id: &str) -> Result<()> { - let path = pending_update_path(profile_root, id)?; - match tokio::fs::remove_file(&path).await { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(config_error(format!( - "failed to remove managed skill pending update '{}': {e}", - path.display() - ))), +fn stage_skill_directory( + root: &Path, + skill: &ManagedSkill, + nonce: u128, +) -> Result { + validate_managed_skill(skill)?; + let id = skill.metadata.id.clone(); + let stage = root.join(format!(".stage-{id}-{}-{nonce}", std::process::id())); + let backup = root.join(format!(".backup-{id}-{}-{nonce}", std::process::id())); + remove_owned_dir(&stage); + remove_owned_dir(&backup); + let entry = SkillTransactionEntry { id, stage, backup }; + let write_stage = || -> Result<()> { + std::fs::create_dir_all(&entry.stage).map_err(|error| { + config_error(format!( + "failed to create '{}': {error}", + entry.stage.display() + )) + })?; + let mut persisted = skill.clone(); + persisted.pending_update = None; + write_synced( + &entry.stage.join("skill.json"), + &serde_json::to_vec_pretty(&persisted)?, + )?; + write_synced( + &entry.stage.join("SKILL.md"), + skill.render_skill_markdown().as_bytes(), + )?; + if let Some(pending) = &skill.pending_update { + write_synced( + &entry.stage.join("pending_update.json"), + &serde_json::to_vec_pretty(pending)?, + )?; + } + for support in &skill.support_files { + write_synced(&entry.stage.join(&support.path), &support.bytes)?; + } + sync_directory(&entry.stage) + }; + if let Err(error) = write_stage() { + clean_staged_entries(std::slice::from_ref(&entry)); + return Err(error); } + Ok(entry) } -fn remove_stale_support_files(dir: &Path, support_files: &[ManagedSupportFile]) -> Result<()> { - let expected: BTreeSet = support_files - .iter() - .map(|support| support.path.clone()) - .collect(); - let existing = existing_support_files(dir)?; - for relative in existing { - if expected.contains(&relative) { - continue; +fn persist_skill_transaction_unlocked(profile_root: &Path, skills: &[&ManagedSkill]) -> Result<()> { + let root = managed_skill_root(profile_root); + let mut ids = BTreeSet::new(); + for skill in skills { + if !ids.insert(skill.metadata.id.as_str()) { + return Err(config_error(format!( + "managed skill transaction contains duplicate id '{}'", + skill.metadata.id + ))); + } + } + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let mut entries = Vec::with_capacity(skills.len()); + for skill in skills { + match stage_skill_directory(&root, skill, nonce) { + Ok(entry) => entries.push(entry), + Err(error) => { + clean_staged_entries(&entries); + return Err(error); + } } - let path = dir.join(&relative); - std::fs::remove_file(&path).map_err(|e| { + } + let journal = SkillTransactionJournal { entries }; + let journal_path = root.join(SKILL_TRANSACTION_JOURNAL); + let journal_temp = root.join(format!("{SKILL_TRANSACTION_JOURNAL}.tmp")); + let publish_journal = || -> Result<()> { + write_synced(&journal_temp, &serde_json::to_vec_pretty(&journal)?)?; + std::fs::rename(&journal_temp, &journal_path).map_err(|error| { config_error(format!( - "failed to remove stale managed skill support file '{}': {e}", - path.display() + "failed to publish skill transaction journal: {error}" )) - })?; + }) + }; + if let Err(error) = publish_journal() { + let _ = std::fs::remove_file(&journal_temp); + clean_staged_entries(&journal.entries); + return Err(error); } - Ok(()) + sync_directory(&root)?; + recover_skill_transaction(&root) } -fn existing_support_files(dir: &Path) -> Result> { - fn visit(root: &Path, dir: &Path, out: &mut Vec) -> Result<()> { - let entries = match std::fs::read_dir(dir) { - Ok(entries) => entries, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(e) => { - return Err(config_error(format!( - "failed to read managed skill support files '{}': {e}", - dir.display() - ))); - } - }; - for entry in entries { - let entry = entry.map_err(|e| { - config_error(format!( - "failed to read managed skill support file entry '{}': {e}", - dir.display() - )) - })?; - let path = entry.path(); - if path.is_dir() { - visit(root, &path, out)?; - continue; - } - let relative = path.strip_prefix(root).map_err(|e| { - config_error(format!( - "failed to relativize managed skill support file '{}': {e}", - path.display() - )) - })?; - if relative == Path::new("skill.json") - || relative == Path::new("SKILL.md") - || relative == Path::new("pending_update.json") - { - continue; - } - out.push(relative.to_path_buf()); +/// Re-loads and checksum-validates both revisions under one store lock, then +/// publishes target and archived source through one crash-recoverable directory +/// transaction. Source content is never deleted. +pub async fn apply_managed_skill_consolidation( + profile_root: &Path, + target_id: Option<&str>, + target_checksum: Option<&str>, + target_update: Option, + source_id: &str, + source_checksum: &str, + reason: &str, +) -> Result { + if target_id == Some(source_id) { + return Err(config_error( + "managed skill consolidation source and target must differ", + )); + } + let lock = lock_skill_store_async(profile_root).await?; + let mut source = load_managed_skill_unlocked(profile_root, source_id)?; + validate_autonomous_consolidation_skill(&source, source_checksum)?; + let source_before_checksum = source.metadata.checksum.clone(); + + let mut original_target = None; + let mut target = match target_id { + Some(id) => { + let loaded = load_managed_skill_unlocked(profile_root, id)?; + let checksum = + target_checksum.ok_or_else(|| config_error("merge target checksum is required"))?; + validate_autonomous_consolidation_skill(&loaded, checksum)?; + original_target = Some(loaded.clone()); + Some(loaded) } - Ok(()) + None => None, + }; + + if let (Some(target), Some(update)) = (&mut target, target_update) { + if apply_managed_skill_update(target, update)? { + target.touch(); + target.refresh_checksum(); + } + } + + source.metadata.absorbed_into = target_id.map(ToOwned::to_owned); + source.metadata.archived_reason = Some(reason.to_string()); + source.set_state(ManagedSkillState::Archived); + source.pending_update = None; + let mut revisions: Vec<&ManagedSkill> = target.iter().collect(); + revisions.push(&source); + persist_skill_transaction_unlocked(profile_root, &revisions)?; + drop(lock); + for skill in &revisions { + if let Err(error) = super::skill_usage::sync_skill_usage_metadata(profile_root, skill).await + { + tracing::warn!(skill_id = %skill.metadata.id, error = %error, "skill usage metadata reconciliation failed after committed consolidation"); + } + } + + Ok(SkillConsolidationResult { + target_before_checksum: original_target + .as_ref() + .map(|skill| skill.metadata.checksum.clone()), + target_after_checksum: target.as_ref().map(|skill| skill.metadata.checksum.clone()), + source_before_checksum, + source_after_checksum: source.metadata.checksum.clone(), + target, + source, + }) +} + +fn validate_autonomous_consolidation_skill(skill: &ManagedSkill, checksum: &str) -> Result<()> { + let id = &skill.metadata.id; + if skill.metadata.checksum != checksum { + return Err(config_error(format!( + "base_checksum for managed skill id '{id}' is stale" + ))); + } + if skill.metadata.provenance.source != ManagedSkillSource::AutomationRun { + return Err(config_error(format!( + "managed skill '{id}' is not automation-owned" + ))); + } + if skill.metadata.pinned { + return Err(config_error(format!( + "managed skill '{id}' is pinned and exempt from consolidation" + ))); } + if skill.metadata.state == ManagedSkillState::Archived { + return Err(config_error(format!( + "managed skill '{id}' is already archived" + ))); + } + if skill.pending_update.is_some() { + return Err(config_error(format!( + "managed skill '{id}' already has a pending update" + ))); + } + Ok(()) +} + +pub fn managed_skill_dir(profile_root: &Path, id: &str) -> Result { + validate_skill_id(id)?; + Ok(managed_skill_root(profile_root).join(id)) +} + +fn pending_update_path(profile_root: &Path, id: &str) -> Result { + Ok(managed_skill_dir(profile_root, id)?.join("pending_update.json")) +} + +pub async fn save_managed_skill(profile_root: &Path, skill: &ManagedSkill) -> Result<()> { + let lock = lock_skill_store_async(profile_root).await?; + let destination = managed_skill_dir(profile_root, &skill.metadata.id)?; + if destination.exists() { + return Err(config_error(format!( + "managed skill '{}' already exists; use a lifecycle update operation", + skill.metadata.id + ))); + } + persist_skill_transaction_unlocked(profile_root, &[skill])?; + drop(lock); + if let Err(error) = super::skill_usage::sync_skill_usage_metadata(profile_root, skill).await { + tracing::warn!(skill_id = %skill.metadata.id, error = %error, "skill usage metadata reconciliation failed after committed skill save"); + } + Ok(()) +} - let mut out = Vec::new(); - visit(dir, dir, &mut out)?; - Ok(out) +fn load_pending_update(profile_root: &Path, id: &str) -> Result> { + let path = pending_update_path(profile_root, id)?; + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => { + return Err(config_error(format!( + "failed to read managed skill pending update '{}': {e}", + path.display() + ))); + } + }; + let mut pending: ManagedSkillPendingUpdate = serde_json::from_slice(&bytes).map_err(|e| { + config_error(format!( + "failed to parse managed skill pending update '{}': {e}", + path.display() + )) + })?; + pending.normalize_timestamps(); + validate_managed_pending_update(id, &pending)?; + Ok(Some(pending)) } pub async fn create_managed_skill_draft( @@ -212,14 +461,31 @@ pub async fn create_managed_skill_draft( draft: ManagedSkillDraft, ) -> Result { let skill = draft.materialize()?; - save_managed_skill(profile_root, &skill).await?; + let lock = lock_skill_store_async(profile_root).await?; + let destination = managed_skill_dir(profile_root, &skill.metadata.id)?; + if destination.exists() { + return Err(config_error(format!( + "managed skill '{}' already exists", + skill.metadata.id + ))); + } + persist_skill_transaction_unlocked(profile_root, &[&skill])?; + drop(lock); + if let Err(error) = super::skill_usage::sync_skill_usage_metadata(profile_root, &skill).await { + tracing::warn!(skill_id = %skill.metadata.id, error = %error, "skill usage metadata reconciliation failed after committed skill create"); + } Ok(skill) } pub async fn load_managed_skill(profile_root: &Path, id: &str) -> Result { + let _lock = lock_skill_store_async(profile_root).await?; + load_managed_skill_unlocked(profile_root, id) +} + +fn load_managed_skill_unlocked(profile_root: &Path, id: &str) -> Result { let dir = managed_skill_dir(profile_root, id)?; let path = dir.join("skill.json"); - let bytes = tokio::fs::read(&path).await.map_err(|e| { + let bytes = std::fs::read(&path).map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { config_error(format!("managed skill '{id}' not found")) } else { @@ -237,28 +503,41 @@ pub async fn load_managed_skill(profile_root: &Path, id: &str) -> Result Result> { + let _lock = lock_skill_store_async(profile_root).await?; + list_managed_skills_unlocked(profile_root) +} + +pub(crate) fn load_active_managed_skills_snapshot( + profile_root: &Path, +) -> Result> { + let _lock = lock_skill_store(profile_root)?; + Ok(list_managed_skills_unlocked(profile_root)? + .into_iter() + .filter(|skill| skill.metadata.state == ManagedSkillState::Active) + .collect()) +} + +fn list_managed_skills_unlocked(profile_root: &Path) -> Result> { let root = managed_skill_root(profile_root); - let mut entries = match tokio::fs::read_dir(&root).await { + let entries = match std::fs::read_dir(&root) { Ok(entries) => entries, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), Err(e) => return Err(config_error(format!("failed to read managed skills: {e}"))), }; let mut skills = Vec::new(); - while let Some(entry) = entries - .next_entry() - .await - .map_err(|e| config_error(format!("failed to read managed skill entry: {e}")))? - { + for entry in entries { + let entry = + entry.map_err(|e| config_error(format!("failed to read managed skill entry: {e}")))?; let path = entry.path().join("skill.json"); if !path.is_file() { continue; } - let bytes = tokio::fs::read(&path).await.map_err(|e| { + let bytes = std::fs::read(&path).map_err(|e| { config_error(format!( "failed to read managed skill record '{}': {e}", path.display() @@ -272,7 +551,7 @@ pub async fn list_managed_skills(profile_root: &Path) -> Result Result { - let mut skill = load_managed_skill(profile_root, id).await?; + let lock = lock_skill_store_async(profile_root).await?; + let skill = set_managed_skill_state_unlocked(profile_root, id, state)?; + drop(lock); + record_skill_patch_best_effort(profile_root, &skill, "lifecycle").await; + Ok(skill) +} + +fn set_managed_skill_state_unlocked( + profile_root: &Path, + id: &str, + state: ManagedSkillState, +) -> Result { + let mut skill = load_managed_skill_unlocked(profile_root, id)?; skill.set_state(state); - save_managed_skill(profile_root, &skill).await?; - record_skill_patch(profile_root, &skill, "lifecycle".to_string()).await?; + persist_skill_transaction_unlocked(profile_root, &[&skill])?; Ok(skill) } @@ -296,10 +586,14 @@ pub async fn update_managed_skill( id: &str, update: ManagedSkillUpdate, ) -> Result { - let mut skill = load_managed_skill(profile_root, id).await?; + let lock = lock_skill_store_async(profile_root).await?; + let mut skill = load_managed_skill_unlocked(profile_root, id)?; if skill.metadata.state != ManagedSkillState::PendingApproval { - return stage_managed_skill_update(profile_root, id, &skill.metadata.checksum, update) - .await; + let checksum = skill.metadata.checksum.clone(); + let skill = stage_managed_skill_update_unlocked(profile_root, id, &checksum, update)?; + drop(lock); + record_skill_patch_best_effort(profile_root, &skill, "staged_update").await; + return Ok(skill); } let content_changed = apply_managed_skill_update(&mut skill, update)?; if content_changed { @@ -307,8 +601,23 @@ pub async fn update_managed_skill( skill.touch(); skill.refresh_checksum(); } - save_managed_skill(profile_root, &skill).await?; - record_skill_patch(profile_root, &skill, "update".to_string()).await?; + persist_skill_transaction_unlocked(profile_root, &[&skill])?; + drop(lock); + record_skill_patch_best_effort(profile_root, &skill, "update").await; + Ok(skill) +} + +pub async fn set_managed_skill_pinned( + profile_root: &Path, + id: &str, + pinned: bool, +) -> Result { + let lock = lock_skill_store_async(profile_root).await?; + let mut skill = load_managed_skill_unlocked(profile_root, id)?; + skill.set_pinned(pinned); + persist_skill_transaction_unlocked(profile_root, &[&skill])?; + drop(lock); + record_skill_patch_best_effort(profile_root, &skill, "pin").await; Ok(skill) } @@ -318,7 +627,20 @@ pub async fn stage_managed_skill_update( base_checksum: &str, update: ManagedSkillUpdate, ) -> Result { - let skill = load_managed_skill(profile_root, id).await?; + let lock = lock_skill_store_async(profile_root).await?; + let skill = stage_managed_skill_update_unlocked(profile_root, id, base_checksum, update)?; + drop(lock); + record_skill_patch_best_effort(profile_root, &skill, "staged_update").await; + Ok(skill) +} + +fn stage_managed_skill_update_unlocked( + profile_root: &Path, + id: &str, + base_checksum: &str, + update: ManagedSkillUpdate, +) -> Result { + let skill = load_managed_skill_unlocked(profile_root, id)?; if base_checksum != skill.metadata.checksum { return Err(config_error(format!( "base_checksum for managed skill id '{id}' is stale" @@ -355,8 +677,9 @@ pub async fn stage_managed_skill_update( resulting_state: None, staged_reason: None, }; - save_pending_update(profile_root, id, &pending).await?; - record_skill_patch(profile_root, &staged, "staged_update".to_string()).await?; + let mut persisted = skill; + persisted.pending_update = Some(pending.clone()); + persist_skill_transaction_unlocked(profile_root, &[&persisted])?; Ok(pending.into_skill()) } @@ -371,7 +694,20 @@ pub async fn stage_managed_skill_archive( base_checksum: &str, reason: Option, ) -> Result { - let skill = load_managed_skill(profile_root, id).await?; + let lock = lock_skill_store_async(profile_root).await?; + let skill = stage_managed_skill_archive_unlocked(profile_root, id, base_checksum, reason)?; + drop(lock); + record_skill_patch_best_effort(profile_root, &skill, "staged_archive").await; + Ok(skill) +} + +fn stage_managed_skill_archive_unlocked( + profile_root: &Path, + id: &str, + base_checksum: &str, + reason: Option, +) -> Result { + let skill = load_managed_skill_unlocked(profile_root, id)?; if base_checksum != skill.metadata.checksum { return Err(config_error(format!( "base_checksum for managed skill id '{id}' is stale" @@ -406,8 +742,9 @@ pub async fn stage_managed_skill_archive( resulting_state: Some(ManagedSkillState::Archived), staged_reason: reason, }; - save_pending_update(profile_root, id, &pending).await?; - record_skill_patch(profile_root, &staged, "staged_archive".to_string()).await?; + let mut persisted = skill; + persisted.pending_update = Some(pending.clone()); + persist_skill_transaction_unlocked(profile_root, &[&persisted])?; Ok(pending.into_skill()) } @@ -415,12 +752,11 @@ pub async fn discard_pending_managed_skill_update( profile_root: &Path, id: &str, ) -> Result { - let skill = load_managed_skill(profile_root, id).await?; - remove_pending_update(profile_root, id).await?; - Ok(ManagedSkill { - pending_update: None, - ..skill - }) + let _lock = lock_skill_store_async(profile_root).await?; + let mut skill = load_managed_skill_unlocked(profile_root, id)?; + skill.pending_update = None; + persist_skill_transaction_unlocked(profile_root, &[&skill])?; + Ok(skill) } fn replace_if_changed(slot: &mut T, next: T) -> bool { @@ -463,29 +799,33 @@ fn apply_managed_skill_update( Ok(content_changed) } -async fn record_skill_patch( - profile_root: &Path, - skill: &ManagedSkill, - target: String, -) -> Result<()> { - super::skill_usage::record_skill_usage_event( +async fn record_skill_patch_best_effort(profile_root: &Path, skill: &ManagedSkill, target: &str) { + if let Err(error) = super::skill_usage::record_skill_usage_event( profile_root, super::skill_usage::SkillUsageEvent { skill_name: skill.metadata.id.clone(), action: super::skill_usage::SkillUsageAction::Patch, timestamp: crate::tracedecay::current_timestamp(), - target: Some(target), + target: Some(target.to_string()), }, Some(skill), ) - .await?; - Ok(()) + .await + { + tracing::warn!(skill_id = %skill.metadata.id, target, error = %error, "skill usage patch recording failed after committed skill change"); + } } pub async fn approve_managed_skill(profile_root: &Path, id: &str) -> Result { - let skill = load_managed_skill(profile_root, id).await?; - let approved = match skill.pending_update { - None => set_managed_skill_state(profile_root, id, ManagedSkillState::Active).await?, + let lock = lock_skill_store_async(profile_root).await?; + let skill = load_managed_skill_unlocked(profile_root, id)?; + let (approved, patch_target) = match skill.pending_update { + None => { + let mut active = skill; + active.set_state(ManagedSkillState::Active); + persist_skill_transaction_unlocked(profile_root, &[&active])?; + (active, "lifecycle") + } Some(pending) => { let resulting_state = pending.resulting_state.unwrap_or(ManagedSkillState::Active); let patch_target = match resulting_state { @@ -495,13 +835,15 @@ pub async fn approve_managed_skill(profile_root: &Path, id: &str) -> Result Result Result { - set_managed_skill_state(profile_root, id, ManagedSkillState::PendingApproval).await + let lock = lock_skill_store_async(profile_root).await?; + let mut skill = load_managed_skill_unlocked(profile_root, id)?; + skill.metadata.absorbed_into = None; + skill.metadata.archived_reason = None; + skill.set_state(ManagedSkillState::PendingApproval); + persist_skill_transaction_unlocked(profile_root, &[&skill])?; + drop(lock); + record_skill_patch_best_effort(profile_root, &skill, "restore").await; + Ok(skill) +} + +#[cfg(test)] +mod transaction_tests { + use super::*; + use std::sync::{Arc, Barrier}; + use std::time::Duration; + + fn skill(id: &str, body: &str) -> ManagedSkill { + ManagedSkillDraft { + id: id.to_string(), + title: id.to_string(), + summary: format!("Reusable workflow for {id}."), + category: "testing".to_string(), + targets: vec![SkillInstallTarget::Codex], + body_markdown: body.to_string(), + support_files: Vec::new(), + provenance: ManagedSkillProvenance { + source: ManagedSkillSource::AutomationRun, + actor: "test".to_string(), + run_id: Some("run-1".to_string()), + }, + } + .materialize() + .unwrap() + } + + #[tokio::test] + async fn recovery_rolls_forward_a_partially_published_multi_skill_transaction() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path(); + let original_a = skill("skill-a", "# A\nold"); + let original_b = skill("skill-b", "# B\nold"); + { + let _lock = lock_skill_store(profile).unwrap(); + persist_skill_transaction_unlocked(profile, &[&original_a, &original_b]).unwrap(); + } + + let mut next_a = original_a.clone(); + next_a.body_markdown = "# A\nnew".to_string(); + next_a.refresh_checksum(); + let mut next_b = original_b.clone(); + next_b.set_state(ManagedSkillState::Archived); + let root = managed_skill_root(profile); + let nonce = 7; + let entry_a = stage_skill_directory(&root, &next_a, nonce).unwrap(); + let entry_b = stage_skill_directory(&root, &next_b, nonce).unwrap(); + let journal = SkillTransactionJournal { + entries: vec![entry_a, entry_b], + }; + write_synced( + &root.join(SKILL_TRANSACTION_JOURNAL), + &serde_json::to_vec_pretty(&journal).unwrap(), + ) + .unwrap(); + let first = &journal.entries[0]; + std::fs::rename(root.join(&first.id), &first.backup).unwrap(); + std::fs::rename(&first.stage, root.join(&first.id)).unwrap(); + + drop(lock_skill_store(profile).unwrap()); + + let loaded_a = load_managed_skill(profile, "skill-a").await.unwrap(); + let loaded_b = load_managed_skill(profile, "skill-b").await.unwrap(); + assert_eq!(loaded_a.body_markdown, "# A\nnew"); + assert_eq!(loaded_b.metadata.state, ManagedSkillState::Archived); + assert!(!root.join(SKILL_TRANSACTION_JOURNAL).exists()); + assert!( + journal + .entries + .iter() + .all(|entry| !entry.stage.exists() && !entry.backup.exists()) + ); + } + + #[test] + fn transaction_rejects_duplicate_skill_ids() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path(); + let first = skill("same-skill", "# First"); + let second = skill("same-skill", "# Second"); + let _lock = lock_skill_store(profile).unwrap(); + + let error = persist_skill_transaction_unlocked(profile, &[&first, &second]).unwrap_err(); + + assert!(error.to_string().contains("duplicate id 'same-skill'")); + } + + #[test] + fn failed_journal_write_cleans_staged_directories() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path(); + let skill = skill("staged-skill", "# Staged"); + let root = managed_skill_root(profile); + let _lock = lock_skill_store(profile).unwrap(); + std::fs::create_dir(root.join(format!("{SKILL_TRANSACTION_JOURNAL}.tmp"))).unwrap(); + + persist_skill_transaction_unlocked(profile, &[&skill]).unwrap_err(); + + assert!(std::fs::read_dir(root).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".stage-") + })); + } + + #[tokio::test] + async fn consolidation_rejects_the_same_source_and_target() { + let temp = tempfile::tempdir().unwrap(); + let error = apply_managed_skill_consolidation( + temp.path(), + Some("same-skill"), + Some("checksum"), + None, + "same-skill", + "checksum", + "duplicate", + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("source and target must differ")); + } + + #[tokio::test(flavor = "current_thread")] + async fn async_store_wait_does_not_block_the_runtime_thread() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().to_path_buf(); + create_managed_skill_draft( + &profile, + ManagedSkillDraft { + id: "waiting-skill".to_string(), + title: "Waiting skill".to_string(), + summary: "Exercises asynchronous lock acquisition.".to_string(), + category: "testing".to_string(), + targets: vec![SkillInstallTarget::Codex], + body_markdown: "# Waiting".to_string(), + support_files: Vec::new(), + provenance: ManagedSkillProvenance { + source: ManagedSkillSource::AutomationRun, + actor: "test".to_string(), + run_id: Some("run-1".to_string()), + }, + }, + ) + .await + .unwrap(); + let lock = lock_skill_store(&profile).unwrap(); + let waiter_profile = profile.clone(); + let waiter = + tokio::spawn(async move { load_managed_skill(&waiter_profile, "waiting-skill").await }); + + tokio::time::sleep(Duration::from_millis(20)).await; + drop(lock); + + let loaded = tokio::time::timeout(Duration::from_secs(2), waiter) + .await + .expect("lock waiter should not block the runtime") + .unwrap() + .unwrap(); + assert_eq!(loaded.metadata.id, "waiting-skill"); + } + + #[tokio::test] + async fn public_save_rejects_stale_replacement() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path(); + let original = skill("existing-skill", "# Original"); + save_managed_skill(profile, &original).await.unwrap(); + let mut stale = original.clone(); + stale.body_markdown = "# Stale replacement".to_string(); + stale.refresh_checksum(); + + let error = save_managed_skill(profile, &stale).await.unwrap_err(); + + assert!(error.to_string().contains("already exists")); + assert_eq!( + load_managed_skill(profile, "existing-skill") + .await + .unwrap() + .body_markdown, + "# Original" + ); + } + + #[tokio::test] + async fn committed_change_survives_usage_ledger_failure() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path(); + let original = skill("ledger-skill", "# Ledger"); + save_managed_skill(profile, &original).await.unwrap(); + let ledger_path = super::super::skill_usage::skill_usage_ledger_path(profile); + std::fs::remove_file(&ledger_path).unwrap(); + std::fs::create_dir(&ledger_path).unwrap(); + + let pinned = set_managed_skill_pinned(profile, "ledger-skill", true) + .await + .unwrap(); + + assert!(pinned.metadata.pinned); + assert!( + load_managed_skill(profile, "ledger-skill") + .await + .unwrap() + .metadata + .pinned + ); + } + + #[test] + fn concurrent_export_recovers_before_reading_a_partial_publish() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().to_path_buf(); + let mut original_a = skill("skill-a", "# A\nold"); + original_a.set_state(ManagedSkillState::Active); + let mut original_b = skill("skill-b", "# B\nold"); + original_b.set_state(ManagedSkillState::Active); + { + let _lock = lock_skill_store(&profile).unwrap(); + persist_skill_transaction_unlocked(&profile, &[&original_a, &original_b]).unwrap(); + } + + let lock = lock_skill_store(&profile).unwrap(); + let mut next_a = original_a.clone(); + next_a.body_markdown = "# A\nnew".to_string(); + next_a.refresh_checksum(); + let mut next_b = original_b.clone(); + next_b.set_state(ManagedSkillState::Archived); + let root = managed_skill_root(&profile); + let entry_a = stage_skill_directory(&root, &next_a, 11).unwrap(); + let entry_b = stage_skill_directory(&root, &next_b, 11).unwrap(); + let journal = SkillTransactionJournal { + entries: vec![entry_a, entry_b], + }; + write_synced( + &root.join(SKILL_TRANSACTION_JOURNAL), + &serde_json::to_vec_pretty(&journal).unwrap(), + ) + .unwrap(); + let first = &journal.entries[0]; + std::fs::rename(root.join(&first.id), &first.backup).unwrap(); + std::fs::rename(&first.stage, root.join(&first.id)).unwrap(); + + let barrier = Arc::new(Barrier::new(2)); + let thread_barrier = Arc::clone(&barrier); + let thread_profile = profile.clone(); + let exporter = std::thread::spawn(move || { + thread_barrier.wait(); + crate::automation::skill_targets::load_active_managed_skills(&thread_profile).unwrap() + }); + barrier.wait(); + drop(lock); + + let exported = exporter.join().unwrap(); + assert_eq!(exported.len(), 1); + assert_eq!(exported[0].metadata.id, "skill-a"); + assert_eq!(exported[0].body_markdown, "# A\nnew"); + } } diff --git a/src/automation/memory_curator.rs b/src/automation/memory_curator.rs index d646db567..578b8d72b 100644 --- a/src/automation/memory_curator.rs +++ b/src/automation/memory_curator.rs @@ -12,9 +12,12 @@ use super::lifecycle::{AgentTaskRunContext, SchedulerGate, failed_backend_fallba use super::run_ledger::{AutomationRunLedgerRecord, AutomationTrigger}; use crate::dashboard::memory_curate::{ CURATION_DEFAULT_MAX_CLUSTERS, CURATION_DEFAULT_MIN_CONFIDENCE, MemoryCurateOptions, - run_memory_curate, + run_memory_curate, run_user_memory_curate, }; +use crate::db::Database; use crate::errors::{Result, TraceDecayError}; +use crate::memory::user::{open_user_memory_db, user_memory_db_path}; +use crate::sessions::user_sessions_db_path; use crate::tracedecay::TraceDecay; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -54,10 +57,101 @@ pub async fn run_memory_curator_with_backend( config: &AutomationConfig, backend: &dyn AgentTaskBackend, options: MemoryCuratorAutomationOptions, +) -> Result { + let mut autonomous_config = config.clone(); + autonomous_config.auto_apply_memory_ops = true; + run_memory_curator_for_store( + MemoryCuratorStore::Project(cg), + &autonomous_config, + backend, + options, + ) + .await +} + +/// Runs autonomous curation against profile-level user memory. +pub async fn run_user_memory_curator_with_backend( + profile_root: &std::path::Path, + config: &AutomationConfig, + backend: &dyn AgentTaskBackend, + options: MemoryCuratorAutomationOptions, +) -> Result { + let db = open_user_memory_db(profile_root).await?; + let mut autonomous_config = config.clone(); + autonomous_config.auto_apply_memory_ops = true; + run_memory_curator_for_store( + MemoryCuratorStore::User { + profile_root, + db: &db, + }, + &autonomous_config, + backend, + options, + ) + .await +} + +enum MemoryCuratorStore<'a> { + Project(&'a TraceDecay), + User { + profile_root: &'a std::path::Path, + db: &'a Database, + }, +} + +impl MemoryCuratorStore<'_> { + fn dashboard_root(&self) -> std::path::PathBuf { + match self { + Self::Project(cg) => cg.store_layout().dashboard_root.clone(), + Self::User { profile_root, .. } => super::runner::user_automation_root(profile_root), + } + } + + fn sessions_db_path(&self) -> std::path::PathBuf { + match self { + Self::Project(cg) => cg.store_layout().sessions_db_path.clone(), + Self::User { profile_root, .. } => user_sessions_db_path(profile_root), + } + } + + async fn curate(&self, options: &MemoryCurateOptions) -> Result { + match self { + Self::Project(cg) => run_memory_curate(cg, options).await, + Self::User { profile_root, db } => { + run_user_memory_curate( + db, + &user_memory_db_path(profile_root), + profile_root, + &super::runner::user_automation_root(profile_root), + options, + ) + .await + } + } + } + + async fn refresh_digest(&self) { + if let Self::Project(cg) = self + && let Ok(project_db) = cg.open_project_store_db().await + { + crate::automation::memory_digest::refresh_memory_digest_after_memory_change( + project_db.conn(), + &cg.store_layout().project_root, + ) + .await; + } + } +} + +async fn run_memory_curator_for_store( + store: MemoryCuratorStore<'_>, + config: &AutomationConfig, + backend: &dyn AgentTaskBackend, + options: MemoryCuratorAutomationOptions, ) -> Result { let mut run = AgentTaskRunContext::new( - cg.store_layout().dashboard_root.clone(), - cg.store_layout().sessions_db_path.clone(), + store.dashboard_root(), + store.sessions_db_path(), options.run_id.clone(), "memory_curator", options.trigger, @@ -74,17 +168,15 @@ pub async fn run_memory_curator_with_backend( } }; - let review_report = run_memory_curate( - cg, - &MemoryCurateOptions { + let review_report = store + .curate(&MemoryCurateOptions { apply: false, llm: true, llm_ops: None, max_clusters, min_confidence, - }, - ) - .await?; + }) + .await?; let llm_review = review_report .get("llm_review") @@ -130,17 +222,15 @@ pub async fn run_memory_curator_with_backend( .response_output_json(&response, evidence_hash.clone()) .await?; - let dry_run_report = match run_memory_curate( - cg, - &MemoryCurateOptions { + let dry_run_report = match store + .curate(&MemoryCurateOptions { apply: false, llm: false, llm_ops: Some(proposed_ops.clone()), max_clusters, min_confidence, - }, - ) - .await + }) + .await { Ok(report) => report, Err(err) => { @@ -163,17 +253,15 @@ pub async fn run_memory_curator_with_backend( .and_then(Value::as_bool) .unwrap_or(false); let validated_report = if should_apply { - let mut applied_report = match run_memory_curate( - cg, - &MemoryCurateOptions { + let mut applied_report = match store + .curate(&MemoryCurateOptions { apply: true, llm: false, llm_ops: Some(proposed_ops.clone()), max_clusters, min_confidence, - }, - ) - .await + }) + .await { Ok(report) => report, Err(err) => { @@ -197,13 +285,7 @@ pub async fn run_memory_curator_with_backend( memory_curation_apply_policy(config, applied_ops.as_ref(), Some(applied_count)); annotate_memory_curation_report(&mut applied_report, apply_policy.clone()); if applied_count > 0 { - if let Ok(project_db) = cg.open_project_store_db().await { - crate::automation::memory_digest::refresh_memory_digest_after_memory_change( - project_db.conn(), - &cg.store_layout().project_root, - ) - .await; - } + store.refresh_digest().await; } applied_report } else { @@ -290,7 +372,10 @@ fn memory_curation_apply_policy( MemoryApplyDecision::AutoApplyAllowed => { "Accepted memory curation ops were applied autonomously and recorded in automation telemetry." } - MemoryApplyDecision::DryRunOnly | MemoryApplyDecision::ProposalOnly => { + MemoryApplyDecision::ApplyIncomplete => { + "Automation attempted to apply accepted memory curation ops, but one or more mutations did not complete." + } + MemoryApplyDecision::ProposalOnly => { "Automation recorded accepted memory curation ops without mutating the memory store." } MemoryApplyDecision::NoValidOps | MemoryApplyDecision::NoValidFacts => { @@ -299,7 +384,7 @@ fn memory_curation_apply_policy( }; let mut payload = policy.to_json(); if let Some(object) = payload.as_object_mut() { - object.insert("dry_run_first".to_string(), json!(true)); + object.insert("validated_before_apply".to_string(), json!(true)); object.insert("accepted_count".to_string(), json!(accepted_count)); if let Some(applied_count) = applied_count { object.insert("applied_count".to_string(), json!(applied_count)); diff --git a/src/automation/mod.rs b/src/automation/mod.rs index fb386e31b..2863e564b 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -11,6 +11,7 @@ pub mod backend; pub mod config; pub mod fact_proposals; pub mod hermes_skill_bridge; +pub mod host_receipts; mod job_webhook; pub mod jobs; pub mod lifecycle; diff --git a/src/automation/run_ledger.rs b/src/automation/run_ledger.rs index b719596b5..6fb9efb17 100644 --- a/src/automation/run_ledger.rs +++ b/src/automation/run_ledger.rs @@ -23,6 +23,7 @@ pub enum AutomationTrigger { ManualCli, Dashboard, Scheduler, + HostReceipt, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/automation/runner.rs b/src/automation/runner.rs index 220cac67e..fb5023e66 100644 --- a/src/automation/runner.rs +++ b/src/automation/runner.rs @@ -19,7 +19,7 @@ use super::lifecycle::{ }; use super::managed_skills::list_managed_skills; use super::run_ledger::{AutomationRunLedgerRecord, AutomationTrigger}; -use super::session_reflector::validate_fact_proposals; +use super::session_reflector::validate_fact_proposals_on_connection; use super::skill_usage::{ DEFAULT_SKILL_OVERLAP_LIMIT, ingest_project_analytics_events, skill_overlap_candidates, stale_skill_recommendations, summarize_skill_usage, @@ -33,16 +33,25 @@ use super::text::truncate_chars_for_prompt; use crate::analytics::{ToolUsageObservation, underused_tool_family_signals}; use crate::errors::{Result, TraceDecayError}; use crate::global_db::GlobalDb; +use crate::memory::user::open_user_memory_db; use crate::sessions::lcm::{ LcmGrepRequest, LcmGrepSort, LcmScope, LcmSessionReplayRequest, LcmSessionReplaySlice, }; +use crate::sessions::user_sessions_db_path; use crate::tracedecay::{TraceDecay, current_timestamp}; pub use super::memory_curator::{ MemoryCuratorAutomationOptions, MemoryCuratorAutomationRun, run_memory_curator_with_backend, + run_user_memory_curator_with_backend, }; const SKILL_ANALYTICS_IMPORT_LIMIT: usize = 2_000; +const USER_AUTOMATION_DIR: &str = "user-automation"; + +/// Profile-level artifact, ledger, and lock root for projectless automation. +pub fn user_automation_root(profile_root: &std::path::Path) -> PathBuf { + profile_root.join(USER_AUTOMATION_DIR) +} /// Bounds for the session-replay evidence channel. Worst case per session is /// `(4 + 4) * 500 + 3 * 700 = 6_100` snippet chars, so the default three @@ -170,6 +179,50 @@ pub struct SkillWriterAutomationRun { pub backend_response: Option, } +/// One callable projectless post-session review suitable for host hooks. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct UserSessionAutomationOptions { + #[serde(default)] + pub session_reflector: SessionReflectorAutomationOptions, + #[serde(default)] + pub memory_curator: MemoryCuratorAutomationOptions, + #[serde(default)] + pub skill_writer: SkillWriterAutomationOptions, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UserSessionAutomationRun { + pub session_reflector: SessionReflectorAutomationRun, + pub memory_curator: MemoryCuratorAutomationRun, + pub skill_writer: SkillWriterAutomationRun, +} + +pub async fn run_user_session_automation_with_backend( + profile_root: &std::path::Path, + config: &AutomationConfig, + backend: &dyn AgentTaskBackend, + options: UserSessionAutomationOptions, +) -> Result { + let session_reflector = run_user_session_reflector_with_backend( + profile_root, + config, + backend, + options.session_reflector, + ) + .await?; + let memory_curator = + run_user_memory_curator_with_backend(profile_root, config, backend, options.memory_curator) + .await?; + let skill_writer = + run_user_skill_writer_with_backend(profile_root, config, backend, options.skill_writer) + .await?; + Ok(UserSessionAutomationRun { + session_reflector, + memory_curator, + skill_writer, + }) +} + struct SkillWriterEvidenceBundle { profile_root: PathBuf, evidence: Value, @@ -203,9 +256,51 @@ pub async fn run_session_reflector_with_backend( backend: &dyn AgentTaskBackend, options: SessionReflectorAutomationOptions, ) -> Result { - let mut run = AgentTaskRunContext::new( + let memory_db = cg.open_project_store_db().await?; + run_session_reflector_for_store( cg.store_layout().dashboard_root.clone(), cg.store_layout().sessions_db_path.clone(), + memory_db.conn(), + Some(cg.store_layout().project_root.as_path()), + config, + backend, + options, + ) + .await +} + +/// Runs session reflection for projectless evidence and profile-level memory. +pub async fn run_user_session_reflector_with_backend( + profile_root: &std::path::Path, + config: &AutomationConfig, + backend: &dyn AgentTaskBackend, + options: SessionReflectorAutomationOptions, +) -> Result { + let memory_db = open_user_memory_db(profile_root).await?; + run_session_reflector_for_store( + user_automation_root(profile_root), + user_sessions_db_path(profile_root), + memory_db.conn(), + None, + config, + backend, + options, + ) + .await +} + +async fn run_session_reflector_for_store( + dashboard_root: PathBuf, + sessions_db_path: PathBuf, + memory_conn: &libsql::Connection, + digest_root: Option<&std::path::Path>, + config: &AutomationConfig, + backend: &dyn AgentTaskBackend, + options: SessionReflectorAutomationOptions, +) -> Result { + let mut run = AgentTaskRunContext::new( + dashboard_root, + sessions_db_path.clone(), options.run_id.clone(), "session_reflector", options.trigger, @@ -222,7 +317,14 @@ pub async fn run_session_reflector_with_backend( let SessionReflectorEvidenceBundle { evidence, evidence_hash, - } = match build_session_reflector_evidence(cg, &options).await? { + } = match build_session_reflector_evidence( + &run.dashboard_root, + &sessions_db_path, + memory_conn, + &options, + ) + .await? + { SessionReflectorEvidenceOutcome::Ready(bundle) => bundle, SessionReflectorEvidenceOutcome::Skipped { reason, @@ -266,7 +368,8 @@ pub async fn run_session_reflector_with_backend( ) .await?; let (report, record) = finalize_session_reflector_success( - cg, + memory_conn, + digest_root, &finalizer, &run.dashboard_root, &run.run_id, @@ -293,7 +396,8 @@ pub async fn run_session_reflector_with_backend( /// returning the report plus the not-yet-appended success ledger record. #[allow(clippy::too_many_arguments)] async fn finalize_session_reflector_success( - cg: &TraceDecay, + memory_conn: &libsql::Connection, + digest_root: Option<&std::path::Path>, finalizer: &AgentRunFinalizer<'_>, dashboard_root: &std::path::Path, run_id: &str, @@ -303,7 +407,8 @@ async fn finalize_session_reflector_success( proposed_ops: &Value, proposals: &[Value], ) -> Result<(Value, AutomationRunLedgerRecord)> { - let (accepted_facts, rejected_facts) = validate_fact_proposals(cg, proposals, evidence).await?; + let (accepted_facts, rejected_facts) = + validate_fact_proposals_on_connection(memory_conn, proposals, evidence).await?; let accepted_count = accepted_facts.len(); let rejected_count = rejected_facts.len(); let mut proposal_records = record_session_fact_proposals( @@ -316,8 +421,13 @@ async fn finalize_session_reflector_success( .await?; let auto_apply_facts = MemoryApplyPolicy::should_apply(accepted_count); let applied_fact_proposals = if auto_apply_facts { - auto_apply_session_fact_proposals(cg, dashboard_root, std::mem::take(&mut proposal_records)) - .await? + auto_apply_session_fact_proposals( + memory_conn, + digest_root, + dashboard_root, + std::mem::take(&mut proposal_records), + ) + .await? } else { Vec::new() }; @@ -415,11 +525,11 @@ async fn finalize_session_reflector_success( } async fn auto_apply_session_fact_proposals( - cg: &TraceDecay, + memory_conn: &libsql::Connection, + digest_root: Option<&std::path::Path>, dashboard_root: &std::path::Path, proposal_records: Vec, ) -> Result> { - let project_db = cg.open_project_store_db().await?; let mut applied = Vec::with_capacity(proposal_records.len()); for record in proposal_records { if record.state != FactProposalState::PendingApproval { @@ -429,7 +539,7 @@ async fn auto_apply_session_fact_proposals( applied.push( apply_fact_proposal( dashboard_root, - project_db.conn(), + memory_conn, &record.proposal_id, Some("session_reflector:auto_apply".to_string()), ) @@ -440,11 +550,13 @@ async fn auto_apply_session_fact_proposals( .iter() .any(|record| record.state == FactProposalState::Applied) { - crate::automation::memory_digest::refresh_memory_digest_after_memory_change( - project_db.conn(), - &cg.store_layout().project_root, - ) - .await; + if let Some(digest_root) = digest_root { + crate::automation::memory_digest::refresh_memory_digest_after_memory_change( + memory_conn, + digest_root, + ) + .await; + } } Ok(applied) } @@ -455,9 +567,47 @@ pub async fn run_skill_writer_with_backend( backend: &dyn AgentTaskBackend, options: SkillWriterAutomationOptions, ) -> Result { - let mut run = AgentTaskRunContext::new( + run_skill_writer_for_store( cg.store_layout().dashboard_root.clone(), cg.store_layout().sessions_db_path.clone(), + Some(cg.project_root()), + config, + backend, + options, + ) + .await +} + +/// Runs skill writing from profile-level projectless session evidence. +pub async fn run_user_skill_writer_with_backend( + profile_root: &std::path::Path, + config: &AutomationConfig, + backend: &dyn AgentTaskBackend, + mut options: SkillWriterAutomationOptions, +) -> Result { + options.profile_root = Some(profile_root.to_path_buf()); + run_skill_writer_for_store( + user_automation_root(profile_root), + user_sessions_db_path(profile_root), + None, + config, + backend, + options, + ) + .await +} + +async fn run_skill_writer_for_store( + dashboard_root: PathBuf, + sessions_db_path: PathBuf, + analytics_project_root: Option<&std::path::Path>, + config: &AutomationConfig, + backend: &dyn AgentTaskBackend, + options: SkillWriterAutomationOptions, +) -> Result { + let mut run = AgentTaskRunContext::new( + dashboard_root, + sessions_db_path.clone(), options.run_id.clone(), "skill_writer", options.trigger, @@ -471,13 +621,16 @@ pub async fn run_skill_writer_with_backend( } }; - let evidence_bundle = match build_skill_writer_evidence(cg, options).await? { - SkillWriterEvidenceOutcome::Ready(bundle) => bundle, - SkillWriterEvidenceOutcome::Skipped { - reason, - evidence_hash, - } => return skipped_skill_writer_run(&run, reason, evidence_hash).await, - }; + let evidence_bundle = + match build_skill_writer_evidence(&sessions_db_path, analytics_project_root, options) + .await? + { + SkillWriterEvidenceOutcome::Ready(bundle) => bundle, + SkillWriterEvidenceOutcome::Skipped { + reason, + evidence_hash, + } => return skipped_skill_writer_run(&run, reason, evidence_hash).await, + }; let SkillWriterEvidenceBundle { profile_root, evidence, @@ -603,7 +756,7 @@ async fn finalize_skill_writer_success( let rejected_count = proposal_outcome.rejected.len(); let report = json!({ "status": if config.auto_enable_skills { "auto_enabled" } else { "needs_approval" }, - "dry_run": true, + "dry_run": !config.auto_enable_skills, "task": "skill_writer", "evidence_hash": evidence_hash, "activation_policy": activation_policy, @@ -640,7 +793,7 @@ async fn finalize_skill_writer_success( record.rejected_ops = report.get("rejected_skills").cloned(); record.validation_report = Some(json!({ "status": report.get("status").cloned().unwrap_or_else(|| json!("needs_approval")), - "dry_run": true, + "dry_run": !config.auto_enable_skills, "activation_policy": activation_policy, "accepted_count": accepted_count, "rejected_count": rejected_count, @@ -649,22 +802,19 @@ async fn finalize_skill_writer_success( } async fn build_session_reflector_evidence( - cg: &TraceDecay, + dashboard_root: &std::path::Path, + sessions_db_path: &std::path::Path, + memory_conn: &libsql::Connection, options: &SessionReflectorAutomationOptions, ) -> Result { // Refresh outcomes of previously applied fact proposals so this run's // feedback artifact reports real post-apply quality. Best effort: a // missing memory store must not block reflection. - if let Ok(project_db) = cg.open_project_store_db().await { - if let Err(err) = super::outcomes::refresh_fact_outcomes( - &cg.store_layout().dashboard_root, - project_db.conn(), - current_timestamp(), - ) - .await - { - eprintln!("[tracedecay] warning: failed to refresh fact outcomes: {err}"); - } + if let Err(err) = + super::outcomes::refresh_fact_outcomes(dashboard_root, memory_conn, current_timestamp()) + .await + { + eprintln!("[tracedecay] warning: failed to refresh fact outcomes: {err}"); } let provider = normalized_non_empty(&options.provider).unwrap_or_else(default_session_provider); @@ -675,14 +825,13 @@ async fn build_session_reflector_evidence( let source = options.source.as_deref().and_then(normalized_non_empty); let role = options.role.as_deref().and_then(normalized_non_empty); - let sessions_db_path = cg.store_layout().sessions_db_path.clone(); if !sessions_db_path.is_file() { return Ok(SessionReflectorEvidenceOutcome::Skipped { reason: "lcm_not_ingested", evidence_hash: None, }); } - let Some(lcm_db) = GlobalDb::open_read_only_at(&sessions_db_path).await else { + let Some(lcm_db) = GlobalDb::open_read_only_at(sessions_db_path).await else { return Ok(SessionReflectorEvidenceOutcome::Skipped { reason: "lcm_unavailable", evidence_hash: None, @@ -769,7 +918,8 @@ async fn build_session_reflector_evidence( } async fn build_skill_writer_evidence( - cg: &TraceDecay, + sessions_db_path: &std::path::Path, + analytics_project_root: Option<&std::path::Path>, options: SkillWriterAutomationOptions, ) -> Result { let profile_root = match options.profile_root { @@ -781,14 +931,13 @@ async fn build_skill_writer_evidence( let query = normalized_non_empty(&options.query).unwrap_or_else(default_skill_writer_query); let evidence_limit = options.evidence_limit.clamp(1, 50); - let sessions_db_path = cg.store_layout().sessions_db_path.clone(); if !sessions_db_path.is_file() { return Ok(SkillWriterEvidenceOutcome::Skipped { reason: "lcm_not_ingested", evidence_hash: None, }); } - let Some(lcm_db) = GlobalDb::open_read_only_at(&sessions_db_path).await else { + let Some(lcm_db) = GlobalDb::open_read_only_at(sessions_db_path).await else { return Ok(SkillWriterEvidenceOutcome::Skipped { reason: "lcm_unavailable", evidence_hash: None, @@ -828,14 +977,16 @@ async fn build_skill_writer_evidence( None }; let existing_skills = list_managed_skills(&profile_root).await?; - let global_db = GlobalDb::open().await; - ingest_project_analytics_events( - &profile_root, - cg.project_root(), - global_db.as_ref(), - SKILL_ANALYTICS_IMPORT_LIMIT, - ) - .await?; + if let Some(project_root) = analytics_project_root { + let global_db = GlobalDb::open().await; + ingest_project_analytics_events( + &profile_root, + project_root, + global_db.as_ref(), + SKILL_ANALYTICS_IMPORT_LIMIT, + ) + .await?; + } let skill_usage_summaries = summarize_skill_usage(&profile_root, &existing_skills).await?; let stale_recommendations = stale_skill_recommendations( &skill_usage_summaries, @@ -952,7 +1103,7 @@ async fn skipped_skill_writer_run( /// Options for the scheduler-only combined reflector+skill pass. Manual /// (CLI/dashboard) runs stay per-task; this path exists so one backend call /// can serve both tasks when they are due in the same scheduler tick. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CombinedReviewAutomationOptions { #[serde(default, skip_serializing_if = "Option::is_none")] pub run_id: Option, @@ -960,6 +1111,23 @@ pub struct CombinedReviewAutomationOptions { pub session_reflector: SessionReflectorAutomationOptions, #[serde(default)] pub skill_writer: SkillWriterAutomationOptions, + #[serde(default = "scheduler_trigger")] + pub trigger: AutomationTrigger, +} + +fn scheduler_trigger() -> AutomationTrigger { + AutomationTrigger::Scheduler +} + +impl Default for CombinedReviewAutomationOptions { + fn default() -> Self { + Self { + run_id: None, + session_reflector: SessionReflectorAutomationOptions::default(), + skill_writer: SkillWriterAutomationOptions::default(), + trigger: AutomationTrigger::Scheduler, + } + } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -1008,6 +1176,7 @@ pub async fn run_combined_review_with_backend( } let dashboard_root = cg.store_layout().dashboard_root.clone(); let sessions_db_path = cg.store_layout().sessions_db_path.clone(); + let memory_db = cg.open_project_store_db().await?; let started_at = current_timestamp().to_string(); let (reflector_gate, _) = task_run_gate( @@ -1015,7 +1184,7 @@ pub async fn run_combined_review_with_backend( &dashboard_root, &sessions_db_path, AgentTaskKind::SessionReflector, - AutomationTrigger::Scheduler, + options.trigger, ) .await?; let _reflector_lock = match reflector_gate { @@ -1031,7 +1200,7 @@ pub async fn run_combined_review_with_backend( &dashboard_root, &sessions_db_path, AgentTaskKind::SkillWriter, - AutomationTrigger::Scheduler, + options.trigger, ) .await?; let _skill_lock = match skill_gate { @@ -1043,16 +1212,28 @@ pub async fn run_combined_review_with_backend( } }; - let reflector_bundle = - match build_session_reflector_evidence(cg, &options.session_reflector).await? { - SessionReflectorEvidenceOutcome::Ready(bundle) => bundle, - SessionReflectorEvidenceOutcome::Skipped { .. } => { - return Ok(CombinedReviewDispatch::NotCombined { - reason: "session_reflector_evidence_unavailable", - }); - } - }; - let skill_bundle = match build_skill_writer_evidence(cg, options.skill_writer).await? { + let reflector_bundle = match build_session_reflector_evidence( + &dashboard_root, + &sessions_db_path, + memory_db.conn(), + &options.session_reflector, + ) + .await? + { + SessionReflectorEvidenceOutcome::Ready(bundle) => bundle, + SessionReflectorEvidenceOutcome::Skipped { .. } => { + return Ok(CombinedReviewDispatch::NotCombined { + reason: "session_reflector_evidence_unavailable", + }); + } + }; + let skill_bundle = match build_skill_writer_evidence( + &sessions_db_path, + Some(cg.project_root()), + options.skill_writer, + ) + .await? + { SkillWriterEvidenceOutcome::Ready(bundle) => bundle, SkillWriterEvidenceOutcome::Skipped { .. } => { return Ok(CombinedReviewDispatch::NotCombined { @@ -1087,7 +1268,7 @@ pub async fn run_combined_review_with_backend( let reflector_finalizer = AgentRunFinalizer::new( &dashboard_root, &reflector_run_id, - AutomationTrigger::Scheduler, + options.trigger, config, AgentTaskKind::SessionReflector, &started_at, @@ -1097,7 +1278,7 @@ pub async fn run_combined_review_with_backend( let skill_finalizer = AgentRunFinalizer::new( &dashboard_root, &skill_run_id, - AutomationTrigger::Scheduler, + options.trigger, config, AgentTaskKind::SkillWriter, &started_at, @@ -1184,7 +1365,8 @@ pub async fn run_combined_review_with_backend( }; let (reflector_report, reflector_record) = finalize_session_reflector_success( - cg, + memory_db.conn(), + Some(cg.store_layout().project_root.as_path()), &reflector_finalizer, &dashboard_root, &reflector_run_id, @@ -1350,7 +1532,7 @@ fn build_skill_writer_prompt(evidence: &Value) -> String { "\n", "An empty skills array is a real option when the session ran smoothly with no corrections and produced no new technique, but do not reach for it as a default.\n", "\n", - "Response contract: Return only JSON with a skills array of managed skill creates or updates. New skills may omit action or use action=create and must include id, title, summary, category, body_markdown, optional targets, optional support_files with text content, and reason. Targets, when present, must be an array using cursor, codex, claude, agents, opencode, kimi, or kiro; Hermes is host-owned and must not be targeted. Updates must use action=update or action=patch, include id and base_checksum, and include at least one changed field among title, summary, category, targets, body_markdown/body, support_files, or pinned. For updates, support_files is a complete replacement list, not a partial file patch. Consolidations: when skill_overlap_candidates shows overlapping managed skills, you may propose action=merge (include id for the surviving skill, base_checksum, source_skill_id, source_base_checksum, reason, and optional merged title/summary/category/targets/body_markdown/support_files) or action=archive (include id, base_checksum, reason). Consolidations are always staged for human approval and archive-only; content is never deleted. Never propose merge or archive for pinned or user-authored skills. Activation is controlled only by the runner policy; do not assume activation from your response.\n", + "Response contract: Return only JSON with a skills array of managed skill creates or updates. New skills may omit action or use action=create and must include id, title, summary, category, body_markdown, optional targets, optional support_files with text content, and reason. Targets, when present, must be an array using cursor, codex, claude, agents, opencode, kimi, kiro, or hermes; Hermes exports are generated read-only under the TraceDecay plugin package and never overwrite host-owned user skills. Updates must use action=update or action=patch, include id and base_checksum, and include at least one changed field among title, summary, category, targets, body_markdown/body, support_files, or pinned. For updates, support_files is a complete replacement list, not a partial file patch. Consolidations: when skill_overlap_candidates shows overlapping managed skills, you may propose action=merge (include id for the surviving skill, base_checksum, source_skill_id, source_base_checksum, reason, and optional merged title/summary/category/targets/body_markdown/support_files) or action=archive (include id, base_checksum, reason). Consolidations preserve archived source content. The runner stages them by default and may auto-apply only when auto_enable_skills is explicitly enabled and every ownership, checksum, pin, pending-update, and scheduled-job guard passes. Never propose merge or archive for pinned or user-authored skills. Activation is controlled only by the runner policy; do not assume activation from your response.\n", ); format!( "{POLICY}{}", diff --git a/src/automation/scheduler.rs b/src/automation/scheduler.rs index 0ad36186d..3f7384bc2 100644 --- a/src/automation/scheduler.rs +++ b/src/automation/scheduler.rs @@ -314,6 +314,27 @@ pub fn schedule_decision( records: &[AutomationRunLedgerRecord], activity: SessionActivity, now_secs: i64, +) -> AutomationScheduleDecision { + schedule_decision_for_trigger(config, task, records, activity, now_secs, true) +} + +pub fn host_receipt_decision( + config: &AutomationConfig, + task: AgentTaskKind, + records: &[AutomationRunLedgerRecord], + activity: SessionActivity, + now_secs: i64, +) -> AutomationScheduleDecision { + schedule_decision_for_trigger(config, task, records, activity, now_secs, false) +} + +fn schedule_decision_for_trigger( + config: &AutomationConfig, + task: AgentTaskKind, + records: &[AutomationRunLedgerRecord], + activity: SessionActivity, + now_secs: i64, + enforce_schedule: bool, ) -> AutomationScheduleDecision { if !config.enabled { return AutomationScheduleDecision::skipped("automation_disabled"); @@ -331,20 +352,25 @@ pub fn schedule_decision( return AutomationScheduleDecision::skipped("task_disabled"); } - let Ok(schedule) = parse_schedule(task_config.schedule.as_deref()) else { - return AutomationScheduleDecision::skipped("scheduler_schedule_invalid"); - }; - let (interval_secs, cron) = match schedule { - AutomationSchedule::Manual => { + let (interval_secs, cron) = if enforce_schedule { + let Ok(schedule) = parse_schedule(task_config.schedule.as_deref()) else { + return AutomationScheduleDecision::skipped("scheduler_schedule_invalid"); + }; + let timing = match schedule { + AutomationSchedule::Manual => { + return AutomationScheduleDecision::skipped("scheduler_schedule_manual"); + } + AutomationSchedule::ConfiguredInterval => (task_config.interval_secs, None), + AutomationSchedule::Interval { every_secs } => (Some(every_secs), None), + AutomationSchedule::Cron(cron) => (None, Some(cron)), + }; + if timing.0.is_none() && timing.1.is_none() { return AutomationScheduleDecision::skipped("scheduler_schedule_manual"); } - AutomationSchedule::ConfiguredInterval => (task_config.interval_secs, None), - AutomationSchedule::Interval { every_secs } => (Some(every_secs), None), - AutomationSchedule::Cron(cron) => (None, Some(cron)), + timing + } else { + (None, None) }; - if interval_secs.is_none() && cron.is_none() { - return AutomationScheduleDecision::skipped("scheduler_schedule_manual"); - } // `min_idle_secs` is a true idle window: the project must have been quiet // (no LCM session ingest activity) for at least this long. An unknown @@ -357,9 +383,24 @@ pub fn schedule_decision( } } - if let Some(record) = - latest_non_skipped_record(records, task, Some(AutomationTrigger::Scheduler)) - { + // Session-evidence tasks are event-driven across every supported host. + // Cursor, Claude, Codex, and Hermes all ingest completed-turn evidence + // into the project session store; a newer activity watermark should wake + // reflection as soon as the idle window closes instead of waiting for the + // periodic interval. The interval remains the repair/backstop schedule. + let fresh_session_activity = task_consumes_session_evidence(task) + && latest_successful_record(records, task).is_some_and(|record| { + let started_at = record.started_at.parse::().ok().unwrap_or(0); + activity + .last_activity_secs + .is_some_and(|last_activity| last_activity > started_at) + }); + + if let Some(record) = latest_non_skipped_record( + records, + task, + enforce_schedule.then_some(AutomationTrigger::Scheduler), + ) { let completed_at = record.completed_at.parse::().ok().unwrap_or(0); if record.status == AutomationRunStatus::Failed { let failure = agent_task_failure_disposition( @@ -378,12 +419,12 @@ pub fn schedule_decision( } return AutomationScheduleDecision::due(); } - if let Some(interval_secs) = interval_secs { + if let Some(interval_secs) = interval_secs.filter(|_| !fresh_session_activity) { if elapsed_secs(completed_at, now_secs) < interval_secs { return AutomationScheduleDecision::skipped("scheduler_interval_not_elapsed"); } } - if let Some(cron) = cron { + if let Some(cron) = cron.filter(|_| !fresh_session_activity) { if !cron_is_due(&cron, Some(completed_at), now_secs) { return AutomationScheduleDecision::skipped("scheduler_cron_not_due"); } diff --git a/src/automation/session_reflector.rs b/src/automation/session_reflector.rs index 49885e2a9..972828baf 100644 --- a/src/automation/session_reflector.rs +++ b/src/automation/session_reflector.rs @@ -6,15 +6,11 @@ use crate::errors::Result; use crate::memory::retrieval::FactRetriever; use crate::memory::trust::{DEFAULT_TRUST, HIGH_TRUST_REPRESENTATIVE, LOW_TRUST_REPRESENTATIVE}; use crate::memory::types::{AddFactRequest, MemoryCategory}; -use crate::tracedecay::TraceDecay; - -pub(crate) async fn validate_fact_proposals( - cg: &TraceDecay, +pub(crate) async fn validate_fact_proposals_on_connection( + conn: &libsql::Connection, proposals: &[Value], evidence: &Value, ) -> Result<(Vec, Vec)> { - let db = cg.open_project_store_db().await?; - let conn = db.conn(); let retriever = FactRetriever::new(conn); let citations = EvidenceCitationSet::from_evidence(evidence); let mut accepted = Vec::new(); diff --git a/src/automation/skill_targets.rs b/src/automation/skill_targets.rs index 59a63531b..d4956695c 100644 --- a/src/automation/skill_targets.rs +++ b/src/automation/skill_targets.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use super::config_error; use crate::agents::safe_write_text_file; use crate::automation::managed_skills::{ - ManagedSkill, ManagedSkillState, ManagedSupportFile, managed_skill_root, + ManagedSkill, ManagedSupportFile, load_active_managed_skills_snapshot, validate_managed_support_files, }; use crate::config::{TRACEDECAY_DIR, USER_DATA_DIR_ENV}; @@ -62,9 +62,6 @@ pub fn install_managed_skills( target: SkillInstallTarget, output: &Path, ) -> Result { - if target == SkillInstallTarget::Hermes { - return Err(hermes_host_owned_error()); - } if target.is_native_overlay() { export_native_skill_overlay(profile_root, target, output) } else { @@ -259,25 +256,7 @@ fn remove_prompt_skill_indexes( } pub fn load_active_managed_skills(profile_root: &Path) -> Result> { - let root = managed_skill_root(profile_root); - if !root.exists() { - return Ok(Vec::new()); - } - - let mut skills = Vec::new(); - for entry in fs::read_dir(&root)? { - let entry = entry?; - let path = entry.path().join("skill.json"); - if !path.is_file() { - continue; - } - let skill: ManagedSkill = serde_json::from_slice(&fs::read(&path)?)?; - if skill.metadata.state == ManagedSkillState::Active { - skills.push(skill); - } - } - skills.sort_by(|left, right| left.metadata.id.cmp(&right.metadata.id)); - Ok(skills) + load_active_managed_skills_snapshot(profile_root) } pub fn load_active_managed_skills_for_target( diff --git a/src/automation/skill_writer.rs b/src/automation/skill_writer.rs index efd3beddf..f1bf9c944 100644 --- a/src/automation/skill_writer.rs +++ b/src/automation/skill_writer.rs @@ -7,9 +7,9 @@ use super::artifacts::sha256_bytes; use super::config::AutomationConfig; use super::managed_skills::{ ManagedSkill, ManagedSkillDraft, ManagedSkillProvenance, ManagedSkillSource, - ManagedSkillUpdate, ManagedSupportFile, SkillInstallTarget, approve_managed_skill, - create_managed_skill_draft, default_managed_skill_targets, list_managed_skills, - stage_managed_skill_archive, stage_managed_skill_update, + ManagedSkillUpdate, ManagedSupportFile, SkillInstallTarget, apply_managed_skill_consolidation, + approve_managed_skill, create_managed_skill_draft, default_managed_skill_targets, + list_managed_skills, stage_managed_skill_archive, stage_managed_skill_update, }; use super::skill_usage::{ SkillOverlapCandidate, SkillStaleRecommendation, SkillUsageSummary, @@ -28,9 +28,7 @@ use consolidation::{ }; /// Outcome of validating and applying one batch of `skill_writer` proposals. -/// `consolidations` holds staged merge/archive records; consolidations are -/// always staged for dashboard approval and never auto-applied, even when -/// `auto_enable_skills` is set. +/// `consolidations` holds staged or autonomously applied merge/archive records. #[derive(Debug, Default)] pub(crate) struct SkillProposalOutcome { pub created: Vec, @@ -55,6 +53,7 @@ pub(crate) async fn validate_and_apply_skill_proposals( let mut updated = Vec::new(); let mut consolidations = Vec::new(); let mut rejected = Vec::new(); + let mut refresh_exports = false; for proposal in proposals { match skill_proposal_action(proposal) { Ok(SkillProposalAction::Create) => { @@ -67,7 +66,7 @@ pub(crate) async fn validate_and_apply_skill_proposals( let approved = approve_managed_skill(profile_root, &skill.metadata.id) .await?; - refresh_managed_skill_exports_after_auto_enable(profile_root); + refresh_exports = true; approved } else { skill @@ -98,7 +97,7 @@ pub(crate) async fn validate_and_apply_skill_proposals( let approved = approve_managed_skill(profile_root, &skill.metadata.id) .await?; - refresh_managed_skill_exports_after_auto_enable(profile_root); + refresh_exports = true; approved } else { skill @@ -121,6 +120,41 @@ pub(crate) async fn validate_and_apply_skill_proposals( Ok(SkillProposalAction::Archive) => { match skill_archive_from_proposal(proposal, &existing_skills) { Ok(archive) => { + if auto_enable_skills { + if let Err(reason) = ensure_skill_not_referenced_by_scheduled_job( + profile_root, + &archive.skill_id, + ) { + rejected.push(rejected_skill(proposal, &reason)); + continue; + } + match apply_managed_skill_consolidation( + profile_root, + None, + None, + None, + &archive.skill_id, + &archive.base_checksum, + &archive.reason, + ) + .await + { + Ok(result) => { + existing_skills + .insert(archive.skill_id.clone(), result.source.clone()); + consolidations.push(applied_consolidation_record( + SkillProposalAction::Archive, + proposal, + &result, + )); + refresh_exports = true; + } + Err(err) => { + rejected.push(rejected_skill(proposal, &err.to_string())); + } + } + continue; + } match stage_managed_skill_archive( profile_root, &archive.skill_id, @@ -147,6 +181,42 @@ pub(crate) async fn validate_and_apply_skill_proposals( } Ok(SkillProposalAction::Merge) => { match skill_merge_from_proposal(proposal, &existing_skills) { + Ok(merge) if auto_enable_skills => { + if let Err(reason) = ensure_skill_not_referenced_by_scheduled_job( + profile_root, + &merge.source_skill_id, + ) { + rejected.push(rejected_skill(proposal, &reason)); + continue; + } + match apply_managed_skill_consolidation( + profile_root, + Some(&merge.target_skill_id), + Some(&merge.base_checksum), + merge.update.clone(), + &merge.source_skill_id, + &merge.source_base_checksum, + &merge.reason, + ) + .await + { + Ok(result) => { + existing_skills + .insert(merge.source_skill_id.clone(), result.source.clone()); + if let Some(target) = &result.target { + existing_skills + .insert(merge.target_skill_id.clone(), target.clone()); + } + consolidations.push(applied_consolidation_record( + SkillProposalAction::Merge, + proposal, + &result, + )); + refresh_exports = true; + } + Err(err) => rejected.push(rejected_skill(proposal, &err.to_string())), + } + } Ok(merge) => match stage_skill_merge(profile_root, &merge).await { Ok((source_skill, target_skill)) => { existing_skills @@ -171,6 +241,14 @@ pub(crate) async fn validate_and_apply_skill_proposals( Err(reason) => rejected.push(rejected_skill(proposal, &reason)), } } + if refresh_exports { + let export_result = refresh_managed_skill_exports_after_auto_enable(profile_root); + for record in &mut consolidations { + if record.get("approval_status").and_then(Value::as_str) == Some("auto_applied") { + record["journal"]["export_result"] = export_result.clone(); + } + } + } Ok(SkillProposalOutcome { created, updated, @@ -179,6 +257,94 @@ pub(crate) async fn validate_and_apply_skill_proposals( }) } +fn applied_consolidation_record( + action: SkillProposalAction, + proposal: &Value, + result: &super::managed_skills::SkillConsolidationResult, +) -> Value { + json!({ + "action": action.as_str(), + "proposal_action": action.as_str(), + "reason": proposal.get("reason").cloned().unwrap_or(Value::Null), + "approval_status": "auto_applied", + "resulting_state": "archived", + "archived_skill_id": result.source.metadata.id, + "source_skill_id": result.source.metadata.id, + "target_skill_id": result.target.as_ref().map(|skill| skill.metadata.id.clone()), + "absorbed_into": result.source.metadata.absorbed_into, + "target_update_staged": false, + "journal": { + "provenance": result.source.metadata.provenance, + "target_before_checksum": result.target_before_checksum, + "target_after_checksum": result.target_after_checksum, + "source_before_checksum": result.source_before_checksum, + "source_after_checksum": result.source_after_checksum, + "export_refresh": "requested_once_after_batch", + } + }) +} + +fn ensure_skill_not_referenced_by_scheduled_job( + profile_root: &Path, + skill_id: &str, +) -> std::result::Result<(), String> { + fn visit(dir: &Path, skill_id: &str, depth: usize) -> std::result::Result { + if depth > 5 { + return Ok(false); + } + let Ok(entries) = std::fs::read_dir(dir) else { + return Ok(false); + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if visit(&path, skill_id, depth + 1)? { + return Ok(true); + } + } else if path + .file_name() + .is_some_and(|name| name == "automation_jobs.json") + { + let bytes = std::fs::read(&path).map_err(|error| { + format!( + "cannot verify scheduled job references in '{}': {error}", + path.display() + ) + })?; + let value = serde_json::from_slice::(&bytes).map_err(|error| { + format!( + "cannot verify scheduled job references in '{}': {error}", + path.display() + ) + })?; + if value + .get("jobs") + .and_then(Value::as_array) + .is_some_and(|jobs| { + jobs.iter().any(|job| { + job.get("skill_ids") + .and_then(Value::as_array) + .is_some_and(|ids| { + ids.iter().any(|id| id.as_str() == Some(skill_id)) + }) + }) + }) + { + return Ok(true); + } + } + } + Ok(false) + } + if visit(profile_root, skill_id, 0)? { + Err(format!( + "managed skill '{skill_id}' is referenced by a scheduled job" + )) + } else { + Ok(()) + } +} + pub(crate) fn activation_policy(config: &AutomationConfig) -> &'static str { if config.auto_enable_skills { "auto_enable_after_validation" @@ -383,16 +549,16 @@ fn accepted_skill_proposal_record( record } -fn refresh_managed_skill_exports_after_auto_enable(profile_root: &Path) { +fn refresh_managed_skill_exports_after_auto_enable(profile_root: &Path) -> Value { let Some(home) = crate::agents::home_dir() else { - return; + return json!({"status": "skipped", "reason": "home_unavailable"}); }; let start = std::env::current_dir().unwrap_or_else(|_| home.clone()); let project_root = crate::automation::skill_materialization::resolve_project_root(&start); - for report in - crate::agents::export_managed_skills_to_agent_hosts(&home, &project_root, profile_root) - { - if let Some(error) = report.error { + let reports = + crate::agents::export_managed_skills_to_agent_hosts(&home, &project_root, profile_root); + for report in &reports { + if let Some(error) = &report.error { tracing::warn!( agent = %report.agent, error = %error, @@ -406,6 +572,11 @@ fn refresh_managed_skill_exports_after_auto_enable(profile_root: &Path) { profile_root, &project_root, ); + json!({ + "status": if reports.iter().any(|report| report.error.is_some()) { "partial_failure" } else { "succeeded" }, + "reports": reports, + "materialization_reconciled": true, + }) } fn accepted_skill_approval_status( @@ -687,6 +858,7 @@ fn normalized_non_empty(value: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use tempfile::tempdir; fn assert_err_eq(result: std::result::Result, expected: &str) { match result { @@ -769,4 +941,23 @@ mod tests { "support_files.text is required", ); } + + #[test] + fn scheduled_job_skill_reference_blocks_archive() { + let temp = tempdir().unwrap(); + let dashboard = temp.path().join("projects/p1/dashboard"); + std::fs::create_dir_all(&dashboard).unwrap(); + std::fs::write( + dashboard.join("automation_jobs.json"), + br#"{"schema_version":1,"jobs":[{"skill_ids":["keep-me"]}]}"#, + ) + .unwrap(); + + assert!( + ensure_skill_not_referenced_by_scheduled_job(temp.path(), "keep-me") + .unwrap_err() + .contains("referenced") + ); + ensure_skill_not_referenced_by_scheduled_job(temp.path(), "other").unwrap(); + } } diff --git a/src/automation/staged_notice.rs b/src/automation/staged_notice.rs index 65f1dbf1a..657ad4848 100644 --- a/src/automation/staged_notice.rs +++ b/src/automation/staged_notice.rs @@ -232,6 +232,8 @@ mod tests { created_at: 1, updated_at: 1, approved_at: None, + absorbed_into: None, + archived_reason: None, provenance: ManagedSkillProvenance { source: ManagedSkillSource::AutomationRun, actor: "test".to_string(), diff --git a/src/automation_cli.rs b/src/automation_cli.rs index fc9aa18e8..407cabae1 100644 --- a/src/automation_cli.rs +++ b/src/automation_cli.rs @@ -294,7 +294,7 @@ async fn handle_automation_skills_command( body, pinned, } => { - let mut skill = create_managed_skill_draft( + let skill = create_managed_skill_draft( &profile_root, ManagedSkillDraft { id, @@ -314,11 +314,15 @@ async fn handle_automation_skills_command( ) .await?; if pinned { - skill.set_pinned(true); - tracedecay::automation::managed_skills::save_managed_skill(&profile_root, &skill) - .await?; + tracedecay::automation::managed_skills::set_managed_skill_pinned( + &profile_root, + &skill.metadata.id, + true, + ) + .await? + } else { + skill } - skill } AutomationSkillsAction::Update { id, @@ -465,17 +469,10 @@ async fn handle_automation_run_command( match action { AutomationRunAction::MemoryCuration { - dry_run, max_clusters, min_confidence, path, } => { - if !dry_run { - return Err(tracedecay::errors::TraceDecayError::Config { - message: "automation run memory-curation only supports --dry-run true" - .to_string(), - }); - } let project_path = resolve_cli_project_root(path, None, None).await?; let cg = crate::serve::ensure_initialized(&project_path).await?; let dashboard_root = cg.store_layout().dashboard_root.clone(); @@ -504,7 +501,6 @@ async fn handle_automation_run_command( println!("{}", serde_json::to_string_pretty(&run)?); } AutomationRunAction::SessionReflection { - dry_run, provider, query, evidence_limit, @@ -518,12 +514,6 @@ async fn handle_automation_run_command( end_time, path, } => { - if !dry_run { - return Err(tracedecay::errors::TraceDecayError::Config { - message: "automation run session-reflection only supports --dry-run true" - .to_string(), - }); - } let project_path = resolve_cli_project_root(path, None, None).await?; let cg = crate::serve::ensure_initialized(&project_path).await?; let dashboard_root = cg.store_layout().dashboard_root.clone(); @@ -570,18 +560,11 @@ async fn handle_automation_run_command( println!("{}", serde_json::to_string_pretty(&run)?); } AutomationRunAction::SkillWriting { - dry_run, provider, query, evidence_limit, path, } => { - if !dry_run { - return Err(tracedecay::errors::TraceDecayError::Config { - message: "automation run skill-writing only supports --dry-run true" - .to_string(), - }); - } let project_path = resolve_cli_project_root(path, None, None).await?; let cg = crate::serve::ensure_initialized(&project_path).await?; let dashboard_root = cg.store_layout().dashboard_root.clone(); @@ -856,15 +839,10 @@ fn print_automation_config( && effective.host_mode == tracedecay::automation::config::AutomationHostMode::Standalone; let delegated_host = effective.host_mode == tracedecay::automation::config::AutomationHostMode::DelegatedHost; - // Automation applies its output without any human approval; `require_dashboard_approval` - // is deprecated and ignored for gating. The effective apply behavior per category is - // governed solely by the `auto_apply_*` switches, so surface it explicitly here so the - // config is not self-contradictory to a reader. - let memory_ops_policy = if effective.auto_apply_memory_ops { - "auto_apply" - } else { - "propose_only" - }; + // Automation applies validated memory output autonomously; + // `require_dashboard_approval` and `auto_apply_memory_ops` are retained + // only for legacy config compatibility and do not gate curation runs. + let memory_ops_policy = "validate_then_apply"; let skills_policy = if effective.auto_enable_skills { "auto_enable" } else { @@ -880,6 +858,7 @@ fn print_automation_config( "trace_decay_backend_calls": trace_decay_backend_calls, "delegated_host": delegated_host, "auto_apply_memory_ops": effective.auto_apply_memory_ops, + "auto_apply_memory_ops_legacy_config_only": true, "auto_enable_skills": effective.auto_enable_skills, "export_memory_digest": effective.export_memory_digest, "effective_apply_policy": { @@ -920,7 +899,10 @@ fn print_automation_config( effective.tasks.session_reflector.enabled ); println!("skill_writer: {}", effective.tasks.skill_writer.enabled); - println!("auto_apply_memory_ops: {}", effective.auto_apply_memory_ops); + println!( + "auto_apply_memory_ops: {} (legacy; autonomous curation always applies)", + effective.auto_apply_memory_ops + ); println!("auto_enable_skills: {}", effective.auto_enable_skills); println!("export_memory_digest: {}", effective.export_memory_digest); println!("apply_policy.human_approval_required: false"); diff --git a/src/branch.rs b/src/branch.rs index 925a22276..48a76fd5a 100644 --- a/src/branch.rs +++ b/src/branch.rs @@ -229,7 +229,13 @@ fn unique_branch_db_stem( if !conflicts(&base) { return Some(base); } - Some(format!("{base}-{}", short_branch_hash(branch_name))) + let hashed = format!("{base}-{}", short_branch_hash(branch_name)); + if !conflicts(&hashed) { + return Some(hashed); + } + (1..10_000) + .map(|suffix| format!("{hashed}-{suffix}")) + .find(|candidate| !conflicts(candidate)) } /// Short, stable hex digest of a branch name for DB-stem disambiguation. @@ -457,13 +463,11 @@ pub async fn prepare_branch_tracking_in_layout( } })?; let new_db_path = branches_dir.join(format!("{stem}.db")); - // Only the main `.db` is copied here; the parent's -wal/-shm are already - // checkpointed into it, so a single-file clone is a complete copy. We hold - // the branch-add lock, so the parent is not being written mid-clone. - if let Err(e) = clone_or_copy_db(&parent_db, &new_db_path) { - remove_branch_db_files(&new_db_path); - return Err(e.into()); - } + // Copy through SQLite rather than cloning the live main file. The + // branch-add lock serializes metadata changes, but it does not stop other + // processes from writing or checkpointing the parent WAL. + let snapshot_result = create_consistent_branch_snapshot(&parent_db, &new_db_path).await; + snapshot_result?; // Save metadata before the caller opens the new branch DB for sync. let db_file = format!("branches/{stem}.db"); @@ -582,19 +586,40 @@ fn remove_branch_db_files(db_path: &Path) { let _ = std::fs::remove_file(&sidecar); } -/// Clones `src` to `dst` using a reflink (copy-on-write, FICLONE) when the -/// filesystem supports it (btrfs/xfs/APFS), so a ~110MB branch-add DB copy is -/// an instant metadata operation. Falls back to a byte copy on ANY reflink -/// error (unsupported FS, cross-device, older kernel), so the destination is -/// always a complete, independent copy either way. -fn clone_or_copy_db(src: &Path, dst: &Path) -> std::io::Result<()> { - if reflink_copy::reflink(src, dst).is_ok() { - return Ok(()); +async fn create_consistent_branch_snapshot(src: &Path, dst: &Path) -> crate::errors::Result<()> { + let parent_dir = dst + .parent() + .ok_or_else(|| crate::errors::TraceDecayError::Config { + message: format!("branch snapshot path '{}' has no parent", dst.display()), + })?; + let stem = dst + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("branch"); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let temp = parent_dir.join(format!( + ".{stem}.snapshot-{}-{nonce}.db", + std::process::id() + )); + let result = async { + let (source, _) = crate::db::Database::open_read_only(src).await?; + source.snapshot_to(&temp).await?; + std::fs::hard_link(&temp, dst).map_err(|error| { + crate::errors::TraceDecayError::Config { + message: format!( + "failed to publish branch snapshot '{}' without replacing an existing store: {error}", + dst.display() + ), + } + })?; + Ok(()) } - // A failed reflink can leave a partial/zero-length destination behind; - // remove it so the byte copy starts clean. - let _ = std::fs::remove_file(dst); - std::fs::copy(src, dst).map(|_| ()) + .await; + remove_branch_db_files(&temp); + result } /// Untracks a single non-default branch: removes its metadata entry and deletes diff --git a/src/branch/tests.rs b/src/branch/tests.rs index d388adb62..9a6969d09 100644 --- a/src/branch/tests.rs +++ b/src/branch/tests.rs @@ -47,6 +47,20 @@ fn unique_stem_disambiguates_sanitization_collision() { assert!(stem.starts_with("feature_foo-"), "got: {stem}"); } +#[test] +fn unique_stem_preserves_hashed_orphan_recovery_file() { + let dir = tempfile::tempdir().unwrap(); + let mut meta = crate::branch_meta::BranchMeta::new("main"); + meta.add_branch("feature/foo", "branches/feature_foo.db", "main"); + let hashed = format!("feature_foo-{}", short_branch_hash("feature_foo")); + std::fs::write(dir.path().join(format!("{hashed}.db")), b"recovery").unwrap(); + + assert_eq!( + unique_branch_db_stem(&meta, dir.path(), "feature_foo").unwrap(), + format!("{hashed}-1") + ); +} + #[test] fn unique_stem_is_idempotent_for_same_branch() { // Recomputing for a branch already in meta must not treat its own entry @@ -179,18 +193,31 @@ fn add_tracked_branch( db_path } -#[test] -fn clone_or_copy_db_produces_identical_bytes() { +#[tokio::test] +async fn read_only_sqlite_snapshot_includes_committed_data() { let dir = tempfile::tempdir().unwrap(); let src = dir.path().join("src.db"); let dst = dir.path().join("dst.db"); - let payload: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); - std::fs::write(&src, &payload).unwrap(); - - clone_or_copy_db(&src, &dst).unwrap(); - - let copied = std::fs::read(&dst).unwrap(); - assert_eq!(copied, payload, "reflink-or-copy must be byte-identical"); + let (writer, _) = crate::db::Database::initialize(&src).await.unwrap(); + writer + .conn() + .execute_batch( + "CREATE TABLE snapshot_probe(value TEXT NOT NULL); + INSERT INTO snapshot_probe(value) VALUES ('committed-in-wal');", + ) + .await + .unwrap(); + + writer.snapshot_to(&dst).await.unwrap(); + + let (snapshot, _) = crate::db::Database::open_read_only(&dst).await.unwrap(); + let mut rows = snapshot + .conn() + .query("SELECT value FROM snapshot_probe", ()) + .await + .unwrap(); + let row = rows.next().await.unwrap().unwrap(); + assert_eq!(row.get::(0).unwrap(), "committed-in-wal"); } #[test] diff --git a/src/cli.rs b/src/cli.rs index 756df0113..a15529829 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -254,6 +254,12 @@ pub enum Commands { /// Codex PostCompact hook handler for app-server LCM summaries (called by Codex) #[command(name = "hook-codex-post-compact", hide = true)] HookCodexPostCompact, + /// Hermes terminal receipt handler (called by the TraceDecay plugin) + #[command(name = "hook-hermes-terminal-receipt", hide = true)] + HookHermesTerminalReceipt, + /// Detached profile user-session automation review. + #[command(name = "hook-user-session-review", hide = true)] + HookUserSessionReview, /// Serve the local dashboard UI (holographic memory + LCM + code graph explorers) #[command(long_about = DASHBOARD_LONG_ABOUT, after_help = DASHBOARD_AFTER_HELP)] Dashboard { diff --git a/src/cli/automation.rs b/src/cli/automation.rs index 4a7906b6a..b8403cb4a 100644 --- a/src/cli/automation.rs +++ b/src/cli/automation.rs @@ -173,9 +173,6 @@ pub enum AutomationRunAction { /// Build a memory-curation review, call the configured backend, and validate proposed ops. #[command(name = "memory-curation")] MemoryCuration { - /// Keep the run non-mutating. This is currently the only supported mode. - #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] - dry_run: bool, /// Maximum candidate clusters included in the backend review request. #[arg(long, default_value_t = tracedecay::dashboard::memory_curate::CURATION_DEFAULT_MAX_CLUSTERS)] max_clusters: usize, @@ -189,9 +186,6 @@ pub enum AutomationRunAction { /// Build a session-reflection fact proposal review from LCM evidence. #[command(name = "session-reflection")] SessionReflection { - /// Keep the run non-mutating. This is currently the only supported mode. - #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] - dry_run: bool, /// LCM provider to inspect. #[arg(long, default_value = "cursor")] provider: String, @@ -232,9 +226,6 @@ pub enum AutomationRunAction { /// Draft managed skills from repeated workflow evidence without activating them. #[command(name = "skill-writing")] SkillWriting { - /// Keep the run non-mutating. This is currently the only supported mode. - #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] - dry_run: bool, /// LCM provider to inspect. Use all for unified cross-provider evidence. #[arg(long, default_value = "all")] provider: String, diff --git a/src/cli/help.rs b/src/cli/help.rs index ed1b72480..fab850eff 100644 --- a/src/cli/help.rs +++ b/src/cli/help.rs @@ -481,7 +481,7 @@ Related: tracedecay dashboard (visual curation), tracedecay tool fact_store pub(crate) const AUTOMATION_LONG_ABOUT: &str = "\ Configures and drives the self-improvement automation loop (memory curator, \ -session reflector, skill writer): sidecar config, manual dry-runs, run \ +session reflector, skill writer): sidecar config, autonomous validated runs, run \ history with durable artifacts, managed-skill lifecycle, and fact-proposal \ review. Read-only MCP twins: tracedecay_skill_list, tracedecay_skill_view, \ tracedecay_automation_run_artifact_view."; @@ -490,7 +490,7 @@ pub(crate) const AUTOMATION_AFTER_HELP: &str = "\ Examples: tracedecay automation config get --json Effective config tracedecay automation config enable Turn the loop on - tracedecay automation run skill-writing Manual dry-run + tracedecay automation run skill-writing Run managed-skill evolution tracedecay automation runs list --json Run history tracedecay automation runs artifact run-123 codex_handoff --json tracedecay automation skills list Managed skills diff --git a/src/cli/parse_tests.rs b/src/cli/parse_tests.rs index 54948d631..b96de2b55 100644 --- a/src/cli/parse_tests.rs +++ b/src/cli/parse_tests.rs @@ -866,14 +866,12 @@ fn automation_config_commands_parse_project_sidecar_flags() { } #[test] -fn automation_run_memory_curation_parses_manual_dry_run_flags() { +fn automation_run_memory_curation_parses_manual_flags() { let cli = Cli::try_parse_from([ "tracedecay", "automation", "run", "memory-curation", - "--dry-run", - "true", "--max-clusters", "8", "--min-confidence", @@ -890,28 +888,24 @@ fn automation_run_memory_curation_parses_manual_dry_run_flags() { AutomationAction::Run { action: AutomationRunAction::MemoryCuration { - dry_run, max_clusters, min_confidence, path, } } - }) if dry_run - && max_clusters == 8 + }) if max_clusters == 8 && (min_confidence - 0.7).abs() < f64::EPSILON && path.as_deref() == Some("/tmp/project") )); } #[test] -fn automation_run_session_reflection_parses_manual_dry_run_flags() { +fn automation_run_session_reflection_parses_manual_flags() { let cli = Cli::try_parse_from([ "tracedecay", "automation", "run", "session-reflection", - "--dry-run", - "true", "--provider", "codex", "--query", @@ -946,7 +940,6 @@ fn automation_run_session_reflection_parses_manual_dry_run_flags() { AutomationAction::Run { action: AutomationRunAction::SessionReflection { - dry_run, provider, query, evidence_limit, @@ -961,8 +954,7 @@ fn automation_run_session_reflection_parses_manual_dry_run_flags() { path, } } - }) if dry_run - && provider == "codex" + }) if provider == "codex" && query == "remember decisions" && evidence_limit == 12 && scope == "session" @@ -978,14 +970,12 @@ fn automation_run_session_reflection_parses_manual_dry_run_flags() { } #[test] -fn automation_run_skill_writing_parses_manual_dry_run_flags() { +fn automation_run_skill_writing_parses_manual_flags() { let cli = Cli::try_parse_from([ "tracedecay", "automation", "run", "skill-writing", - "--dry-run", - "true", "--provider", "cursor", "--query", @@ -1004,15 +994,13 @@ fn automation_run_skill_writing_parses_manual_dry_run_flags() { AutomationAction::Run { action: AutomationRunAction::SkillWriting { - dry_run, provider, query, evidence_limit, path, } } - }) if dry_run - && provider == "cursor" + }) if provider == "cursor" && query == "workflow corrections" && evidence_limit == 9 && path.as_deref() == Some("/tmp/project") diff --git a/src/daemon.rs b/src/daemon.rs index 2e0fca48a..d98331646 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -141,6 +141,7 @@ pub enum HookAgent { Codex, Cursor, Kiro, + Hermes, } impl HookAgent { @@ -150,6 +151,7 @@ impl HookAgent { Self::Codex => "codex", Self::Cursor => "cursor", Self::Kiro => "kiro", + Self::Hermes => "hermes", } } @@ -159,6 +161,7 @@ impl HookAgent { "codex" => Some(Self::Codex), "cursor" => Some(Self::Cursor), "kiro" => Some(Self::Kiro), + "hermes" => Some(Self::Hermes), _ => None, } } @@ -170,6 +173,7 @@ impl HookAgent { Self::Codex => ".codex_shell_sync_at", Self::Cursor => ".cursor_shell_sync_at", Self::Kiro => ".kiro_post_tool_sync_at", + Self::Hermes => ".hermes_terminal_receipt_at", } } } @@ -188,6 +192,20 @@ pub struct HookRouteMetadata { pub branch: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HookTerminalReceipt { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transcript_watermark: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DaemonHookEvent { pub agent: String, @@ -200,6 +218,8 @@ pub struct DaemonHookEvent { pub cwd: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub route: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receipt: Option, } impl DaemonHookEvent { @@ -217,6 +237,7 @@ impl DaemonHookEvent { command, cwd, route: None, + receipt: None, } } @@ -270,6 +291,23 @@ impl DaemonHookEvent { pub fn kiro_post_tool_use(rel_paths: Vec, cwd: Option) -> Self { Self::new(HookAgent::Kiro, "postToolUse", rel_paths, None, cwd) } + + pub fn hermes_terminal_receipt( + cwd: PathBuf, + route: HookRouteMetadata, + receipt: HookTerminalReceipt, + ) -> Self { + let mut event = Self::new( + HookAgent::Hermes, + "terminalReceipt", + Vec::new(), + None, + Some(cwd), + ); + event.route = Some(route); + event.receipt = Some(receipt); + event + } } /// Per-connection metadata sent before JSON-RPC traffic. @@ -1623,7 +1661,8 @@ struct DaemonEngine { /// Shared daemon state, partitioned by the client-scoped project server key. project_servers: Arc>>>, /// Background automation loops, partitioned with the same client/project identity as MCP state. - automation_schedulers: Arc>>>, + automation_schedulers: + Arc>>, /// Client versions whose skew was already logged. Proxy clients reconnect /// per request, so without this the mismatch would flood the daemon log. logged_client_version_skews: Arc>>, @@ -1641,6 +1680,12 @@ struct DaemonEngine { pr_autotrack_task: Arc>>>, } +#[cfg(unix)] +struct AutomationSchedulerHandle { + task: JoinHandle<()>, + wake: Arc, +} + #[cfg(unix)] #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct ProjectServerKey { @@ -1916,8 +1961,11 @@ impl DaemonEngine { let handshake = handshake.clone(); tokio::spawn(async move { engine - .ensure_automation_scheduler(key, project_path, handshake) + .ensure_automation_scheduler(key.clone(), project_path, handshake) .await; + if let Some(handle) = engine.automation_schedulers.lock().await.get(&key) { + handle.wake.notify_one(); + } }); }) } @@ -1932,16 +1980,23 @@ impl DaemonEngine { if schedulers.contains_key(&key) { return; } - let handle = tokio::spawn(async move { - Box::pin(run_automation_scheduler_loop(project_path, handshake)).await; + let wake = Arc::new(tokio::sync::Notify::new()); + let loop_wake = Arc::clone(&wake); + let task = tokio::spawn(async move { + Box::pin(run_automation_scheduler_loop( + project_path, + handshake, + loop_wake, + )) + .await; }); - schedulers.insert(key, handle); + schedulers.insert(key, AutomationSchedulerHandle { task, wake }); } async fn shutdown_background_tasks(&self) { let scheduler_handles: Vec> = { let mut schedulers = self.automation_schedulers.lock().await; - schedulers.drain().map(|(_, handle)| handle).collect() + schedulers.drain().map(|(_, handle)| handle.task).collect() }; for handle in scheduler_handles { handle.abort(); @@ -1979,7 +2034,11 @@ impl DaemonEngine { } #[cfg(unix)] -async fn run_automation_scheduler_loop(project_path: PathBuf, handshake: DaemonHandshake) { +async fn run_automation_scheduler_loop( + project_path: PathBuf, + handshake: DaemonHandshake, + wake: Arc, +) { loop { match Box::pin(automation_scheduler_has_work_for_project( &project_path, @@ -2035,6 +2094,16 @@ async fn run_automation_scheduler_loop(project_path: PathBuf, handshake: DaemonH ], ); } + if let Err(error) = Box::pin(run_host_receipt_review(&project_path, &handshake)).await { + log_daemon_event( + "host_receipt_review", + &[ + ("project", project_path.display().to_string()), + ("outcome", "error".to_string()), + ("error", error.to_string()), + ], + ); + } let tick_secs = Box::pin(automation_scheduler_tick_secs_for_project( &project_path, &handshake, @@ -2047,7 +2116,30 @@ async fn run_automation_scheduler_loop(project_path: PathBuf, handshake: DaemonH ("next_tick_secs", tick_secs.to_string()), ], ); - tokio::time::sleep(Duration::from_secs(tick_secs)).await; + tokio::select! { + () = tokio::time::sleep(Duration::from_secs(tick_secs)) => {} + () = wake.notified() => { + // Receipts arrive at tool cadence. Wait for a short quiet + // period and reset it for every later receipt, producing one + // review for the burst rather than one review per command. + loop { + tokio::select! { + () = tokio::time::sleep(Duration::from_secs(5)) => break, + () = wake.notified() => {} + } + } + if let Err(error) = Box::pin(run_host_receipt_review(&project_path, &handshake)).await { + log_daemon_event( + "host_receipt_review", + &[ + ("project", project_path.display().to_string()), + ("outcome", "error".to_string()), + ("error", error.to_string()), + ], + ); + } + } + } } } @@ -2370,6 +2462,108 @@ async fn run_automation_scheduler_tick( } } +#[cfg(unix)] +async fn run_host_receipt_review(project_path: &Path, handshake: &DaemonHandshake) -> Result<()> { + use crate::automation::backend::CodexAppServerBackend; + use crate::automation::run_ledger::AutomationTrigger; + use crate::automation::runner::{ + CombinedReviewAutomationOptions, CombinedReviewDispatch, SessionReflectorAutomationOptions, + SkillWriterAutomationOptions, run_combined_review_with_backend, + }; + + let cg = Box::pin(open_existing_project_with_options( + project_path, + handshake.open_options(), + )) + .await?; + let dashboard_root = cg.store_layout().dashboard_root.clone(); + let Some(ready) = crate::automation::host_receipts::oldest_ready(&dashboard_root).await? else { + return Ok(()); + }; + let pending = ready.pending; + if crate::automation::scheduler::load_scheduler_control(&dashboard_root) + .await? + .paused + { + return Ok(()); + } + let config = effective_automation_config_for_project(&cg, &handshake.client_identity).await?; + let session_id = pending + .route + .as_ref() + .and_then(|route| route.session_id.clone()); + let Some(session_db) = + crate::global_db::GlobalDb::open_read_only_at(&cg.store_layout().sessions_db_path).await + else { + return Ok(()); + }; + if session_db + .lcm_load_raw_message("hermes", &ready.transcript_watermark) + .await + .is_none() + { + // Never review a terminal receipt until the exact completed-turn + // watermark is durable in LCM. + return Ok(()); + } + let backend = CodexAppServerBackend::from_automation_config(&config); + let result = run_combined_review_with_backend( + &cg, + &config, + &backend, + CombinedReviewAutomationOptions { + run_id: Some(format!("host_receipt_{}", pending.generation)), + session_reflector: SessionReflectorAutomationOptions { + trigger: AutomationTrigger::HostReceipt, + provider: "hermes".to_string(), + session_id, + ..SessionReflectorAutomationOptions::default() + }, + skill_writer: SkillWriterAutomationOptions { + trigger: AutomationTrigger::HostReceipt, + provider: "hermes".to_string(), + ..SkillWriterAutomationOptions::default() + }, + trigger: AutomationTrigger::HostReceipt, + }, + ) + .await?; + match result { + CombinedReviewDispatch::Ran(run) => { + log_daemon_scheduler_record(project_path, &run.session_reflector.ledger_record); + log_daemon_scheduler_record(project_path, &run.skill_writer.ledger_record); + if run.session_reflector.ledger_record.status + == crate::automation::run_ledger::AutomationRunStatus::Succeeded + && run.skill_writer.ledger_record.status + == crate::automation::run_ledger::AutomationRunStatus::Succeeded + { + crate::automation::host_receipts::mark_consumed( + &dashboard_root, + &pending.session_key, + pending.generation, + ) + .await?; + } + } + CombinedReviewDispatch::RecordedFailure { run, error } => { + log_daemon_scheduler_record(project_path, &run.session_reflector.ledger_record); + log_daemon_scheduler_record(project_path, &run.skill_writer.ledger_record); + return Err(error); + } + CombinedReviewDispatch::NotCombined { reason } => { + log_daemon_event( + "host_receipt_review", + &[ + ("project", project_path.display().to_string()), + ("outcome", "deferred".to_string()), + ("reason", reason.to_string()), + ], + ); + } + } + Ok(()) +} + #[cfg(unix)] async fn effective_automation_config_for_project( cg: &crate::tracedecay::TraceDecay, @@ -2404,6 +2598,12 @@ fn automation_scheduler_configured(config: &crate::automation::config::Automatio { return false; } + if config.combine_due_tasks + && config.tasks.session_reflector.enabled + && config.tasks.skill_writer.enabled + { + return true; + } [ &config.tasks.memory_curator, &config.tasks.session_reflector, diff --git a/src/dashboard/automation_skills_api.rs b/src/dashboard/automation_skills_api.rs index f8bfc285a..80e0fa0fe 100644 --- a/src/dashboard/automation_skills_api.rs +++ b/src/dashboard/automation_skills_api.rs @@ -13,7 +13,7 @@ use crate::automation::managed_skills::{ ManagedSkill, ManagedSkillDraft, ManagedSkillProvenance, ManagedSkillSource, ManagedSkillState, ManagedSkillUpdate, ManagedSupportFile, SkillInstallTarget, approve_managed_skill, create_managed_skill_draft, discard_pending_managed_skill_update, list_managed_skills, - load_managed_skill, managed_skill_dir, managed_skill_root, save_managed_skill, + load_managed_skill, managed_skill_dir, managed_skill_root, set_managed_skill_pinned, set_managed_skill_state, stage_managed_skill_update, update_managed_skill, }; use crate::automation::skill_usage::{ @@ -106,15 +106,16 @@ pub(crate) async fn draft( let profile_root = profile_root_or_error()?; reject_existing_managed_skill(&profile_root, &request.id).await?; let pinned = request.pinned; - let mut skill = create_managed_skill_draft(&profile_root, request.into_draft()) + let skill = create_managed_skill_draft(&profile_root, request.into_draft()) .await .map_err(|err| bad_request_or_internal(&err))?; - if let Some(pinned) = pinned { - skill.set_pinned(pinned); - save_managed_skill(&profile_root, &skill) + let skill = if let Some(pinned) = pinned { + set_managed_skill_pinned(&profile_root, &skill.metadata.id, pinned) .await - .map_err(|err| internal_error(&err))?; - } + .map_err(|err| internal_error(&err))? + } else { + skill + }; skill_payload(&profile_root, skill).await } diff --git a/src/dashboard/memory_curate.rs b/src/dashboard/memory_curate.rs index 8fc8d8de7..f6b0439ac 100644 --- a/src/dashboard/memory_curate.rs +++ b/src/dashboard/memory_curate.rs @@ -24,7 +24,10 @@ use super::memory_service::{ }; use super::util::{qmarks, query_rows}; use super::{DashboardState, code_diagnostics_broker, storage_mode_label, token_count}; +use crate::db::Database; use crate::errors::{Result, TraceDecayError}; +use crate::memory::store::MemoryStore; +use crate::memory::types::MemoryGroomingOperation; use crate::tracedecay::TraceDecay; pub const CURATION_DEFAULT_MAX_CLUSTERS: usize = 12; @@ -56,7 +59,8 @@ contradictions - leave them with \"keep\".\n\n\ There is NO archive: delete and merge losers are removed permanently, \ so prefer \"keep\" whenever unsure.\n\n\ Return JSON of shape: {\"ops\": [ ... ]}. Each op MUST include: \ -cluster_id (string, from the input), op (one of merge, delete, keep), \ +cluster_id (string, from the input), op (one of merge, delete, keep, \ +normalize_tags, merge_entities, add_alias, link_facts, repair_vector), \ confidence (0.0-1.0), and reason (short string). Use op \"keep\" for \ reviewed clusters that need no change; do not omit keep reviews.\n\ Per-op required fields:\n\ @@ -64,6 +68,16 @@ Per-op required fields:\n\ \"merged_content\" (string) when the winner's text should be replaced \ by a consolidated fact.\n\ delete: {\"fact_id\": }\n\ + normalize_tags: {\"fact_id\": , \"tags\": [, ...], \ +\"evidence_fact_ids\": [, ...]}\n\ + merge_entities: {\"winner_entity_id\": , \"loser_entity_ids\": [, ...], \ +\"evidence_fact_ids\": [, ...]}\n\ + add_alias: {\"entity_id\": , \"alias\": , \ +\"evidence_fact_ids\": [, ...]}\n\ + link_facts: {\"source_fact_id\": , \"target_fact_id\": , \ +\"relation\": , \ +\"source\": , \"evidence_fact_ids\": [, ...]}\n\ + repair_vector: {\"fact_id\": , \"evidence_fact_ids\": [, ...]}\n\ Only reference fact ids that appear in the input clusters or in \ hygiene_candidates. Return ONLY the JSON object.\n\n\ Hygiene categories: the input may also carry \"hygiene_candidates\" — \ @@ -141,9 +155,62 @@ async fn cli_state(cg: &TraceDecay) -> DashboardState { } } +fn user_state( + memory_db: &Database, + memory_db_path: &std::path::Path, + profile_root: &std::path::Path, + dashboard_root: &std::path::Path, +) -> DashboardState { + let conn = memory_db.conn().clone(); + DashboardState { + project_id: None, + graph_conn: conn.clone(), + graph_db_path: memory_db_path.display().to_string(), + mem_conn: conn, + mem_db_path: memory_db_path.display().to_string(), + lcm_conn: None, + lcm_db_path: String::new(), + lcm_scope: "user".to_string(), + savings_db: None, + savings_db_path: String::new(), + project_root: profile_root.to_path_buf(), + storage_mode: "user".to_string(), + store_root: profile_root.to_path_buf(), + config_path: profile_root.join("config.json"), + dashboard_root: dashboard_root.to_path_buf(), + curation_activity: Arc::new(RwLock::new(Vec::new())), + token_counts: Arc::new(token_count::TokenCountCache::new()), + code_diagnostics: Arc::new(RwLock::new(code_diagnostics_broker( + profile_root.to_path_buf(), + crate::diagnostics::lsp::settings::CodeDiagnosticsSettings::default(), + ))), + code_diagnostics_backfill_started: Arc::new(AtomicBool::new(false)), + automation_scheduler_reconciler: None, + } +} + /// Runs the curate verb and returns the JSON report printed by the CLI. pub async fn run_memory_curate(cg: &TraceDecay, options: &MemoryCurateOptions) -> Result { let state = cli_state(cg).await; + run_memory_curate_with_state(&state, options).await +} + +/// Runs memory curation against the profile-level user memory store. +pub async fn run_user_memory_curate( + memory_db: &Database, + memory_db_path: &std::path::Path, + profile_root: &std::path::Path, + dashboard_root: &std::path::Path, + options: &MemoryCurateOptions, +) -> Result { + let state = user_state(memory_db, memory_db_path, profile_root, dashboard_root); + run_memory_curate_with_state(&state, options).await +} + +async fn run_memory_curate_with_state( + state: &DashboardState, + options: &MemoryCurateOptions, +) -> Result { let mut report = Map::new(); report.insert("mode".to_string(), json!("similarity_dedup")); report.insert("dry_run".to_string(), json!(!options.apply)); @@ -153,13 +220,13 @@ pub async fn run_memory_curate(cg: &TraceDecay, options: &MemoryCurateOptions) - // deletions beforehand would invalidate the very clusters the ops // reference (their fact ids would already be gone). if let Some(provided) = options.llm_ops.as_ref() { - let clusters = build_clusters(&state, options.max_clusters).await?; + let clusters = build_clusters(state, options.max_clusters).await?; // Evidence guard: cluster members plus the deterministic hygiene // candidates (recomputed against the same pre-apply state the ops // were planned on) are the only legal delete targets. let mut allowed_ids: BTreeSet = cluster_fact_ids(&clusters); let (_, pre_apply_hygiene_candidates, _, _) = - build_delete_plan(&state) + build_delete_plan(state) .await .map_err(|message| TraceDecayError::Config { message: format!("curation analysis failed: {message}"), @@ -179,16 +246,43 @@ pub async fn run_memory_curate(cg: &TraceDecay, options: &MemoryCurateOptions) - if options.apply { let mut results = Vec::new(); let mut applied = 0i64; - for op in &valid { - let (result, ok) = match op.get("op").and_then(Value::as_str) { - Some("delete") => apply_delete_op(&state, op).await, - Some("merge") => apply_merge_op(&state, op).await, - _ => (json!({ "status": "error", "error": "unknown op" }), false), - }; - if ok { - applied += 1; + if let Err(error) = prevalidate_destructive_ops(state, &valid).await { + results.extend(valid.iter().map(|op| { + json!({ + "status": "error", + "error": error.to_string(), + "op": op, + }) + })); + } else { + let grooming_ops = parse_grooming_ops(&valid)?; + if !grooming_ops.is_empty() { + let store = MemoryStore::new(&state.mem_conn); + let grooming_report = store + .apply_grooming_batch(&grooming_ops, options.min_confidence) + .await?; + applied += grooming_ops.len() as i64; + results.push(json!({ + "status": "applied", + "operations": grooming_ops.len(), + "grooming": grooming_report, + })); + } + for op in &valid { + let (result, ok) = match op.get("op").and_then(Value::as_str) { + Some("delete") => apply_delete_op(state, op).await, + Some("merge") => apply_merge_op(state, op).await, + Some( + "normalize_tags" | "merge_entities" | "add_alias" | "link_facts" + | "repair_vector", + ) => continue, + _ => (json!({ "status": "error", "error": "unknown op" }), false), + }; + if ok { + applied += 1; + } + results.push(result); } - results.push(result); } llm_report.insert("applied".to_string(), json!(applied)); llm_report.insert("results".to_string(), Value::Array(results)); @@ -209,7 +303,7 @@ pub async fn run_memory_curate(cg: &TraceDecay, options: &MemoryCurateOptions) - // are never auto-applied here — the external LLM (or a human) confirms // them and feeds delete/merge ops back through `--llm-ops`. let (actions, hygiene_candidates, counts, total) = - build_delete_plan(&state) + build_delete_plan(state) .await .map_err(|message| TraceDecayError::Config { message: format!("curation analysis failed: {message}"), @@ -229,7 +323,7 @@ pub async fn run_memory_curate(cg: &TraceDecay, options: &MemoryCurateOptions) - skipped += 1; continue; }; - match delete_fact(&state, fact_id).await { + match delete_fact(state, fact_id).await { Ok(true) => applied += 1, Ok(false) | Err(_) => skipped += 1, } @@ -241,8 +335,27 @@ pub async fn run_memory_curate(cg: &TraceDecay, options: &MemoryCurateOptions) - } report.insert("actions".to_string(), Value::Array(actions)); + if options.apply { + let repair = super::memory_api::repair_derived_memory(state) + .await + .map_err(|message| TraceDecayError::Config { + message: format!("memory derived-state repair failed: {message}"), + })?; + report.insert( + "derived_memory_repair".to_string(), + serde_json::to_value(repair).map_err(|e| TraceDecayError::Config { + message: format!("failed to serialize memory repair report: {e}"), + })?, + ); + } else { + report.insert( + "derived_memory_repair".to_string(), + json!({ "status": "not_run_read_only_preview" }), + ); + } + if options.llm && options.llm_ops.is_none() { - let clusters = build_clusters(&state, options.max_clusters).await?; + let clusters = build_clusters(state, options.max_clusters).await?; let mut allowed_ids: BTreeSet = cluster_fact_ids(&clusters); allowed_ids.extend(hygiene_candidate_fact_ids(&hygiene_candidates)); let has_hygiene = !hygiene_candidate_fact_ids(&hygiene_candidates).is_empty(); @@ -446,6 +559,13 @@ fn validate_llm_ops( allowed_ids: &BTreeSet, min_confidence: f64, ) -> (Vec, Vec) { + const GROOMING_OPS: [&str; 5] = [ + "normalize_tags", + "merge_entities", + "add_alias", + "link_facts", + "repair_vector", + ]; let mut valid = Vec::new(); let mut rejected = Vec::new(); for raw in raw_ops { @@ -461,7 +581,7 @@ fn validate_llm_ops( if op == "keep" { continue; } - if op != "merge" && op != "delete" { + if op != "merge" && op != "delete" && !GROOMING_OPS.contains(&op) { rejected.push(reject(raw, &format!("unknown op '{op}'"))); continue; } @@ -476,6 +596,74 @@ fn validate_llm_ops( rejected.push(reject(raw, &format!("confidence {confidence} below floor"))); continue; } + if GROOMING_OPS.contains(&op) { + let evidence_ids = op_obj + .get("evidence_fact_ids") + .and_then(Value::as_array) + .map(|ids| ids.iter().filter_map(Value::as_i64).collect::>()) + .unwrap_or_default(); + if evidence_ids.is_empty() || evidence_ids.iter().any(|id| !allowed_ids.contains(id)) { + rejected.push(reject( + raw, + "grooming evidence_fact_ids were empty or outside reviewed evidence", + )); + continue; + } + let valid_shape = match op { + "normalize_tags" => { + op_obj + .get("fact_id") + .and_then(Value::as_i64) + .is_some_and(|id| allowed_ids.contains(&id)) + && op_obj.get("tags").and_then(Value::as_array).is_some() + } + "merge_entities" => { + op_obj + .get("winner_entity_id") + .and_then(Value::as_i64) + .is_some() + && op_obj + .get("loser_entity_ids") + .and_then(Value::as_array) + .is_some_and(|ids| { + !ids.is_empty() && ids.iter().all(|id| id.as_i64().is_some()) + }) + } + "add_alias" => { + op_obj.get("entity_id").and_then(Value::as_i64).is_some() + && op_obj + .get("alias") + .and_then(Value::as_str) + .is_some_and(|alias| !alias.trim().is_empty()) + } + "link_facts" => { + let source_id = op_obj.get("source_fact_id").and_then(Value::as_i64); + let target_id = op_obj.get("target_fact_id").and_then(Value::as_i64); + source_id.is_some_and(|id| allowed_ids.contains(&id)) + && target_id.is_some_and(|id| allowed_ids.contains(&id)) + && source_id != target_id + && matches!( + op_obj.get("relation").and_then(Value::as_str), + Some("supports" | "contradicts" | "supersedes" | "derived_from") + ) + && op_obj + .get("source") + .and_then(Value::as_str) + .is_some_and(|source| !source.trim().is_empty()) + } + "repair_vector" => op_obj + .get("fact_id") + .and_then(Value::as_i64) + .is_some_and(|id| allowed_ids.contains(&id)), + _ => false, + }; + if !valid_shape { + rejected.push(reject(raw, "missing/invalid bounded grooming fields")); + continue; + } + valid.push(raw.clone()); + continue; + } if op == "delete" { let Some(fact_id) = op_obj.get("fact_id").and_then(Value::as_i64) else { rejected.push(reject(raw, "missing/invalid fact_id")); @@ -520,6 +708,137 @@ fn reject(raw: &Value, reason: &str) -> Value { Value::Object(out) } +fn parse_grooming_ops(valid: &[Value]) -> Result> { + valid + .iter() + .filter(|op| { + matches!( + op.get("op").and_then(Value::as_str), + Some( + "normalize_tags" + | "merge_entities" + | "add_alias" + | "link_facts" + | "repair_vector" + ) + ) + }) + .map(|op| { + serde_json::from_value(op.clone()).map_err(|e| TraceDecayError::Config { + message: format!("invalid grooming operation after validation: {e}"), + }) + }) + .collect() +} + +async fn prevalidate_destructive_ops(state: &DashboardState, ops: &[Value]) -> Result<()> { + let destructive_count = ops + .iter() + .filter(|op| { + matches!( + op.get("op").and_then(Value::as_str), + Some("delete" | "merge") + ) + }) + .count(); + let has_grooming = ops.iter().any(|op| { + matches!( + op.get("op").and_then(Value::as_str), + Some( + "normalize_tags" | "merge_entities" | "add_alias" | "link_facts" | "repair_vector" + ) + ) + }); + if destructive_count > 1 || (destructive_count == 1 && has_grooming) { + return Err(TraceDecayError::Config { + message: "curation batches may contain one destructive operation or an atomic grooming batch, not both".to_string(), + }); + } + let mut mutation_targets = BTreeSet::new(); + let mut merge_winners = BTreeSet::new(); + let mut required_facts = BTreeSet::new(); + for op in ops { + match op.get("op").and_then(Value::as_str) { + Some("delete") => { + let fact_id = op.get("fact_id").and_then(Value::as_i64).ok_or_else(|| { + TraceDecayError::Config { + message: "delete op lost fact_id after validation".to_string(), + } + })?; + if merge_winners.contains(&fact_id) || !mutation_targets.insert(fact_id) { + return Err(TraceDecayError::Config { + message: format!("fact {fact_id} is targeted by multiple destructive ops"), + }); + } + required_facts.insert(fact_id); + } + Some("merge") => { + let winner_id = op.get("winner_id").and_then(Value::as_i64).ok_or_else(|| { + TraceDecayError::Config { + message: "merge op lost winner_id after validation".to_string(), + } + })?; + if mutation_targets.contains(&winner_id) { + return Err(TraceDecayError::Config { + message: format!( + "fact {winner_id} has conflicting destructive batch roles" + ), + }); + } + merge_winners.insert(winner_id); + required_facts.insert(winner_id); + for loser_id in op + .get("loser_ids") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_i64) + { + if merge_winners.contains(&loser_id) + || !mutation_targets.insert(loser_id) + || loser_id == winner_id + { + return Err(TraceDecayError::Config { + message: format!( + "fact {loser_id} has conflicting destructive batch roles" + ), + }); + } + required_facts.insert(loser_id); + } + } + _ => {} + } + } + for fact_id in required_facts { + let mut rows = state + .mem_conn + .query( + "SELECT 1 FROM memory_facts WHERE fact_id = ?1 LIMIT 1", + libsql::params![fact_id], + ) + .await + .map_err(|e| TraceDecayError::Database { + message: e.to_string(), + operation: "prevalidate_destructive_ops".to_string(), + })?; + if rows + .next() + .await + .map_err(|e| TraceDecayError::Database { + message: e.to_string(), + operation: "prevalidate_destructive_ops".to_string(), + })? + .is_none() + { + return Err(TraceDecayError::Config { + message: format!("fact {fact_id} no longer exists; batch was not applied"), + }); + } + } + Ok(()) +} + fn uses_updated_at_as_truth_freshness(raw: &Value) -> bool { raw.get("reason") .and_then(Value::as_str) @@ -542,6 +861,77 @@ mod tests { ids.iter().copied().collect() } + #[tokio::test] + async fn preview_is_read_only_while_apply_repairs_derived_memory() { + let temp = tempfile::tempdir().unwrap(); + let memory_path = temp.path().join("user-memory.db"); + let (db, _) = Database::initialize(&memory_path).await.unwrap(); + db.conn() + .execute( + "INSERT INTO memory_facts + (fact_id, content, category, trust_score, created_at, updated_at, source) + VALUES (1, 'Keep diagnostic previews read only', 'decision', 0.9, 1, 1, 'test')", + (), + ) + .await + .unwrap(); + let options = MemoryCurateOptions { + llm_ops: Some(json!({ "ops": [] })), + ..MemoryCurateOptions::default() + }; + + let preview = run_user_memory_curate( + &db, + &memory_path, + temp.path(), + &temp.path().join("user-automation"), + &options, + ) + .await + .unwrap(); + + assert_eq!( + preview["derived_memory_repair"]["status"], + json!("not_run_read_only_preview") + ); + assert_eq!(missing_vector_count(&db).await, 1); + + let applied = run_user_memory_curate( + &db, + &memory_path, + temp.path(), + &temp.path().join("user-automation"), + &MemoryCurateOptions { + apply: true, + ..options + }, + ) + .await + .unwrap(); + + assert_eq!( + applied["derived_memory_repair"]["missing_vectors_repaired"], + json!(1) + ); + assert_eq!(missing_vector_count(&db).await, 0); + } + + async fn missing_vector_count(db: &Database) -> i64 { + db.conn() + .query( + "SELECT COUNT(*) FROM memory_facts WHERE hrr_vector IS NULL", + (), + ) + .await + .unwrap() + .next() + .await + .unwrap() + .unwrap() + .get(0) + .unwrap() + } + #[test] fn validate_keeps_are_silently_dropped() { let ops = vec![json!({ "op": "keep", "cluster_id": "cluster-0000", "confidence": 0.9 })]; @@ -644,4 +1034,41 @@ mod tests { .contains("updated_at is maintenance metadata") ); } + + #[test] + fn validate_accepts_only_bounded_grooming_with_reviewed_evidence() { + let ops = vec![ + json!({ + "op": "link_facts", + "source_fact_id": 1, + "target_fact_id": 2, + "relation": "supports", + "source": "memory_curator", + "evidence_fact_ids": [1, 2], + "confidence": 0.9 + }), + json!({ + "op": "link_facts", + "source_fact_id": 1, + "target_fact_id": 1, + "relation": "invented_relation", + "source": "memory_curator", + "evidence_fact_ids": [1], + "confidence": 0.9 + }), + json!({ + "op": "add_alias", + "entity_id": 4, + "alias": "safe alias", + "evidence_fact_ids": [99], + "confidence": 0.9 + }), + ]; + + let (valid, rejected) = validate_llm_ops(&ops, &allowed(&[1, 2]), 0.5); + + assert_eq!(valid.len(), 1); + assert_eq!(valid[0]["relation"], "supports"); + assert_eq!(rejected.len(), 2); + } } diff --git a/src/db/connection.rs b/src/db/connection.rs index b03835f4b..820d9ff26 100644 --- a/src/db/connection.rs +++ b/src/db/connection.rs @@ -266,6 +266,31 @@ impl Database { Ok(()) } + /// Writes a transactionally consistent copy of this database. + /// + /// `VACUUM INTO` reads from one `SQLite` snapshot, so concurrent WAL + /// checkpoints cannot leave the destination with a partially copied + /// B-tree. The destination must not already exist. + pub async fn snapshot_to(&self, destination: &Path) -> Result<()> { + let destination = destination + .to_str() + .ok_or_else(|| TraceDecayError::Database { + message: format!( + "snapshot destination is not valid UTF-8: '{}'", + destination.display() + ), + operation: "snapshot".to_string(), + })?; + self.conn + .execute("VACUUM INTO ?1", libsql::params![destination]) + .await + .map_err(|e| TraceDecayError::Database { + message: format!("failed to create consistent database snapshot: {e}"), + operation: "snapshot".to_string(), + })?; + Ok(()) + } + /// Runs VACUUM and ANALYZE to reclaim space and update query planner statistics. /// Returns the on-disk size of the database file in bytes. pub async fn size(&self) -> Result { diff --git a/src/db/migrations.rs b/src/db/migrations.rs index 3b45a5298..e99b27383 100644 --- a/src/db/migrations.rs +++ b/src/db/migrations.rs @@ -16,7 +16,7 @@ use crate::memory::store::MemoryStore; /// The highest migration version defined in this file. Bump this and add a /// new entry to `run_migration` whenever the schema changes. -const LATEST_VERSION: u32 = 17; +const LATEST_VERSION: u32 = 18; /// Reads the current schema version from `PRAGMA user_version`. async fn get_version(conn: &Connection) -> Result { @@ -376,6 +376,7 @@ async fn run_migration(conn: &Connection, version: u32) -> Result<()> { 15 => migrate_v15(conn).await, 16 => migrate_v16(conn).await, 17 => migrate_v17(conn).await, + 18 => migrate_v18(conn).await, _ => Err(TraceDecayError::Database { message: format!("unknown migration version: {version}"), operation: "run_migration".to_string(), @@ -630,6 +631,85 @@ async fn migrate_v17(_conn: &Connection) -> Result<()> { Ok(()) } +/// v18: bounded, project-local semantic relationships between memory facts. +/// +/// Relations deliberately use a closed vocabulary. Entity timestamps support +/// optimistic grooming without creating a global entity identity system. +async fn migrate_v18(conn: &Connection) -> Result<()> { + let mut has_updated_at = false; + let mut rows = conn + .query("PRAGMA table_info(memory_entities)", ()) + .await + .map_err(|e| TraceDecayError::Database { + message: format!("v18: failed to read memory_entities columns: {e}"), + operation: "migrate_v18".to_string(), + })?; + while let Some(row) = rows.next().await.map_err(|e| TraceDecayError::Database { + message: format!("v18: failed to iterate memory_entities columns: {e}"), + operation: "migrate_v18".to_string(), + })? { + let name: String = row.get(1).map_err(|e| TraceDecayError::Database { + message: format!("v18: failed to read memory_entities column name: {e}"), + operation: "migrate_v18".to_string(), + })?; + has_updated_at |= name == "updated_at"; + } + if !has_updated_at { + conn.execute( + "ALTER TABLE memory_entities ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0", + (), + ) + .await + .map_err(|e| TraceDecayError::Database { + message: format!("v18: failed to add memory_entities.updated_at: {e}"), + operation: "migrate_v18".to_string(), + })?; + conn.execute( + "UPDATE memory_entities SET updated_at = created_at WHERE updated_at = 0", + (), + ) + .await + .map_err(|e| TraceDecayError::Database { + message: format!("v18: failed to backfill memory_entities.updated_at: {e}"), + operation: "migrate_v18".to_string(), + })?; + } + create_memory_fact_relations_schema(conn, "migrate_v18").await +} + +async fn create_memory_fact_relations_schema(conn: &Connection, operation: &str) -> Result<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS memory_fact_relations ( + source_fact_id INTEGER NOT NULL, + target_fact_id INTEGER NOT NULL, + relation TEXT NOT NULL CHECK ( + relation IN ('supports', 'contradicts', 'supersedes', 'derived_from') + ), + confidence REAL NOT NULL CHECK (confidence >= 0.0 AND confidence <= 1.0), + source TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (source_fact_id, target_fact_id, relation), + CHECK (source_fact_id != target_fact_id), + FOREIGN KEY (source_fact_id) REFERENCES memory_facts(fact_id) ON DELETE CASCADE, + FOREIGN KEY (target_fact_id) REFERENCES memory_facts(fact_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_memory_fact_relations_source + ON memory_fact_relations(source_fact_id); + CREATE INDEX IF NOT EXISTS idx_memory_fact_relations_target + ON memory_fact_relations(target_fact_id); + CREATE INDEX IF NOT EXISTS idx_memory_fact_relations_kind + ON memory_fact_relations(relation);", + ) + .await + .map_err(|e| TraceDecayError::Database { + message: format!("{operation}: failed to create memory fact relations: {e}"), + operation: operation.to_string(), + })?; + Ok(()) +} + /// Freshness-validated cache of `tracedecay_redundancy` duplicate pairs. Shared /// verbatim by the fresh-schema path (`create_schema`) and the v16 migration so /// the two cannot drift. See [`migrate_v16`] for the column contract. @@ -1305,7 +1385,8 @@ async fn create_holographic_memory_schema(conn: &Connection, operation: &str) -> normalized_name TEXT NOT NULL UNIQUE, entity_type TEXT NOT NULL DEFAULT 'unknown', aliases TEXT NOT NULL DEFAULT '[]', - created_at INTEGER NOT NULL DEFAULT 0 + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS memory_fact_entities ( @@ -1401,6 +1482,8 @@ async fn create_holographic_memory_schema(conn: &Connection, operation: &str) -> operation: operation.to_string(), })?; + create_memory_fact_relations_schema(conn, operation).await?; + Ok(()) } diff --git a/src/hook_cmd.rs b/src/hook_cmd.rs index 4135e4f19..89c74950f 100644 --- a/src/hook_cmd.rs +++ b/src/hook_cmd.rs @@ -54,12 +54,12 @@ fn drain_hook_input(mode: HookInput, input: &mut impl std::io::Read) -> std::io: pub(crate) fn hook_input(command: &Commands) -> Option { match command { - Commands::HookPreToolUse | Commands::HookPromptSubmit | Commands::HookStop => { - Some(HookInput::NoInput) - } + Commands::HookPreToolUse => Some(HookInput::NoInput), Commands::HookClaudeSessionStart | Commands::HookClaudePostToolUse | Commands::HookClaudeSubagentStart + | Commands::HookPromptSubmit + | Commands::HookStop | Commands::HookKiroPreToolUse | Commands::HookKiroPromptSubmit | Commands::HookKiroPostToolUse @@ -77,7 +77,9 @@ pub(crate) fn hook_input(command: &Commands) -> Option { | Commands::HookCodexUserPromptSubmit | Commands::HookCodexSubagentStart | Commands::HookCodexPostToolUse - | Commands::HookCodexPostCompact => Some(HookInput::Stdin), + | Commands::HookCodexPostCompact + | Commands::HookHermesTerminalReceipt + | Commands::HookUserSessionReview => Some(HookInput::Stdin), _ => None, } } @@ -159,6 +161,12 @@ pub(crate) async fn handle_hook_command(command: Commands) -> tracedecay::errors Commands::HookCodexPostCompact => { exit_if_nonzero(tracedecay::hooks::hook_codex_post_compact().await); } + Commands::HookHermesTerminalReceipt => { + exit_if_nonzero(tracedecay::hooks::hook_hermes_terminal_receipt().await); + } + Commands::HookUserSessionReview => { + exit_if_nonzero(tracedecay::hooks::hook_user_session_review().await); + } _ => unreachable!("non-hook command passed to hook dispatcher"), } Ok(()) @@ -185,8 +193,8 @@ mod tests { fn hook_commands() -> Vec<(Commands, HookInput)> { vec![ (Commands::HookPreToolUse, HookInput::NoInput), - (Commands::HookPromptSubmit, HookInput::NoInput), - (Commands::HookStop, HookInput::NoInput), + (Commands::HookPromptSubmit, HookInput::Stdin), + (Commands::HookStop, HookInput::Stdin), (Commands::HookClaudeSessionStart, HookInput::Stdin), (Commands::HookClaudePostToolUse, HookInput::Stdin), (Commands::HookClaudeSubagentStart, HookInput::Stdin), @@ -208,26 +216,28 @@ mod tests { (Commands::HookCodexSubagentStart, HookInput::Stdin), (Commands::HookCodexPostToolUse, HookInput::Stdin), (Commands::HookCodexPostCompact, HookInput::Stdin), + (Commands::HookHermesTerminalReceipt, HookInput::Stdin), + (Commands::HookUserSessionReview, HookInput::Stdin), ] } #[test] fn all_hook_commands_have_explicit_input_semantics() { let hooks = hook_commands(); - assert_eq!(hooks.len(), 24); + assert_eq!(hooks.len(), 26); assert_eq!( hooks .iter() .filter(|(_, input)| *input == HookInput::NoInput) .count(), - 3 + 1 ); assert_eq!( hooks .iter() .filter(|(_, input)| *input == HookInput::Stdin) .count(), - 21 + 25 ); for (command, expected) in hooks { assert_eq!(hook_input(&command), Some(expected)); diff --git a/src/hooks/claude.rs b/src/hooks/claude.rs index b0aec184c..8ce425b9b 100644 --- a/src/hooks/claude.rs +++ b/src/hooks/claude.rs @@ -5,13 +5,14 @@ use serde_json::Value; use super::codex::{codex_additional_context_json, codex_project_root_from_parsed_event}; +use super::memory_inject; use super::post_tool_use::{ CLAUDE_POST_TOOL_USE_SHELL_TOOLS, CLAUDE_POST_TOOL_USE_SPEC, captured_tool_output, is_claude_edit_tool, is_claude_hint_tool, is_post_tool_use_failure_event, notify_post_tool_use, tool_input_command_str, tool_input_edit_text, tool_input_file_path_str, trusted_tool_failure, }; use super::steering::{ - append_context_recovery_hint, append_tracedecay_bootstrap_context, + append_context_block, append_context_recovery_hint, append_tracedecay_bootstrap_context, cursor_index_signals_for_root, index_status_line, session_start_from_compaction, }; use super::tool_hints::{HintAgent, ToolHint, ToolHintInput, decide_hint, is_harness_memory_path}; @@ -151,6 +152,19 @@ pub async fn hook_claude_session_start() -> i32 { let _hook_telemetry = record_hook_invoked(root.as_deref(), HintAgent::Claude, "SessionStart", &event); let mut context = claude_session_context_for_event(&event).await; + let session_id = event_session_id(&parsed); + if root.is_none() && ingest_user_claude_session(session_id.clone()).await { + super::schedule_user_session_review("claude", session_id.as_deref()); + } + let digest = match root.as_deref() { + Some(root) => { + memory_inject::combined_session_memory_digest(root, session_id.as_deref()).await + } + None => memory_inject::user_session_memory_digest(session_id.as_deref()).await, + }; + if let Some(digest) = digest { + append_context_block(&mut context, &digest); + } // Fire-and-forget: nudge the daemon to refresh the index (and, when this // session runs in a harness-created linked worktree, auto-track its branch // store) before we print the staleness hint. `notify_hook_event` is @@ -236,6 +250,7 @@ fn session_start_hook_event(cwd: Option) -> crate::daemon::D command: None, cwd, route: None, + receipt: None, } } @@ -368,16 +383,69 @@ fn decide_post_tool_use_hint(parsed: &Value) -> Option { }) } -/// `UserPromptSubmit` hook handler: resets the per-session local counter. +/// `UserPromptSubmit` hook handler: resets the project counter and injects +/// scope-correct memory recall. pub async fn hook_prompt_submit() { - let project_path = crate::config::resolve_path(None); - if let Ok(cg) = crate::tracedecay::TraceDecay::open(&project_path).await { + let event = match super::read_stdin_to_string() { + Ok(event) => event, + Err(error) => { + eprintln!("tracedecay hook: failed to read stdin: {error}"); + return; + } + }; + let parsed = serde_json::from_str::(&event).unwrap_or(Value::Null); + let root = claude_session_project_root(&parsed).await; + let _hook_telemetry = record_hook_invoked( + root.as_deref(), + HintAgent::Claude, + "UserPromptSubmit", + &event, + ); + let session_id = event_session_id(&parsed); + if root.is_none() && ingest_user_claude_session(session_id.clone()).await { + super::schedule_user_session_review("claude", session_id.as_deref()); + } + if let Some(root) = root.as_deref() + && let Ok(cg) = crate::tracedecay::TraceDecay::open(root).await + { let _ = cg.reset_local_counter().await; } + let recall = prompt_like_text(&parsed); + let recall = match (root.as_deref(), recall.as_deref()) { + (Some(root), Some(prompt)) => { + Box::pin(memory_inject::combined_prompt_memory_recall( + root, + session_id.as_deref(), + prompt, + )) + .await + } + (None, Some(prompt)) => { + memory_inject::user_prompt_memory_recall(session_id.as_deref(), prompt).await + } + (_, None) => None, + }; + if let Some(recall) = recall { + println!( + "{}", + codex_additional_context_json("UserPromptSubmit", &recall) + ); + } else { + println!("{}", serde_json::json!({})); + } } /// `Stop` hook handler: ingests new session data and prints a cost receipt. pub async fn hook_stop() { + let event = super::read_stdin_to_string().unwrap_or_default(); + let parsed = serde_json::from_str::(&event).unwrap_or(Value::Null); + let root = claude_session_project_root(&parsed).await; + let session_id = event_session_id(&parsed); + let _hook_telemetry = record_hook_invoked(root.as_deref(), HintAgent::Claude, "Stop", &event); + if root.is_none() && ingest_user_claude_session(session_id.clone()).await { + super::schedule_user_session_review("claude", session_id.as_deref()); + } + let Some(gdb) = crate::global_db::GlobalDb::open().await else { return; }; @@ -410,6 +478,27 @@ pub async fn hook_stop() { } } +/// Incrementally ingests one live projectless Claude session into the profile +/// session store. `false` means no new transcript evidence was written. +pub async fn ingest_user_claude_session(session_id: Option) -> bool { + if session_id.is_none() { + return false; + } + let Ok(profile_root) = crate::storage::default_profile_root() else { + return false; + }; + let Some(db) = crate::sessions::open_user_session_db(&profile_root).await else { + return false; + }; + let Some(registered_roots) = crate::sessions::try_registered_project_roots().await else { + return false; + }; + crate::sessions::claude::ingest_user_sessions(&db, &profile_root, session_id, registered_roots) + .await + .messages_upserted + > 0 +} + #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { diff --git a/src/hooks/codex.rs b/src/hooks/codex.rs index 3fa359973..17a25c28e 100644 --- a/src/hooks/codex.rs +++ b/src/hooks/codex.rs @@ -53,16 +53,21 @@ pub async fn hook_codex_session_start() -> i32 { let _hook_telemetry = record_hook_invoked(root.as_deref(), HintAgent::Codex, "SessionStart", &event); let (mut context, _) = codex_session_context_for_event(&event).await; - if let Some(root) = root.as_deref() { - let session_id = serde_json::from_str::(&event) - .ok() - .as_ref() - .and_then(event_session_id); - if let Some(digest) = - memory_inject::session_memory_digest(root, session_id.as_deref()).await - { - append_context_block(&mut context, &digest); + let session_id = serde_json::from_str::(&event) + .ok() + .as_ref() + .and_then(event_session_id); + if root.is_none() && ingest_user_codex_session(session_id.clone()).await { + super::schedule_user_session_review("codex", session_id.as_deref()); + } + let digest = match root.as_deref() { + Some(root) => { + memory_inject::combined_session_memory_digest(root, session_id.as_deref()).await } + None => memory_inject::user_session_memory_digest(session_id.as_deref()).await, + }; + if let Some(digest) = digest { + append_context_block(&mut context, &digest); } if session_start_from_compaction(&event) { append_context_recovery_hint(&mut context); @@ -87,7 +92,14 @@ pub async fn hook_codex_user_prompt_submit() -> i32 { &event, ); reset_counter_for_codex_event(&event).await; - let context = codex_user_prompt_submit_context_for_event(&event).await; + let session_id = serde_json::from_str::(&event) + .ok() + .as_ref() + .and_then(event_session_id); + if root.is_none() && ingest_user_codex_session(session_id.clone()).await { + super::schedule_user_session_review("codex", session_id.as_deref()); + } + let context = Box::pin(codex_user_prompt_submit_context_for_event(&event)).await; println!( "{}", codex_additional_context_json("UserPromptSubmit", &context) @@ -101,19 +113,28 @@ pub async fn codex_user_prompt_submit_context_for_event(event: &str) -> String { if let Some(hint) = codex_prompt_hint(event) { append_tool_hint(&mut context, &hint); } - if let Some(recall) = codex_prompt_memory_recall(event).await { - append_context_block(&mut context, &recall); - } + } + if let Some(recall) = Box::pin(codex_prompt_memory_recall(event)).await { + append_context_block(&mut context, &recall); } context } async fn codex_prompt_memory_recall(event_json: &str) -> Option { let parsed = serde_json::from_str::(event_json).ok()?; - let root = codex_project_root_from_parsed_event_with_identity(&parsed).await?; let prompt = prompt_like_text(&parsed)?; let session_id = event_session_id(&parsed); - memory_inject::prompt_memory_recall(&root, session_id.as_deref(), &prompt).await + match codex_project_root_from_parsed_event_with_identity(&parsed).await { + Some(root) => { + Box::pin(memory_inject::combined_prompt_memory_recall( + &root, + session_id.as_deref(), + &prompt, + )) + .await + } + None => memory_inject::user_prompt_memory_recall(session_id.as_deref(), &prompt).await, + } } /// Builds Codex session/prompt context. @@ -146,8 +167,8 @@ pub async fn hook_codex_subagent_start() -> i32 { let count = record_codex_subagent_start(&event).await; let output = evaluate_codex_subagent_start(&event); let digest = match root.as_deref() { - Some(root) => memory_inject::session_memory_digest(root, None).await, - None => None, + Some(root) => memory_inject::combined_session_memory_digest(root, None).await, + None => memory_inject::user_session_memory_digest(None).await, }; let output = merge_codex_subagent_output(output, digest); eprintln!( @@ -591,8 +612,18 @@ fn codex_apply_patch_added_text(command: &str) -> Option { async fn codex_post_compact(event_json: &str) { let work = async { - let Some(project_root) = codex_project_root_from_event_with_identity(event_json).await - else { + let project_root = codex_project_root_from_event_with_identity(event_json).await; + if project_root.is_none() { + let session_id = serde_json::from_str::(event_json) + .ok() + .as_ref() + .and_then(event_session_id); + if ingest_user_codex_session(session_id.clone()).await { + super::schedule_user_session_review("codex", session_id.as_deref()); + } + return; + } + let Some(project_root) = project_root else { return; }; if !crate::tracedecay::TraceDecay::has_initialized_store(&project_root).await { @@ -642,6 +673,16 @@ async fn codex_post_compact(event_json: &str) { let _ = tokio::time::timeout(CODEX_POST_COMPACT_BUDGET, work).await; } +async fn ingest_user_codex_session(session_id: Option) -> bool { + if session_id.is_none() { + return false; + } + crate::sessions::ingest_user_codex_sessions(session_id) + .await + .messages_upserted + > 0 +} + async fn reset_counter_for_codex_event(event_json: &str) { let Some(project_root) = codex_project_root_from_event_with_identity(event_json).await else { return; diff --git a/src/hooks/cursor.rs b/src/hooks/cursor.rs index c93807005..c94614c1e 100644 --- a/src/hooks/cursor.rs +++ b/src/hooks/cursor.rs @@ -115,7 +115,7 @@ pub async fn hook_cursor_before_submit_prompt() -> i32 { CURSOR_HOT_INGEST_BUDGET, ) .await; - let context = cursor_before_submit_prompt_context(&event).await; + let context = Box::pin(cursor_before_submit_prompt_context(&event)).await; println!("{}", cursor_before_submit_prompt_json(context.as_deref())); 0 } @@ -141,7 +141,7 @@ pub(super) async fn cursor_before_submit_prompt_context(event_json: &str) -> Opt if let Some(hint) = cursor_prompt_hint(event_json) { append_tool_hint(&mut context, &hint); } - if let Some(recall) = cursor_prompt_memory_recall(event_json).await { + if let Some(recall) = Box::pin(cursor_prompt_memory_recall(event_json)).await { append_context_block(&mut context, &recall); } (!context.trim().is_empty()).then_some(context) @@ -181,10 +181,19 @@ fn cursor_prompt_hint(event_json: &str) -> Option { async fn cursor_prompt_memory_recall(event_json: &str) -> Option { let parsed = serde_json::from_str::(event_json).ok()?; - let root = cursor_project_root_from_parsed_event_with_identity(&parsed).await?; let prompt = prompt_like_text(&parsed)?; let session_id = event_session_id(&parsed); - memory_inject::prompt_memory_recall(&root, session_id.as_deref(), &prompt).await + match cursor_project_root_from_parsed_event_with_identity(&parsed).await { + Some(root) => { + Box::pin(memory_inject::combined_prompt_memory_recall( + &root, + session_id.as_deref(), + &prompt, + )) + .await + } + None => memory_inject::user_prompt_memory_recall(session_id.as_deref(), &prompt).await, + } } /// Cursor `sessionEnd` hook handler (fire-and-forget). @@ -199,12 +208,16 @@ pub async fn hook_cursor_session_end() -> i32 { let root = cursor_project_root_from_event_with_identity(&event).await; let _hook_telemetry = record_hook_invoked(root.as_deref(), HintAgent::Cursor, "sessionEnd", &event); - ingest_cursor_transcript_for_event( + let outcome = ingest_cursor_transcript_for_event_inner( &event, Some(CURSOR_CATCH_UP_INGEST_MAX_BYTES), CURSOR_STOP_INGEST_BUDGET, ) .await; + if outcome.user_scope && outcome.messages_upserted > 0 { + let session_id = event_session_id_from_json(&event); + super::schedule_user_session_review("cursor", session_id.as_deref()); + } println!("{}", serde_json::json!({})); 0 } @@ -219,12 +232,16 @@ pub async fn hook_cursor_stop() -> i32 { let event = read_hook_event!(); let root = cursor_project_root_from_event_with_identity(&event).await; let _hook_telemetry = record_hook_invoked(root.as_deref(), HintAgent::Cursor, "stop", &event); - ingest_cursor_transcript_for_event( + let outcome = ingest_cursor_transcript_for_event_inner( &event, Some(CURSOR_CATCH_UP_INGEST_MAX_BYTES), CURSOR_STOP_INGEST_BUDGET, ) .await; + if outcome.user_scope && outcome.messages_upserted > 0 { + let session_id = event_session_id_from_json(&event); + super::schedule_user_session_review("cursor", session_id.as_deref()); + } println!("{}", serde_json::json!({})); 0 } @@ -300,17 +317,23 @@ pub async fn hook_cursor_session_start() -> i32 { ) .await; let mut context = cursor_session_context_for_root(root.as_deref()).await; - if let Some(root) = root.as_deref() { - let session_id = serde_json::from_str::(&event) - .ok() - .as_ref() - .and_then(event_session_id); - if let Some(digest) = - memory_inject::session_memory_digest(root, session_id.as_deref()).await - { - append_context_block(&mut context, &digest); + let session_id = serde_json::from_str::(&event) + .ok() + .as_ref() + .and_then(event_session_id); + let digest = match root.as_deref() { + Some(root) => { + memory_inject::combined_session_memory_digest(root, session_id.as_deref()).await } + None => memory_inject::user_session_memory_digest(session_id.as_deref()).await, + }; + if let Some(digest) = digest { + append_context_block(&mut context, &digest); + } + if let Some(root) = root.as_deref() { memory_inject::regenerate_cursor_memory_rule(root).await; + } else { + memory_inject::regenerate_cursor_user_memory_rule().await; } if session_start_from_compaction(&event) { append_context_recovery_hint(&mut context); @@ -758,36 +781,88 @@ pub(super) async fn ingest_cursor_transcript_for_event( max_new_bytes: Option, budget: Duration, ) -> bool { + ingest_cursor_transcript_for_event_inner(event_json, max_new_bytes, budget) + .await + .completed +} + +#[derive(Default)] +struct CursorIngestOutcome { + completed: bool, + user_scope: bool, + messages_upserted: u64, +} + +async fn ingest_cursor_transcript_for_event_inner( + event_json: &str, + max_new_bytes: Option, + budget: Duration, +) -> CursorIngestOutcome { let work = async { let Ok(parsed) = serde_json::from_str::(event_json) else { - return false; + return CursorIngestOutcome::default(); }; - let Some(project_root) = cursor_project_root_from_parsed_event_with_identity(&parsed).await - else { - return false; + let project_root = cursor_project_root_from_parsed_event_with_identity(&parsed).await; + if project_root.is_none() { + let Ok(profile_root) = crate::storage::default_profile_root() else { + return CursorIngestOutcome::default(); + }; + let Some(db) = crate::sessions::open_user_session_db(&profile_root).await else { + return CursorIngestOutcome::default(); + }; + let Some(registered_roots) = crate::sessions::try_registered_project_roots().await + else { + return CursorIngestOutcome::default(); + }; + let stats = crate::sessions::cursor::ingest_cursor_user_transcript_event_capped_with_registered_roots( + event_json, + &db, + max_new_bytes, + ®istered_roots, + ) + .await; + return CursorIngestOutcome { + completed: true, + user_scope: true, + messages_upserted: stats.messages_upserted, + }; + } + let Some(project_root) = project_root else { + return CursorIngestOutcome::default(); }; if let Some(cwd_root) = cursor_event_cwd(&parsed) .as_deref() .and_then(crate::config::discover_project_root) { if !paths_same(&cwd_root, &project_root) { - return false; + return CursorIngestOutcome::default(); } } let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { - return false; + return CursorIngestOutcome::default(); }; - let _ = crate::sessions::cursor::ingest_cursor_transcript_event_capped( + let stats = crate::sessions::cursor::ingest_cursor_transcript_event_capped( event_json, &db, max_new_bytes, ) .await; - true + CursorIngestOutcome { + completed: true, + user_scope: false, + messages_upserted: stats.messages_upserted, + } }; // Short-lived CLI hook processes exit immediately, so the ingest must run // inline (not on a detached task); the timeout keeps it inside budget. - tokio::time::timeout(budget, work).await.unwrap_or(false) + tokio::time::timeout(budget, work).await.unwrap_or_default() +} + +fn event_session_id_from_json(event_json: &str) -> Option { + serde_json::from_str::(event_json) + .ok() + .as_ref() + .and_then(event_session_id) } fn cursor_tool_hint_input(parsed: &Value) -> ToolHintInput { diff --git a/src/hooks/kiro.rs b/src/hooks/kiro.rs index 14981f2d6..6a0a34daa 100644 --- a/src/hooks/kiro.rs +++ b/src/hooks/kiro.rs @@ -10,6 +10,7 @@ use serde_json::Value; use super::claude::is_code_research_prompt; use super::codex::codex_project_root_from_event; +use super::memory_inject; use super::tool_hints::{HintAgent, ToolHintInput, decide_hint}; use super::{ event_cwd, event_cwd_from_parsed, event_session_id, hook_route_metadata_from_event, @@ -130,19 +131,29 @@ fn collect_strings<'a>(value: &'a Value, out: &mut Vec<&'a str>) { /// Kiro `userPromptSubmit` hook handler. /// -/// Resets the per-turn counter and runs bounded Kiro transcript catch-up. +/// Resets the per-turn counter, catches up transcripts, and injects bounded +/// user/project memory relevant to the submitted prompt. pub async fn hook_kiro_prompt_submit() -> i32 { let event = read_hook_event!(); let root = codex_project_root_from_event(&event); let _hook_telemetry = record_hook_invoked(root.as_deref(), HintAgent::Kiro, "userPromptSubmit", &event); reset_counter_for_kiro_event(&event).await; - ingest_kiro_transcript_for_event( + let ingest = ingest_kiro_transcript_for_event( &event, Some(KIRO_HOT_INGEST_MAX_BYTES), KIRO_HOT_INGEST_BUDGET, ) .await; + if ingest.user_scope && ingest.messages_upserted > 0 { + // User-scope catch-up can ingest several changed Kiro sessions in one + // bounded sweep, so let the reflector select all recent Kiro evidence + // instead of falsely attributing the batch to the prompt's session id. + super::schedule_user_session_review("kiro", None); + } + if let Some(recall) = Box::pin(kiro_prompt_memory_recall(&event)).await { + println!("{recall}"); + } 0 } @@ -170,22 +181,71 @@ async fn reset_counter_for_kiro_event(event_json: &str) { /// Incrementally ingests Kiro IDE transcripts for the workspace referenced by /// `event_json`. Always fails open. +#[derive(Default)] +struct KiroIngestOutcome { + user_scope: bool, + messages_upserted: u64, +} + async fn ingest_kiro_transcript_for_event( event_json: &str, max_new_bytes: Option, budget: std::time::Duration, -) { +) -> KiroIngestOutcome { let work = async { - let Some(project_root) = kiro_project_root(event_json) else { - return; + if let Some(project_root) = kiro_project_root(event_json) { + let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await + else { + return KiroIngestOutcome::default(); + }; + let stats = + crate::sessions::kiro::ingest_kiro_for_project(&db, &project_root, max_new_bytes) + .await; + return KiroIngestOutcome { + messages_upserted: stats.messages_upserted, + ..KiroIngestOutcome::default() + }; + } + + let Ok(profile_root) = crate::storage::default_profile_root() else { + return KiroIngestOutcome::default(); + }; + let Some(db) = crate::sessions::open_user_session_db(&profile_root).await else { + return KiroIngestOutcome::default(); }; - let Some(db) = crate::sessions::cursor::open_project_session_db(&project_root).await else { - return; + let Some(source) = crate::sessions::kiro::KiroSource::new() else { + return KiroIngestOutcome::default(); }; - let _ = - crate::sessions::kiro::ingest_kiro_for_project(&db, &project_root, max_new_bytes).await; + let Some(registered_roots) = crate::sessions::try_registered_project_roots().await else { + return KiroIngestOutcome::default(); + }; + let source = source.for_user_scope(registered_roots); + let stats = + crate::sessions::source::ingest_source(&db, &source, &profile_root, max_new_bytes) + .await; + KiroIngestOutcome { + user_scope: true, + messages_upserted: stats.messages_upserted, + } }; - let _ = tokio::time::timeout(budget, work).await; + tokio::time::timeout(budget, work).await.unwrap_or_default() +} + +async fn kiro_prompt_memory_recall(event_json: &str) -> Option { + let parsed = serde_json::from_str::(event_json).ok()?; + let prompt = super::prompt_like_text(&parsed)?; + let session_id = event_session_id(&parsed); + match codex_project_root_from_event(event_json) { + Some(root) => { + Box::pin(memory_inject::combined_prompt_memory_recall( + &root, + session_id.as_deref(), + &prompt, + )) + .await + } + None => memory_inject::user_prompt_memory_recall(session_id.as_deref(), &prompt).await, + } } async fn notify_kiro_post_tool_use(event_json: &str) { diff --git a/src/hooks/memory_inject.rs b/src/hooks/memory_inject.rs index e3b4a8d10..200a2c1a5 100644 --- a/src/hooks/memory_inject.rs +++ b/src/hooks/memory_inject.rs @@ -22,6 +22,7 @@ use crate::memory::hygiene::detect_secret_like; use crate::memory::retrieval::FactRetriever; use crate::memory::store::MemoryStore; use crate::memory::types::{FactRecord, FactSearchResult}; +use crate::memory::user::open_user_memory_db; /// Hard cap for the session-start "durable project memory" digest. pub const SESSION_DIGEST_CHAR_BUDGET: usize = 2_000; @@ -47,12 +48,22 @@ const PROMPT_RECALL_MIN_SCORE: f64 = 0.18; const MAX_PERSISTED_SEEN_ENTRIES: usize = 4_096; const SEEN_FACTS_FILENAME: &str = "memory_inject_seen.json"; +const USER_SEEN_FACTS_FILENAME: &str = "user_memory_inject_seen.json"; const DIGEST_HEADER: &str = "Durable project memory (tracedecay fact store; \ rate with tracedecay_fact_feedback — unhelpful for a wrong fact counts as much as helpful; \ correct via tracedecay_fact_store update):"; +#[cfg(test)] const PROMPT_RECALL_HEADER: &str = "Possibly relevant project memory \ (tracedecay fact store; rate with tracedecay_fact_feedback — unhelpful for wrong facts counts too):"; +const USER_DIGEST_HEADER: &str = "Durable user memory (tracedecay user fact store; \ +rate with tracedecay_fact_feedback using memory_scope=user):"; +const USER_PROMPT_RECALL_HEADER: &str = "Possibly relevant user memory \ +(tracedecay user fact store; rate with tracedecay_fact_feedback using memory_scope=user):"; +const COMBINED_DIGEST_HEADER: &str = "Durable user and project memory \ +(tracedecay fact stores; each fact includes its scope):"; +const COMBINED_PROMPT_RECALL_HEADER: &str = "Possibly relevant user and project memory \ +(tracedecay fact stores; each fact includes its scope):"; // --------------------------------------------------------------------------- // Config gate @@ -240,6 +251,89 @@ fn render_fact_block( Some((out, included)) } +#[derive(Clone, Copy, PartialEq, Eq)] +enum MemoryScope { + User, + Project, +} + +impl MemoryScope { + fn label(self) -> &'static str { + match self { + Self::User => "user", + Self::Project => "project", + } + } +} + +fn select_scoped_facts( + user: Vec, + project: Vec, + max: usize, +) -> Vec<(MemoryScope, FactRecord)> { + let mut facts = user + .into_iter() + .map(|fact| (MemoryScope::User, fact)) + .chain(project.into_iter().map(|fact| (MemoryScope::Project, fact))) + .collect::>(); + facts.sort_by(|(left_scope, left), (right_scope, right)| { + right + .trust_score + .total_cmp(&left.trust_score) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| { + let rank = |scope| match scope { + MemoryScope::User => 0, + MemoryScope::Project => 1, + }; + rank(*left_scope).cmp(&rank(*right_scope)) + }) + }); + let mut seen_content = HashSet::new(); + facts + .into_iter() + .filter(|(_, fact)| seen_content.insert(sanitize_fact_line(&fact.content))) + .take(max) + .collect() +} + +fn render_scoped_fact_block( + header: &str, + facts: &[(MemoryScope, FactRecord)], + budget: usize, +) -> Option<(String, Vec, Vec)> { + if facts.is_empty() { + return None; + } + let mut out = String::from(header); + out.push('\n'); + let mut user_ids = Vec::new(); + let mut project_ids = Vec::new(); + for (scope, fact) in facts { + let line = format!( + "- [{} {} #{} trust {:.2}] {}", + scope.label(), + fact.category.as_str(), + fact.fact_id, + fact.trust_score, + sanitize_fact_line(&fact.content) + ); + if out.chars().count() + line.chars().count() + 1 > budget { + break; + } + out.push_str(&line); + out.push('\n'); + match scope { + MemoryScope::User => user_ids.push(fact.fact_id), + MemoryScope::Project => project_ids.push(fact.fact_id), + } + } + if user_ids.is_empty() && project_ids.is_empty() { + return None; + } + Some((out, user_ids, project_ids)) +} + /// Renders the session-start "durable project memory" digest within `budget`. #[cfg(test)] pub fn render_memory_digest(facts: &[FactRecord], budget: usize) -> Option { @@ -346,6 +440,24 @@ fn record_injected_facts(root: &Path, session_id: Option<&str>, fact_ids: &[i64] let _ = seen.save(&path); } +fn user_seen_facts_path() -> Option { + crate::storage::default_profile_root() + .ok() + .map(|root| root.join(USER_SEEN_FACTS_FILENAME)) +} + +fn record_injected_user_facts(session_id: Option<&str>, fact_ids: &[i64]) { + let (Some(session_id), Some(path)) = (session_id, user_seen_facts_path()) else { + return; + }; + if fact_ids.is_empty() { + return; + } + let mut seen = MemoryInjectSeen::load_or_default(&path); + seen.record(session_id, fact_ids); + let _ = seen.save(&path); +} + // --------------------------------------------------------------------------- // Async store-backed entry points (fail-open) // --------------------------------------------------------------------------- @@ -370,40 +482,35 @@ async fn digest_candidates(root: &Path) -> Vec { .unwrap_or_default() } -/// Builds the session-start memory digest for `root`, or `None` when -/// injection is disabled, the project has no initialized store, or no -/// injectable facts exist. When `session_id` is present the injected fact ids -/// are recorded so per-prompt recall does not repeat them. -pub async fn session_memory_digest(root: &Path, session_id: Option<&str>) -> Option { +/// Builds a session-start digest from the profile-level user memory store. +pub async fn user_session_memory_digest(session_id: Option<&str>) -> Option { if !memory_injection_enabled() { return None; } - if !crate::tracedecay::TraceDecay::has_initialized_store(root).await { - return None; - } - let facts = select_digest_facts(digest_candidates(root).await, SESSION_DIGEST_FACT_COUNT); - let (text, included) = render_fact_block(DIGEST_HEADER, &facts, SESSION_DIGEST_CHAR_BUDGET)?; - record_injected_facts(root, session_id, &included); + let profile_root = crate::storage::default_profile_root().ok()?; + let db = open_user_memory_db(&profile_root).await.ok()?; + let facts = MemoryStore::new(db.conn()) + .list_facts( + None, + Some(INJECTION_MIN_TRUST), + SESSION_DIGEST_FACT_COUNT * 4, + ) + .await + .ok()?; + let facts = select_digest_facts(facts, SESSION_DIGEST_FACT_COUNT); + let (text, included) = + render_fact_block(USER_DIGEST_HEADER, &facts, SESSION_DIGEST_CHAR_BUDGET)?; + record_injected_user_facts(session_id, &included); Some(text) } -/// Builds a prompt-relevance-gated recall block for a `UserPromptSubmit`-style -/// event, or `None` when injection is disabled, the prompt is trivial, or no -/// stored fact clears the relevance gate. Injected fact ids are deduplicated -/// against (and recorded into) the session's seen-fact history. -pub async fn prompt_memory_recall( - root: &Path, - session_id: Option<&str>, - prompt: &str, -) -> Option { +/// Recalls profile-level user facts for a projectless prompt. +pub async fn user_prompt_memory_recall(session_id: Option<&str>, prompt: &str) -> Option { if !memory_injection_enabled() || prompt.trim().chars().count() < MIN_PROMPT_CHARS { return None; } - if !crate::tracedecay::TraceDecay::has_initialized_store(root).await { - return None; - } - let cg = crate::tracedecay::TraceDecay::open(root).await.ok()?; - let db = cg.open_project_store_db().await.ok()?; + let profile_root = crate::storage::default_profile_root().ok()?; + let db = open_user_memory_db(&profile_root).await.ok()?; let results = FactRetriever::new(db.conn()) .search_untracked( prompt, @@ -413,7 +520,7 @@ pub async fn prompt_memory_recall( ) .await .ok()?; - let already_injected = match (session_id, seen_facts_path(root)) { + let already_injected = match (session_id, user_seen_facts_path()) { (Some(session_id), Some(path)) => { MemoryInjectSeen::load_or_default(&path).seen_for_session(session_id) } @@ -421,8 +528,116 @@ pub async fn prompt_memory_recall( }; let facts = select_prompt_recall_facts(results, &already_injected, PROMPT_RECALL_FACT_COUNT); let (text, included) = - render_fact_block(PROMPT_RECALL_HEADER, &facts, PROMPT_RECALL_CHAR_BUDGET)?; - record_injected_facts(root, session_id, &included); + render_fact_block(USER_PROMPT_RECALL_HEADER, &facts, PROMPT_RECALL_CHAR_BUDGET)?; + record_injected_user_facts(session_id, &included); + Some(text) +} + +/// Builds one bounded, provenance-labelled digest from user and active-project memory. +pub async fn combined_session_memory_digest( + root: &Path, + session_id: Option<&str>, +) -> Option { + if !memory_injection_enabled() { + return None; + } + let project = if crate::tracedecay::TraceDecay::has_initialized_store(root).await { + select_digest_facts(digest_candidates(root).await, SESSION_DIGEST_FACT_COUNT) + } else { + Vec::new() + }; + let user = match crate::storage::default_profile_root() { + Ok(profile_root) => match open_user_memory_db(&profile_root).await { + Ok(db) => MemoryStore::new(db.conn()) + .list_facts( + None, + Some(INJECTION_MIN_TRUST), + SESSION_DIGEST_FACT_COUNT * 4, + ) + .await + .map(|facts| select_digest_facts(facts, SESSION_DIGEST_FACT_COUNT)) + .unwrap_or_default(), + Err(_) => Vec::new(), + }, + Err(_) => Vec::new(), + }; + let facts = select_scoped_facts(user, project, SESSION_DIGEST_FACT_COUNT); + let (text, user_ids, project_ids) = + render_scoped_fact_block(COMBINED_DIGEST_HEADER, &facts, SESSION_DIGEST_CHAR_BUDGET)?; + record_injected_user_facts(session_id, &user_ids); + record_injected_facts(root, session_id, &project_ids); + Some(text) +} + +/// Recalls one bounded, provenance-labelled result set from user and active-project memory. +pub async fn combined_prompt_memory_recall( + root: &Path, + session_id: Option<&str>, + prompt: &str, +) -> Option { + if !memory_injection_enabled() || prompt.trim().chars().count() < MIN_PROMPT_CHARS { + return None; + } + let project_seen = match (session_id, seen_facts_path(root)) { + (Some(session_id), Some(path)) => { + MemoryInjectSeen::load_or_default(&path).seen_for_session(session_id) + } + _ => HashSet::new(), + }; + let project = if crate::tracedecay::TraceDecay::has_initialized_store(root).await { + match crate::tracedecay::TraceDecay::open(root).await { + Ok(cg) => match cg.open_project_store_db().await { + Ok(db) => FactRetriever::new(db.conn()) + .search_untracked( + prompt, + None, + Some(INJECTION_MIN_TRUST), + PROMPT_RECALL_FACT_COUNT * 4, + ) + .await + .map(|results| { + select_prompt_recall_facts(results, &project_seen, PROMPT_RECALL_FACT_COUNT) + }) + .unwrap_or_default(), + Err(_) => Vec::new(), + }, + Err(_) => Vec::new(), + } + } else { + Vec::new() + }; + let user_seen = match (session_id, user_seen_facts_path()) { + (Some(session_id), Some(path)) => { + MemoryInjectSeen::load_or_default(&path).seen_for_session(session_id) + } + _ => HashSet::new(), + }; + let user = match crate::storage::default_profile_root() { + Ok(profile_root) => match open_user_memory_db(&profile_root).await { + Ok(db) => FactRetriever::new(db.conn()) + .search_untracked( + prompt, + None, + Some(INJECTION_MIN_TRUST), + PROMPT_RECALL_FACT_COUNT * 4, + ) + .await + .map(|results| { + select_prompt_recall_facts(results, &user_seen, PROMPT_RECALL_FACT_COUNT) + }) + .unwrap_or_default(), + Err(_) => Vec::new(), + }, + Err(_) => Vec::new(), + }; + let facts = select_scoped_facts(user, project, PROMPT_RECALL_FACT_COUNT); + let (text, user_ids, project_ids) = render_scoped_fact_block( + COMBINED_PROMPT_RECALL_HEADER, + &facts, + PROMPT_RECALL_CHAR_BUDGET, + )?; + record_injected_user_facts(session_id, &user_ids); + record_injected_facts(root, session_id, &project_ids); Some(text) } @@ -464,6 +679,29 @@ pub fn render_cursor_memory_rule(project_root: Option<&str>, facts: &[FactRecord out } +/// Renders Cursor's always-applied rule for a projectless session. Replacing +/// the previous project rule prevents facts from the last workspace leaking +/// into general chat. +pub fn render_cursor_user_memory_rule(facts: &[FactRecord]) -> String { + let mut out = String::from( + "---\ndescription: Durable user memory from the tracedecay user fact store\nalwaysApply: true\n---\n\n", + ); + out.push_str(CURSOR_MEMORY_RULE_MARKER); + out.push_str("\n\n# User memory (tracedecay)\n\n"); + match render_fact_block(USER_DIGEST_HEADER, facts, SESSION_DIGEST_CHAR_BUDGET) { + Some((block, _)) => out.push_str(&block), + None => out.push_str( + "No durable user facts stored yet. Store lasting preferences with \ + `tracedecay_fact_store` using `memory_scope=user`.\n", + ), + } + out.push_str( + "\nCurate via `tracedecay_fact_store` or `tracedecay_fact_feedback` with \ + `memory_scope=user`.\n", + ); + out +} + fn write_cursor_memory_rule_if_managed(rule_path: &Path, rendered: &str) -> bool { match std::fs::read_to_string(rule_path) { Ok(existing) if existing == rendered => return false, @@ -490,6 +728,38 @@ pub async fn regenerate_cursor_memory_rule(root: &Path) -> bool { regenerate_cursor_memory_rule_with_home(root, &home).await } +/// Replaces Cursor's managed project rule with profile-level user memory for +/// a projectless session. +pub async fn regenerate_cursor_user_memory_rule() -> bool { + if !memory_injection_enabled() { + return false; + } + let (Some(home), Ok(profile_root)) = (dirs::home_dir(), crate::storage::default_profile_root()) + else { + return false; + }; + let rule_path = crate::agents::cursor::cursor_memory_rule_path(&home); + let Some(plugin_dir) = rule_path.parent().and_then(Path::parent) else { + return false; + }; + if !plugin_dir.join(".cursor-plugin/plugin.json").exists() { + return false; + } + let facts = match open_user_memory_db(&profile_root).await { + Ok(db) => MemoryStore::new(db.conn()) + .list_facts( + None, + Some(INJECTION_MIN_TRUST), + SESSION_DIGEST_FACT_COUNT * 4, + ) + .await + .unwrap_or_default(), + Err(_) => Vec::new(), + }; + let facts = select_digest_facts(facts, SESSION_DIGEST_FACT_COUNT); + write_cursor_memory_rule_if_managed(&rule_path, &render_cursor_user_memory_rule(&facts)) +} + async fn regenerate_cursor_memory_rule_with_home(root: &Path, home: &Path) -> bool { let rule_path = crate::agents::cursor::cursor_memory_rule_path(home); let Some(plugin_dir) = rule_path.parent().and_then(Path::parent) else { @@ -564,6 +834,28 @@ mod tests { assert!(digest.contains("[user_pref #3 trust 0.80] Prefer nextest over cargo test")); } + #[test] + fn combined_digest_labels_scopes_deduplicates_content_and_stays_bounded() { + let user = vec![ + fact(1, MemoryCategory::UserPref, 0.95, "Prefer concise answers"), + fact(2, MemoryCategory::General, 0.80, "Shared fact"), + ]; + let project = vec![ + fact(1, MemoryCategory::Decision, 0.90, "Use libsql"), + fact(3, MemoryCategory::General, 0.70, "Shared fact"), + ]; + let selected = select_scoped_facts(user, project, 8); + let (text, user_ids, project_ids) = + render_scoped_fact_block(COMBINED_DIGEST_HEADER, &selected, 300).unwrap(); + + assert!(text.contains("[user user_pref #1 trust 0.95] Prefer concise answers")); + assert!(text.contains("[project decision #1 trust 0.90] Use libsql")); + assert_eq!(text.matches("Shared fact").count(), 1); + assert!(text.chars().count() <= 300); + assert_eq!(user_ids, vec![1, 2]); + assert_eq!(project_ids, vec![1]); + } + #[test] fn digest_enforces_char_budget_by_dropping_trailing_facts() { let facts: Vec = (0..40) @@ -780,6 +1072,12 @@ mod tests { let empty = render_cursor_memory_rule(None, &[]); assert!(empty.contains("No durable facts stored yet")); assert!(empty.contains("alwaysApply: true")); + + let user = render_cursor_user_memory_rule(&facts); + assert!(user.contains("# User memory (tracedecay)")); + assert!(user.contains("Ship digests via hooks")); + assert!(!user.contains("/home/user/proj")); + assert!(user.contains("memory_scope=user")); } #[tokio::test] diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index 20e8b3203..55d87306c 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -3,7 +3,7 @@ //! Each agent sends its own event schema and expects its own output shape, so //! handlers stay agent-specific while shared plumbing lives here. -use std::io::Read; +use std::io::{Read, Write}; use std::path::{Component, Path, PathBuf}; use serde_json::Value; @@ -82,6 +82,233 @@ macro_rules! read_hook_event { } pub(crate) use read_hook_event; +pub async fn hook_hermes_terminal_receipt() -> i32 { + let event_json = read_hook_event!(); + let Ok(event) = serde_json::from_str::(&event_json) else { + return 0; + }; + if event.agent != "hermes" + || !matches!( + event.event.as_str(), + "terminalReceipt" | "turnCompleted" | "turnIngested" + ) + || event.receipt.is_none() + { + return 0; + } + let cwd = event + .route + .as_ref() + .and_then(|route| route.cwd.clone().or_else(|| route.worktree.clone())) + .or_else(|| event.cwd.clone()); + if let Some(project_root) = match cwd { + Some(cwd) => crate::config::discover_project_root_with_identity(&cwd).await, + None => None, + } { + crate::daemon::notify_hook_event(&project_root, event).await; + } else if let Err(error) = Box::pin(handle_user_hermes_receipt(event)).await { + eprintln!("[tracedecay] user Hermes receipt failed: {error}"); + } + 0 +} + +async fn handle_user_hermes_receipt( + event: crate::daemon::DaemonHookEvent, +) -> crate::errors::Result<()> { + use crate::automation::run_ledger::AutomationRunStatus; + + let profile_root = crate::storage::default_profile_root()?; + let dashboard_root = crate::automation::runner::user_automation_root(&profile_root); + let route = event.route.clone(); + let Some(receipt) = event.receipt.clone() else { + return Ok(()); + }; + match event.event.as_str() { + "terminalReceipt" | "turnCompleted" => { + crate::automation::host_receipts::record(&dashboard_root, route, receipt).await?; + } + "turnIngested" => { + let Some(watermark) = receipt.transcript_watermark.as_deref() else { + return Ok(()); + }; + crate::automation::host_receipts::mark_turn_ingested(&dashboard_root, route, watermark) + .await?; + let Some(ready) = + crate::automation::host_receipts::oldest_ready(&dashboard_root).await? + else { + return Ok(()); + }; + let sessions_path = crate::sessions::user_sessions_db_path(&profile_root); + let Some(session_db) = + crate::global_db::GlobalDb::open_read_only_at(&sessions_path).await + else { + return Ok(()); + }; + if session_db + .lcm_load_raw_message("hermes", &ready.transcript_watermark) + .await + .is_none() + { + return Ok(()); + } + if crate::automation::scheduler::load_scheduler_control(&dashboard_root) + .await? + .paused + { + return Ok(()); + } + let Some(_review_lock) = lock_user_session_review(&dashboard_root) else { + return Ok(()); + }; + let session_id = ready + .pending + .route + .as_ref() + .and_then(|route| route.session_id.clone()); + let run = run_user_session_review( + &profile_root, + "hermes", + session_id, + Some(format!("user_host_receipt_{}", ready.pending.generation)), + ) + .await?; + if run.session_reflector.ledger_record.status == AutomationRunStatus::Succeeded + && run.memory_curator.ledger_record.status != AutomationRunStatus::Failed + && run.skill_writer.ledger_record.status == AutomationRunStatus::Succeeded + { + crate::automation::host_receipts::mark_consumed( + &dashboard_root, + &ready.pending.session_key, + ready.pending.generation, + ) + .await?; + } + } + _ => {} + } + Ok(()) +} + +pub(crate) fn schedule_user_session_review(provider: &str, session_id: Option<&str>) { + let Ok(exe) = std::env::current_exe() else { + return; + }; + let payload = serde_json::json!({ + "provider": provider, + "session_id": session_id, + }) + .to_string(); + let Ok(mut child) = std::process::Command::new(exe) + .arg("hook-user-session-review") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + else { + return; + }; + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(payload.as_bytes()); + } +} + +pub async fn hook_user_session_review() -> i32 { + let event = read_hook_event!(); + let Ok(payload) = serde_json::from_str::(&event) else { + return 0; + }; + let Some(provider) = payload.get("provider").and_then(Value::as_str) else { + return 0; + }; + let session_id = payload + .get("session_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + review_user_session(provider, session_id).await; + 0 +} + +async fn review_user_session(provider: &str, session_id: Option) { + let Ok(profile_root) = crate::storage::default_profile_root() else { + return; + }; + let dashboard_root = crate::automation::runner::user_automation_root(&profile_root); + let Some(_review_lock) = lock_user_session_review(&dashboard_root) else { + return; + }; + if crate::automation::scheduler::load_scheduler_control(&dashboard_root) + .await + .is_ok_and(|control| control.paused) + { + return; + } + if let Err(error) = run_user_session_review(&profile_root, provider, session_id, None).await { + eprintln!("[tracedecay] {provider} user session review failed: {error}"); + } +} + +fn lock_user_session_review(dashboard_root: &std::path::Path) -> Option { + std::fs::create_dir_all(dashboard_root).ok()?; + let lock = std::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(dashboard_root.join("host-review.lock")) + .ok()?; + fs2::FileExt::lock_exclusive(&lock).ok()?; + Some(lock) +} + +async fn run_user_session_review( + profile_root: &std::path::Path, + provider: &str, + session_id: Option, + run_id: Option, +) -> crate::errors::Result { + use crate::automation::backend::CodexAppServerBackend; + use crate::automation::run_ledger::AutomationTrigger; + use crate::automation::runner::{ + MemoryCuratorAutomationOptions, SessionReflectorAutomationOptions, + SkillWriterAutomationOptions, UserSessionAutomationOptions, + run_user_session_automation_with_backend, + }; + + let global = crate::user_config::UserConfig::load().automation; + let config = crate::automation::config::effective_user_automation_config( + profile_root, + &global, + crate::user_config::automation_is_configured(), + ) + .await?; + let backend = CodexAppServerBackend::from_automation_config(&config); + run_user_session_automation_with_backend( + profile_root, + &config, + &backend, + UserSessionAutomationOptions { + session_reflector: SessionReflectorAutomationOptions { + trigger: AutomationTrigger::HostReceipt, + run_id, + provider: provider.to_string(), + session_id, + ..SessionReflectorAutomationOptions::default() + }, + memory_curator: MemoryCuratorAutomationOptions { + trigger: AutomationTrigger::HostReceipt, + ..MemoryCuratorAutomationOptions::default() + }, + skill_writer: SkillWriterAutomationOptions { + trigger: AutomationTrigger::HostReceipt, + provider: provider.to_string(), + ..SkillWriterAutomationOptions::default() + }, + }, + ) + .await +} + const TRACEDECAY_RESEARCH_BLOCK_REASON: &str = "STOP: Use tracedecay MCP tools \ (tracedecay_context, tracedecay_grep, tracedecay_search, tracedecay_callees, \ tracedecay_callers, tracedecay_impact, tracedecay_files, tracedecay_affected) \ diff --git a/src/hooks/steering.rs b/src/hooks/steering.rs index f8a21fca1..3fbff1b2e 100644 --- a/src/hooks/steering.rs +++ b/src/hooks/steering.rs @@ -164,7 +164,9 @@ pub fn build_codex_session_context_for_workspace( recovery, use tracedecay_lcm_expand_query, tracedecay_message_search, and \ tracedecay_lcm_describe before asking the user to repeat themselves. When \ a durable preference, decision, correction, or pitfall surfaces, store it \ - proactively with tracedecay_fact_store (action \"add\"). Do NOT store \ + proactively with tracedecay_fact_store (action \"add\") and \ + memory_scope \"user\". The CLI fallback supports this user scope even \ + without an initialized project. Do NOT store \ secrets or credentials, transient errors, environment-specific failures, \ one-off narratives, task progress, or soon-stale session outcomes; \ recover those from transcripts instead.\n", diff --git a/src/main.rs b/src/main.rs index 5ec8efd55..d3997f6ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -516,7 +516,9 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { | Commands::HookCodexUserPromptSubmit | Commands::HookCodexSubagentStart | Commands::HookCodexPostToolUse - | Commands::HookCodexPostCompact) => { + | Commands::HookCodexPostCompact + | Commands::HookHermesTerminalReceipt + | Commands::HookUserSessionReview) => { hook_cmd::handle_hook_command(hook_command).await?; } Commands::Dashboard { diff --git a/src/mcp/hook_events.rs b/src/mcp/hook_events.rs index d99284c97..16c2d92c8 100644 --- a/src/mcp/hook_events.rs +++ b/src/mcp/hook_events.rs @@ -17,6 +17,9 @@ pub(crate) enum HookEventKind { WorkspaceOpen, SessionStart, IncrementalSync, + TerminalReceipt, + TurnCompleted, + TurnIngested, } impl HookEventKind { @@ -27,6 +30,9 @@ impl HookEventKind { "workspaceOpen" => Some(Self::WorkspaceOpen), "sessionStart" => Some(Self::SessionStart), "postToolUse" => Some(Self::IncrementalSync), + "terminalReceipt" => Some(Self::TerminalReceipt), + "turnCompleted" => Some(Self::TurnCompleted), + "turnIngested" => Some(Self::TurnIngested), _ => None, } } @@ -38,6 +44,9 @@ impl HookEventKind { Self::WorkspaceOpen => "workspace_open", Self::SessionStart => "session_start", Self::IncrementalSync => "incremental_sync", + Self::TerminalReceipt => "terminal_receipt", + Self::TurnCompleted => "turn_completed", + Self::TurnIngested => "turn_ingested", } } } @@ -49,6 +58,7 @@ pub(crate) struct HookEvent { pub(crate) command: Option, pub(crate) cwd: Option, pub(crate) route: Option, + pub(crate) receipt: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -65,6 +75,14 @@ pub(crate) enum HookEventPlan { agent: HookAgent, }, DebouncedIncrementalSync(HookAgent), + RecordTerminalReceipt { + route: Option, + receipt: crate::daemon::HookTerminalReceipt, + }, + MarkTurnIngested { + route: Option, + transcript_watermark: String, + }, Noop, } @@ -77,6 +95,7 @@ pub(crate) fn parse_hook_event(params: Option<&Value>) -> Option { command: event.command.filter(|command| !command.is_empty()), cwd: event.cwd, route: event.route, + receipt: event.receipt, }) } @@ -108,6 +127,23 @@ pub(crate) fn plan_hook_event( HookEventPlan::SyncFiles(event.rel_paths.clone()) } HookEventKind::IncrementalSync => HookEventPlan::DebouncedIncrementalSync(event.agent), + HookEventKind::TerminalReceipt | HookEventKind::TurnCompleted => event + .receipt + .clone() + .map(|receipt| HookEventPlan::RecordTerminalReceipt { + route: event.route.clone(), + receipt, + }) + .unwrap_or(HookEventPlan::Noop), + HookEventKind::TurnIngested => event + .receipt + .as_ref() + .and_then(|receipt| receipt.transcript_watermark.clone()) + .map(|transcript_watermark| HookEventPlan::MarkTurnIngested { + route: event.route.clone(), + transcript_watermark, + }) + .unwrap_or(HookEventPlan::Noop), } } @@ -767,6 +803,49 @@ mod tests { assert_eq!(HookEventKind::SessionStart.as_key(), "session_start"); } + #[test] + fn parses_hermes_terminal_receipt_without_terminal_content() { + let event = parse_or_panic(&json!({ + "agent": "hermes", + "event": "terminalReceipt", + "cwd": "/tmp/project", + "route": {"session_id": "session-1", "cwd": "/tmp/project"}, + "receipt": { + "tool_call_id": "call-1", + "turn_id": "turn-1", + "status": "success", + "duration_ms": 12, + "transcript_watermark": "turn-1" + } + })); + assert_eq!(event.agent, HookAgent::Hermes); + assert_eq!(event.kind, HookEventKind::TerminalReceipt); + assert!(matches!( + plan_hook_event(&event, Path::new("/tmp/project"), None), + HookEventPlan::RecordTerminalReceipt { receipt, .. } + if receipt.tool_call_id.as_deref() == Some("call-1") + )); + } + + #[test] + fn plans_projectless_hermes_turn_completion_as_a_review_receipt() { + let event = parse_or_panic(&json!({ + "agent": "hermes", + "event": "turnCompleted", + "route": {"session_id": "session-1"}, + "receipt": { + "status": "success", + "transcript_watermark": "message-1" + } + })); + assert_eq!(event.kind, HookEventKind::TurnCompleted); + assert!(matches!( + plan_hook_event(&event, Path::new("/tmp/project"), None), + HookEventPlan::RecordTerminalReceipt { receipt, .. } + if receipt.transcript_watermark.as_deref() == Some("message-1") + )); + } + #[test] fn plans_session_start_from_main_checkout_as_current_branch_sync() { let (_base, project_root, _worktree_root) = setup_linked_session_worktree(); diff --git a/src/mcp/project_route.rs b/src/mcp/project_route.rs index 367c8ed50..0778736c8 100644 --- a/src/mcp/project_route.rs +++ b/src/mcp/project_route.rs @@ -418,6 +418,7 @@ mod tests { worktree: None, branch: None, }), + receipt: None, } } } diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 7a6bb1d81..36732addc 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -2196,7 +2196,7 @@ impl McpServer { *counts.entry(request.method.clone()).or_insert(0) += 1; } if matches!(classify_mcp_method(&request.method), McpMethod::HookEvent) { - self.handle_hook_event_notification(request.params.as_ref(), route_cache) + Box::pin(self.handle_hook_event_notification(request.params.as_ref(), route_cache)) .await; return None; } @@ -2335,6 +2335,42 @@ impl McpServer { HookEventPlan::DebouncedIncrementalSync(agent) => { self.run_hook_incremental_sync(cg, agent).await; } + HookEventPlan::RecordTerminalReceipt { route, receipt } => { + match crate::automation::host_receipts::record( + &cg.store_layout().dashboard_root, + route, + receipt, + ) + .await + { + Ok(true) => { + if let Some(reconcile) = &self.automation_scheduler_reconciler { + reconcile(); + } + } + Ok(false) => {} + Err(error) => eprintln!("[tracedecay] host receipt record failed: {error}"), + } + } + HookEventPlan::MarkTurnIngested { + route, + transcript_watermark, + } => { + match crate::automation::host_receipts::mark_turn_ingested( + &cg.store_layout().dashboard_root, + route, + &transcript_watermark, + ) + .await + { + Ok(()) => { + if let Some(reconcile) = &self.automation_scheduler_reconciler { + reconcile(); + } + } + Err(error) => eprintln!("[tracedecay] turn ingest receipt failed: {error}"), + } + } HookEventPlan::Noop => {} } } diff --git a/src/mcp/tool_analytics.rs b/src/mcp/tool_analytics.rs index 80ba1d27e..37febbceb 100644 --- a/src/mcp/tool_analytics.rs +++ b/src/mcp/tool_analytics.rs @@ -226,6 +226,7 @@ mod tests { worktree: Some(PathBuf::from("/repo")), branch: Some("feature/hook-route".to_string()), }), + receipt: None, }; let Some(record) = diff --git a/src/mcp/tools/definitions.rs b/src/mcp/tools/definitions.rs index efd63c2a7..8b7beefb0 100644 --- a/src/mcp/tools/definitions.rs +++ b/src/mcp/tools/definitions.rs @@ -347,6 +347,7 @@ pub fn get_tool_definitions() -> Vec { def_find_exact_symbol(), ]; add_registered_project_selector_properties(&mut definitions); + add_lcm_storage_scope_property(&mut definitions); add_format_property(&mut definitions); if !ast_grep_available() { definitions.retain(|d| d.name != "tracedecay_ast_grep_rewrite"); @@ -364,6 +365,29 @@ pub fn get_tool_definitions() -> Vec { definitions } +fn add_lcm_storage_scope_property(definitions: &mut [ToolDefinition]) { + for definition in definitions + .iter_mut() + .filter(|definition| definition.name.starts_with("tracedecay_lcm_")) + { + let Some(properties) = definition + .input_schema + .get_mut("properties") + .and_then(Value::as_object_mut) + else { + continue; + }; + properties.insert( + "storage_scope".to_string(), + json!({ + "type": "string", + "enum": ["project", "user"], + "description": "Session store scope. project (default) uses the active project shard; user uses the profile-level store for untethered conversations and cannot be combined with a project selector." + }), + ); + } +} + fn matching_tool_definitions_mut<'a>( definitions: &'a mut [ToolDefinition], tool_names: &'a [&'static str], @@ -2170,6 +2194,11 @@ fn def_run_affected_tests() -> ToolDefinition { fn memory_fact_properties() -> Value { json!({ + "memory_scope": { + "type": "string", + "enum": ["project", "user"], + "description": "Fact scope. project (default) uses the active project shard; user uses the profile-level store for durable preferences and projectless conversations." + }, "action": { "type": "string", "enum": ["add", "search", "probe", "related", "reason", "contradict", "get", "update", "remove", "list"], @@ -2371,6 +2400,11 @@ fn def_fact_feedback() -> ToolDefinition { "note": { "type": "string", "description": "Optional feedback note." + }, + "memory_scope": { + "type": "string", + "enum": ["project", "user"], + "description": "Scope containing the fact id (default: project)." } }, "anyOf": [ @@ -2396,11 +2430,26 @@ fn def_memory_status() -> ToolDefinition { "Repair derived holographic memory vectors and banks, then return fact/entity counts, trust distribution, below-threshold and missing-vector signals, capacity-per-bank, and repair stats. Defaults to the active project; pass project_id or project_path only when intentionally checking another registered project. Human/operator equivalents: `tracedecay memory status` and `GET /api/plugins/holographic/status`.", json!({ "type": "object", - "properties": project_selector_properties() + "properties": memory_status_properties() }), ) } +fn memory_status_properties() -> Value { + let mut properties = project_selector_properties(); + if let Some(properties) = properties.as_object_mut() { + properties.insert( + "memory_scope".to_string(), + json!({ + "type": "string", + "enum": ["project", "user"], + "description": "Memory scope to inspect (default: project)." + }), + ); + } + properties +} + fn def_automation_run_artifact_view() -> ToolDefinition { def( "tracedecay_automation_run_artifact_view", @@ -2926,6 +2975,10 @@ fn def_lcm_preflight() -> ToolDefinition { "description": "Current active context messages to inspect before compression.", "items": {"type": "object"} }, + "transcript_projection": { + "type": "boolean", + "description": "Host-integration flag: also upsert these stable-id messages into this project's searchable transcript projection. Intended for Hermes live turn ingestion when its state.db session lacks cwd provenance." + }, "current_tokens": { "type": "integer", "minimum": 0, diff --git a/src/mcp/tools/handlers/memory.rs b/src/mcp/tools/handlers/memory.rs index 329222ff6..62ac895df 100644 --- a/src/mcp/tools/handlers/memory.rs +++ b/src/mcp/tools/handlers/memory.rs @@ -15,6 +15,7 @@ use crate::memory::types::{ AddFactRequest, FactRecord, FactSearchResult, FeedbackAction, FeedbackRequest, MemoryCategory, SearchFactsRequest, UpdateFactRequest, }; +use crate::memory::user::open_user_memory_db; use crate::tracedecay::TraceDecay; use super::super::ToolResult; @@ -30,6 +31,19 @@ const MAX_FACT_LIMIT: usize = 200; pub(super) struct TargetMemoryDb { pub(super) db: Database, pub(super) project_root: PathBuf, + pub(super) user_scope: bool, +} + +fn requests_user_memory(args: &Value) -> bool { + args.get("memory_scope").and_then(Value::as_str) == Some("user") +} + +async fn open_user_memory_target(profile_root: &Path) -> Result { + Ok(TargetMemoryDb { + db: open_user_memory_db(profile_root).await?, + project_root: profile_root.to_path_buf(), + user_scope: true, + }) } fn text_tool_result(text: &str) -> ToolResult { @@ -57,6 +71,15 @@ pub(super) async fn open_target_memory_db( global_db: Option<&GlobalDb>, allow_default_registry_fallback: bool, ) -> Result { + if requests_user_memory(args) { + if project_selector_present(args, &["project_path"]) { + return Err(config_error( + "memory_scope=user cannot be combined with a project selector", + )); + } + let profile_root = profile_root_for_global_db(global_db, allow_default_registry_fallback)?; + return open_user_memory_target(&profile_root).await; + } let Some(context) = project_registry_context( args, &["project_path"], @@ -68,6 +91,7 @@ pub(super) async fn open_target_memory_db( return Ok(TargetMemoryDb { db: cg.open_project_store_db().await?, project_root: cg.project_root().to_path_buf(), + user_scope: false, }); }; let profile_root = profile_root_for_global_db(global_db, allow_default_registry_fallback)?; @@ -94,6 +118,7 @@ pub(super) async fn open_target_memory_db( Ok(TargetMemoryDb { db, project_root: PathBuf::from(context.project.display_root), + user_scope: false, }) } @@ -286,6 +311,15 @@ pub(super) async fn handle_fact_store( } let target_memory = open_target_memory_db(cg, &args, global_db, allow_default_registry_fallback).await?; + handle_fact_store_for_target(args, cross_project_selector, target_memory).await +} + +async fn handle_fact_store_for_target( + args: Value, + cross_project_selector: bool, + target_memory: TargetMemoryDb, +) -> Result { + let action = required_str(&args, "action")?; let conn = target_memory.db.conn(); let store = MemoryStore::new(conn); let mut refresh_digest = false; @@ -491,24 +525,30 @@ pub(super) async fn handle_fact_store( } other => return Err(config_error(format!("unknown fact_store action: {other}"))), }; - if refresh_digest { + if refresh_digest && !target_memory.user_scope { refresh_memory_digest_after_memory_change(conn, &target_memory.project_root).await; } Ok(rendered_fact_store( - Some(&target_memory.project_root), + (!target_memory.user_scope).then_some(target_memory.project_root.as_path()), &args, &out, )) } -pub(super) async fn handle_fact_feedback(cg: &TraceDecay, args: Value) -> Result { +pub(super) async fn handle_fact_feedback( + cg: &TraceDecay, + args: Value, + global_db: Option<&GlobalDb>, + allow_default_registry_fallback: bool, +) -> Result { let note = args .get("note") .or_else(|| args.get("reason")) .and_then(Value::as_str) .map(ToOwned::to_owned); - let db = cg.open_project_store_db().await?; - let result = MemoryStore::new(db.conn()) + let target_memory = + open_target_memory_db(cg, &args, global_db, allow_default_registry_fallback).await?; + let result = MemoryStore::new(target_memory.db.conn()) .record_feedback_event(FeedbackRequest { fact_id: fact_id(&args)?, action: feedback_action(&args)?, @@ -519,9 +559,19 @@ pub(super) async fn handle_fact_feedback(cg: &TraceDecay, args: Value) -> Result note, }) .await?; - refresh_memory_digest_after_memory_change(db.conn(), cg.project_root()).await; + if !target_memory.user_scope { + refresh_memory_digest_after_memory_change( + target_memory.db.conn(), + &target_memory.project_root, + ) + .await; + } let value = json!({ "status": "recorded", "feedback": result }); - Ok(rendered_tool_json(Some(cg.project_root()), &args, &value)) + Ok(rendered_tool_json( + (!target_memory.user_scope).then_some(target_memory.project_root.as_path()), + &args, + &value, + )) } pub(super) async fn handle_memory_status( @@ -535,8 +585,64 @@ pub(super) async fn handle_memory_status( let status = TraceDecay::memory_status_for_conn(target_memory.db.conn()).await?; let value = json!({ "status": "ok", "memory": status }); Ok(rendered_tool_json( - Some(&target_memory.project_root), + (!target_memory.user_scope).then_some(target_memory.project_root.as_path()), &args, &value, )) } + +pub async fn handle_user_memory_tool( + tool_name: &str, + args: Value, + profile_root: &Path, +) -> Result { + if !requests_user_memory(&args) { + return Err(config_error( + "projectless memory dispatch requires memory_scope=user", + )); + } + let target_memory = open_user_memory_target(profile_root).await?; + match tool_name { + "tracedecay_fact_store" => { + required_str(&args, "action")?; + if project_selector_present(&args, &["project_path"]) { + return Err(config_error( + "memory_scope=user cannot be combined with a project selector", + )); + } + handle_fact_store_for_target(args, false, target_memory).await + } + "tracedecay_fact_feedback" => { + let note = args + .get("note") + .or_else(|| args.get("reason")) + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let result = MemoryStore::new(target_memory.db.conn()) + .record_feedback_event(FeedbackRequest { + fact_id: fact_id(&args)?, + action: feedback_action(&args)?, + source: args + .get("source") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + note, + }) + .await?; + Ok(rendered_tool_json( + None, + &args, + &json!({ "status": "recorded", "feedback": result }), + )) + } + "tracedecay_memory_status" => { + let status = TraceDecay::memory_status_for_conn(target_memory.db.conn()).await?; + Ok(rendered_tool_json( + None, + &args, + &json!({ "status": "ok", "memory": status }), + )) + } + other => Err(config_error(format!("{other} is not a user-memory tool"))), + } +} diff --git a/src/mcp/tools/handlers/mod.rs b/src/mcp/tools/handlers/mod.rs index 86436b445..1c4fc044f 100644 --- a/src/mcp/tools/handlers/mod.rs +++ b/src/mcp/tools/handlers/mod.rs @@ -33,6 +33,50 @@ use crate::mcp::response_handles::{ResponseHandleLookup, retrieve_response_handl use crate::tracedecay::TraceDecay; use crate::tracedecay::current_timestamp; +pub async fn handle_user_lcm_tool( + tool_name: &str, + args: Value, + profile_root: &Path, +) -> Result { + if args.get("storage_scope").and_then(Value::as_str) != Some("user") { + return Err(TraceDecayError::Config { + message: "projectless LCM dispatch requires storage_scope=user".to_string(), + }); + } + if [ + "project_id", + "project_path", + "project_root", + "project_selector", + ] + .iter() + .any(|key| args.get(*key).is_some()) + { + return Err(TraceDecayError::Config { + message: "storage_scope=user cannot be combined with a project selector".to_string(), + }); + } + let sessions_db_path = crate::sessions::user_sessions_db_path(profile_root); + let context = session::LcmHandlerContext::user(&sessions_db_path); + match tool_name { + "tracedecay_lcm_status" => session::handle_lcm_status(context, args).await, + "tracedecay_lcm_doctor" => session::handle_lcm_doctor(context, args).await, + "tracedecay_lcm_load_session" => session::handle_lcm_load_session(context, args).await, + "tracedecay_lcm_grep" => session::handle_lcm_grep(context, args).await, + "tracedecay_lcm_describe" => session::handle_lcm_describe(context, args).await, + "tracedecay_lcm_expand" => session::handle_lcm_expand(context, args).await, + "tracedecay_lcm_expand_query" => session::handle_lcm_expand_query(context, args).await, + "tracedecay_lcm_preflight" => session::handle_lcm_preflight(context, args).await, + "tracedecay_lcm_compress" => session::handle_lcm_compress(context, args).await, + "tracedecay_lcm_session_boundary" => { + session::handle_lcm_session_boundary(context, args).await + } + _ => Err(TraceDecayError::Config { + message: format!("unknown user-scoped LCM tool: {tool_name}"), + }), + } +} + use super::ToolResult; use super::dispatch_policy::{ tool_accepts_registered_project_selector, tool_dispatches_registered_project_reader, @@ -250,13 +294,39 @@ pub async fn handle_tool_call_with_registry_and_implicit_project( tool_name.starts_with("tracedecay_"), "tool_name must start with 'tracedecay_' prefix" ); - for removed in ["storage_scope", "hermes_home"] { + for removed in ["hermes_home"] { if args.get(removed).is_some() { return Err(TraceDecayError::Config { message: format!("unknown parameter `{removed}` for `{tool_name}`"), }); } } + if let Some(storage_scope) = args.get("storage_scope").and_then(Value::as_str) { + if !tool_name.starts_with("tracedecay_lcm_") { + return Err(TraceDecayError::Config { + message: format!("unknown parameter `storage_scope` for `{tool_name}`"), + }); + } + match storage_scope { + "user" => { + let profile_root = support::profile_root_for_global_db( + options.global_db, + options.allow_default_registry_fallback, + )?; + return handle_user_lcm_tool(tool_name, args, &profile_root).await; + } + "project" => { + if let Some(object) = args.as_object_mut() { + object.remove("storage_scope"); + } + } + _ => { + return Err(TraceDecayError::Config { + message: "storage_scope must be one of project, user".to_string(), + }); + } + } + } if !tool_accepts_registered_project_selector(tool_name) && rejected_tool_project_selector_present(tool_name, &args) { @@ -436,7 +506,15 @@ pub async fn handle_tool_call_with_registry_and_implicit_project( ) .await } - "tracedecay_fact_feedback" => memory::handle_fact_feedback(cg, args).await, + "tracedecay_fact_feedback" => { + memory::handle_fact_feedback( + cg, + args, + options.global_db, + options.allow_default_registry_fallback, + ) + .await + } "tracedecay_memory_status" => { memory::handle_memory_status( cg, diff --git a/src/mcp/tools/handlers/session.rs b/src/mcp/tools/handlers/session.rs index f9be432da..286d32350 100644 --- a/src/mcp/tools/handlers/session.rs +++ b/src/mcp/tools/handlers/session.rs @@ -11,7 +11,9 @@ use super::support::{ string_arg, tool_json, tool_json_with_md, }; use crate::errors::{Result, TraceDecayError}; -use crate::global_db::{GlobalDb, ProjectRegistryContext, WorkflowScopeFilter}; +use crate::global_db::{ + GlobalDb, ParseOffset, ProjectRegistryContext, TranscriptBatch, WorkflowScopeFilter, +}; use crate::mcp::response_handles::{ RESPONSE_RETRIEVE_TOOL, observe_response_truncation, store_response_handle, }; @@ -27,9 +29,10 @@ use crate::sessions::lcm::{ LcmLoadSessionRequest, LcmPreflightRequest, LcmScope, LcmSessionBoundaryRequest, LcmSummarizerMode, }; +use crate::sessions::shared::{content_storage_text_and_tools, preview_title}; use crate::sessions::{ - ProviderScope, SessionMessageSearchResult, SessionMessageType, SessionSearchFilters, - SessionSearchScope, SessionSearchTimeRange, + ProviderScope, SessionMessageRecord, SessionMessageSearchResult, SessionMessageType, + SessionRecord, SessionSearchFilters, SessionSearchScope, SessionSearchTimeRange, }; use crate::timeutil::SearchTimeBound; use crate::tracedecay::{TraceDecay, current_timestamp}; @@ -297,6 +300,13 @@ impl<'a> LcmHandlerContext<'a> { project_session_db_path: Some(cg.store_layout().sessions_db_path.as_path()), } } + + pub(super) fn user(sessions_db_path: &'a Path) -> Self { + Self { + project_root: None, + project_session_db_path: Some(sessions_db_path), + } + } } async fn selected_project_session_db_path( @@ -1787,9 +1797,6 @@ async fn open_lcm_storage( args: &Value, mode: LcmOpenMode, ) -> LcmStorageResolution { - if context.project_root.is_none() { - return LcmStorageResolution::Unavailable(project_local_storage_without_project(args)); - } let Some(db_path) = context.project_session_db_path else { return LcmStorageResolution::Unavailable(project_local_storage_without_project(args)); }; @@ -2910,12 +2917,13 @@ pub(super) async fn handle_lcm_preflight( let provider = required_specific_provider_arg(&args)?; let session_id = required_string_arg(&args, "session_id")?; let storage = lcm_open_storage!(context, &args); + let messages = messages_arg(&args)?; let response = storage .db .lcm_preflight(LcmPreflightRequest { provider: provider.to_string(), session_id: session_id.to_string(), - messages: messages_arg(&args)?, + messages: messages.clone(), current_tokens: non_negative_i64_arg(&args, "current_tokens")?, threshold_tokens: non_negative_i64_arg(&args, "threshold_tokens")?, max_assembly_tokens: non_negative_i64_arg(&args, "max_assembly_tokens")?, @@ -2934,6 +2942,16 @@ pub(super) async fn handle_lcm_preflight( }) .await .map_err(lcm_error)?; + if bool_arg(&args, "transcript_projection")? == Some(true) { + upsert_live_transcript_projection( + &storage.db, + context.project_root, + provider, + session_id, + &messages, + ) + .await; + } Ok(lcm_preflight_tool_json( context.project_root, &args, @@ -2948,6 +2966,121 @@ pub(super) async fn handle_lcm_preflight( )) } +async fn upsert_live_transcript_projection( + db: &GlobalDb, + project_root: Option<&Path>, + provider: &str, + session_id: &str, + messages: &[Value], +) { + let project = project_root + .map(|root| root.to_string_lossy().to_string()) + .unwrap_or_else(|| "user".to_string()); + let storage_scope = if project_root.is_some() { + "project" + } else { + "user" + }; + let source_path = format!("live://{provider}/{session_id}"); + let mut projected = Vec::new(); + for (ordinal, message) in messages.iter().enumerate() { + let Some(message_id) = message + .get("id") + .or_else(|| message.get("message_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + else { + continue; + }; + let role = message + .get("role") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + let content = message.get("content").cloned().unwrap_or(Value::Null); + let tool_calls = message.get("tool_calls"); + let (text, tool_names) = content_storage_text_and_tools(&content, tool_calls); + if text.trim().is_empty() { + continue; + } + projected.push(SessionMessageRecord { + provider: provider.to_string(), + message_id: message_id.to_string(), + session_id: session_id.to_string(), + role, + timestamp: message + .get("timestamp") + .and_then(Value::as_f64) + .map(|value| value as i64), + ordinal: ordinal as i64, + text, + kind: Some("message".to_string()), + model: message + .get("model") + .and_then(Value::as_str) + .map(str::to_string), + tool_names: (!tool_names.is_empty()).then(|| tool_names.join(",")), + source_path: Some(source_path.clone()), + source_offset: Some(ordinal as i64), + metadata_json: Some( + json!({ + "source": "lcm_preflight_live", + "project_root": project, + "storage_scope": storage_scope, + "location_provenance": "host_live_route" + }) + .to_string(), + ), + }); + } + if projected.is_empty() { + return; + } + let title = projected + .iter() + .find(|message| message.role == "user") + .map(|message| preview_title(&message.text)); + let batch = TranscriptBatch { + session: SessionRecord { + provider: provider.to_string(), + session_id: session_id.to_string(), + project_key: project.clone(), + project_path: project, + title, + started_at: projected + .iter() + .filter_map(|message| message.timestamp) + .min(), + ended_at: None, + transcript_path: Some(source_path.clone()), + metadata_json: Some( + json!({ + "source": "lcm_preflight_live", + "storage_scope": storage_scope, + "location_provenance": "host_live_route" + }) + .to_string(), + ), + parent_session_id: None, + is_subagent: false, + agent_id: None, + parent_tool_use_id: None, + }, + messages: projected, + }; + let persisted = db + .upsert_transcript_projection_batches(&[batch], &source_path, ParseOffset::default()) + .await; + if !persisted { + tracing::debug!( + provider, + session_id, + "live transcript projection upsert failed" + ); + } +} + pub(super) async fn handle_lcm_compress( context: LcmHandlerContext<'_>, args: Value, diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs index 12e42a763..ef263f37c 100644 --- a/src/mcp/tools/mod.rs +++ b/src/mcp/tools/mod.rs @@ -21,9 +21,10 @@ pub use definitions::{ tool_defaults_to_markdown, }; pub(crate) use dispatch_policy::tool_dispatches_registered_project_reader; +pub use handlers::memory::handle_user_memory_tool; pub use handlers::{ ToolCallRegistryOptions, handle_tool_call, handle_tool_call_with_registry, - handle_tool_call_with_registry_and_implicit_project, + handle_tool_call_with_registry_and_implicit_project, handle_user_lcm_tool, }; /// Maximum character length for a tool response before truncation. diff --git a/src/memory/entities.rs b/src/memory/entities.rs index 98d536bdb..42c7a96e6 100644 --- a/src/memory/entities.rs +++ b/src/memory/entities.rs @@ -1,5 +1,7 @@ use std::collections::HashSet; +use super::hygiene::detect_secret_like; + /// Hard upper bound on an entity's character length. An entity name is a short /// identifier-like or proper-noun span, never a sentence, so anything longer is /// a mangled extraction (e.g. a run captured between two apostrophes) and is @@ -18,6 +20,22 @@ pub fn normalize_entity(entity: &str) -> String { .join(" ") } +/// Canonicalizes an entity alias and rejects values that are unsafe or too +/// broad for deterministic alias matching. +pub fn normalize_entity_alias(alias: &str) -> Result { + let normalized = normalize_entity(alias); + if normalized.is_empty() || !is_valid_entity(&normalized) { + return Err("alias is empty or not a bounded entity name"); + } + if normalized.contains(['/', '\\']) || normalized == "." || normalized == ".." { + return Err("path fragments are not valid entity aliases"); + } + if detect_secret_like(&normalized).is_some() { + return Err("secret-like values are not valid entity aliases"); + } + Ok(normalized) +} + pub fn extract_entities(text: &str) -> Vec { let mut matches = Vec::new(); diff --git a/src/memory/mod.rs b/src/memory/mod.rs index 6e314ca16..7dc317214 100644 --- a/src/memory/mod.rs +++ b/src/memory/mod.rs @@ -9,3 +9,4 @@ pub mod similarity; pub mod store; pub mod trust; pub mod types; +pub mod user; diff --git a/src/memory/retrieval.rs b/src/memory/retrieval.rs index 603aff3be..5d697565b 100644 --- a/src/memory/retrieval.rs +++ b/src/memory/retrieval.rs @@ -80,6 +80,20 @@ impl<'a> FactRetriever<'a> { limit.saturating_mul(10), ) .await?; + let direct_ids: HashSet = fts_scores + .keys() + .copied() + .chain(entity_candidate_ids) + .collect(); + let related_ids: HashSet = self + .store + .related_fact_ids( + &direct_ids.iter().copied().collect::>(), + limit.saturating_mul(5), + ) + .await? + .into_iter() + .collect(); let mut candidates = self .store .list_facts(category, Some(min_trust), limit.saturating_mul(10)) @@ -89,7 +103,7 @@ impl<'a> FactRetriever<'a> { // `list_facts` baseline did not already include, then hydrate them with a // single batched `get_facts` call instead of one round-trip per id. let mut missing_ids: Vec = Vec::new(); - for fact_id in fts_scores.keys().copied().chain(entity_candidate_ids) { + for fact_id in direct_ids.iter().chain(&related_ids).copied() { if candidate_ids.insert(fact_id) { missing_ids.push(fact_id); } @@ -102,11 +116,14 @@ impl<'a> FactRetriever<'a> { } } } + candidates.retain(|fact| { + fact.trust_score >= min_trust && category.is_none_or(|value| fact.category == value) + }); if !query_tokens.is_empty() { - let fts_ids: HashSet = fts_scores.keys().copied().collect(); candidates.retain(|fact| { - fts_ids.contains(&fact.fact_id) + direct_ids.contains(&fact.fact_id) + || related_ids.contains(&fact.fact_id) || token_overlap(&query_tokens, &fact_search_tokens(fact)) > 0 }); } @@ -201,12 +218,15 @@ impl<'a> FactRetriever<'a> { .conn() .query( "SELECT DISTINCT related.entity_id, related.name, related.normalized_name, - related.entity_type, related.created_at + related.entity_type, related.created_at, related.updated_at FROM memory_entities source JOIN memory_fact_entities source_fe ON source_fe.entity_id = source.entity_id JOIN memory_fact_entities related_fe ON related_fe.fact_id = source_fe.fact_id JOIN memory_entities related ON related.entity_id = related_fe.entity_id - WHERE source.normalized_name = ?1 + WHERE (source.normalized_name = ?1 OR EXISTS ( + SELECT 1 FROM json_each(source.aliases) + WHERE lower(json_each.value) = ?1 + )) AND related.normalized_name != ?1 ORDER BY related.name LIMIT ?2", @@ -224,7 +244,7 @@ impl<'a> FactRetriever<'a> { normalized_name: row.get::(2).map_err(|e| db_error("related", e))?, entity_type: Some(row.get::(3).map_err(|e| db_error("related", e))?), created_at, - updated_at: created_at, + updated_at: row.get::(5).map_err(|e| db_error("related", e))?, }); } Ok(entities) @@ -462,13 +482,19 @@ impl<'a> FactRetriever<'a> { // Bind each term's exact and LIKE values as anonymous `?` placeholders in // positional order. `escape_like` still governs wildcard semantics on the // LIKE value, but the value is bound rather than interpolated. - let mut values: Vec = Vec::with_capacity(terms.len() * 2 + 3); + let mut values: Vec = Vec::with_capacity(terms.len() * 4 + 3); let predicates = terms .iter() .map(|term| { values.push(libsql::Value::Text(term.clone())); values.push(libsql::Value::Text(format!("%{}%", escape_like(term)))); - "(e.normalized_name = ? OR e.normalized_name LIKE ? ESCAPE '\\')".to_string() + values.push(libsql::Value::Text(term.clone())); + values.push(libsql::Value::Text(format!("%{}%", escape_like(term)))); + "(e.normalized_name = ? OR e.normalized_name LIKE ? ESCAPE '\\' OR EXISTS ( + SELECT 1 FROM json_each(e.aliases) alias + WHERE lower(alias.value) = ? OR lower(alias.value) LIKE ? ESCAPE '\\' + ))" + .to_string() }) .collect::>() .join(" OR "); @@ -541,7 +567,10 @@ impl<'a> FactRetriever<'a> { FROM memory_entities e JOIN memory_fact_entities fe ON fe.entity_id = e.entity_id JOIN memory_facts f ON f.fact_id = fe.fact_id - WHERE e.normalized_name = ?1 + WHERE (e.normalized_name = ?1 OR EXISTS ( + SELECT 1 FROM json_each(e.aliases) + WHERE lower(json_each.value) = ?1 + )) AND f.category = ?2 AND f.trust_score >= ?3 ORDER BY f.updated_at DESC @@ -551,7 +580,10 @@ impl<'a> FactRetriever<'a> { FROM memory_entities e JOIN memory_fact_entities fe ON fe.entity_id = e.entity_id JOIN memory_facts f ON f.fact_id = fe.fact_id - WHERE e.normalized_name = ?1 + WHERE (e.normalized_name = ?1 OR EXISTS ( + SELECT 1 FROM json_each(e.aliases) + WHERE lower(json_each.value) = ?1 + )) AND f.trust_score >= ?2 ORDER BY f.updated_at DESC LIMIT ?3" diff --git a/src/memory/store.rs b/src/memory/store.rs index 6dba4a3f2..23eede598 100644 --- a/src/memory/store.rs +++ b/src/memory/store.rs @@ -10,13 +10,15 @@ use super::diff::{ vector_similarity, }; use super::encoding::HolographicEncoder; -use super::entities::{extract_entities, normalize_entity}; +use super::entities::{extract_entities, normalize_entity, normalize_entity_alias}; use super::hygiene::detect_secret_like; use super::similarity::content_tokens; use super::trust::{DEFAULT_MIN_TRUST, apply_feedback, clamp_trust}; use super::types::{ - AddFactDiff, AddFactDiffKind, AddFactOutcome, AddFactRequest, FactRecord, FeedbackAction, - FeedbackRequest, FeedbackResult, MemoryCategory, TrustHistoryEntry, UpdateFactRequest, + AddFactDiff, AddFactDiffKind, AddFactOutcome, AddFactRequest, EntityGroomingResult, FactRecord, + FactRelationKind, FactRelationRecord, FeedbackAction, FeedbackRequest, FeedbackResult, + MemoryCategory, MemoryGroomingOperation, MemoryGroomingReport, TrustHistoryEntry, + UpdateFactRequest, }; use crate::errors::{Result, TraceDecayError}; use crate::sync::content_hash; @@ -549,6 +551,8 @@ impl<'a> MemoryStore<'a> { } let mut deleted = Vec::with_capacity(loser_ids.len()); + self.rewire_fact_relations_inner(winner_id, &loser_ids) + .await?; for loser_id in loser_ids { if self.remove_fact_inner(loser_id).await? { deleted.push(loser_id); @@ -1181,6 +1185,863 @@ impl<'a> MemoryStore<'a> { Ok(rebuilt) } + pub async fn upsert_fact_relation( + &self, + source_fact_id: i64, + target_fact_id: i64, + relation: FactRelationKind, + confidence: f64, + source: &str, + metadata: serde_json::Value, + ) -> Result { + self.with_immediate_tx( + "upsert_fact_relation", + self.upsert_fact_relation_inner( + source_fact_id, + target_fact_id, + relation, + confidence, + source, + metadata, + ), + ) + .await + } + + async fn upsert_fact_relation_inner( + &self, + source_fact_id: i64, + target_fact_id: i64, + relation: FactRelationKind, + confidence: f64, + source: &str, + metadata: serde_json::Value, + ) -> Result { + if source_fact_id == target_fact_id { + return Err(db_message( + "upsert_fact_relation", + "self-relations are not allowed", + )); + } + if !(0.0..=1.0).contains(&confidence) || !confidence.is_finite() { + return Err(db_message( + "upsert_fact_relation", + "confidence must be finite and between 0 and 1", + )); + } + let source = source.trim(); + if source.is_empty() { + return Err(db_message("upsert_fact_relation", "source cannot be empty")); + } + if self.get_fact(source_fact_id).await?.is_none() + || self.get_fact(target_fact_id).await?.is_none() + { + return Err(db_message( + "upsert_fact_relation", + "source and target facts must both exist in this project store", + )); + } + let mut existing_rows = self + .conn + .query( + "SELECT relation FROM memory_fact_relations + WHERE source_fact_id = ?1 AND target_fact_id = ?2", + params![source_fact_id, target_fact_id], + ) + .await + .map_err(|e| db_error("upsert_fact_relation", e))?; + while let Some(row) = existing_rows + .next() + .await + .map_err(|e| db_error("upsert_fact_relation", e))? + { + let existing = row + .get::(0) + .map_err(|e| db_error("upsert_fact_relation", e))? + .parse::() + .map_err(|e| db_message("upsert_fact_relation", e))?; + if relations_conflict(existing, relation) { + return Err(db_message( + "upsert_fact_relation", + "supports and contradicts cannot coexist for the same directed fact pair", + )); + } + } + let metadata_json = to_json_string(&metadata, "upsert_fact_relation")?; + let now = current_timestamp(); + self.conn + .execute( + "INSERT INTO memory_fact_relations ( + source_fact_id, target_fact_id, relation, confidence, + source, metadata, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7) + ON CONFLICT(source_fact_id, target_fact_id, relation) DO UPDATE SET + confidence = excluded.confidence, + source = excluded.source, + metadata = excluded.metadata, + updated_at = excluded.updated_at", + params![ + source_fact_id, + target_fact_id, + relation.as_str(), + confidence, + source, + metadata_json, + now, + ], + ) + .await + .map_err(|e| db_error("upsert_fact_relation", e))?; + self.get_fact_relation(source_fact_id, target_fact_id, relation) + .await? + .ok_or_else(|| { + db_message( + "upsert_fact_relation", + "relation was not found after upsert", + ) + }) + } + + pub async fn list_fact_relations( + &self, + fact_id: Option, + ) -> Result> { + let sql = if fact_id.is_some() { + "SELECT source_fact_id, target_fact_id, relation, confidence, source, + metadata, created_at, updated_at + FROM memory_fact_relations + WHERE source_fact_id = ?1 OR target_fact_id = ?1 + ORDER BY source_fact_id, target_fact_id, relation" + } else { + "SELECT source_fact_id, target_fact_id, relation, confidence, source, + metadata, created_at, updated_at + FROM memory_fact_relations + ORDER BY source_fact_id, target_fact_id, relation" + }; + let mut rows = if let Some(fact_id) = fact_id { + self.conn.query(sql, params![fact_id]).await + } else { + self.conn.query(sql, ()).await + } + .map_err(|e| db_error("list_fact_relations", e))?; + let mut relations = Vec::new(); + while let Some(row) = rows + .next() + .await + .map_err(|e| db_error("list_fact_relations", e))? + { + relations.push(relation_from_row(&row, "list_fact_relations")?); + } + Ok(relations) + } + + pub async fn related_fact_ids(&self, fact_ids: &[i64], limit: usize) -> Result> { + if fact_ids.is_empty() || limit == 0 { + return Ok(Vec::new()); + } + let fact_ids = &fact_ids[..fact_ids.len().min(128)]; + let placeholders = std::iter::repeat_n("?", fact_ids.len()) + .collect::>() + .join(","); + let mut values = Vec::with_capacity(fact_ids.len() * 3 + 1); + for _ in 0..3 { + values.extend(fact_ids.iter().copied().map(libsql::Value::Integer)); + } + values.push(libsql::Value::Integer(limit.min(256) as i64)); + let mut rows = self + .conn + .query( + &format!( + "SELECT CASE WHEN source_fact_id IN ({placeholders}) + THEN target_fact_id ELSE source_fact_id END AS related_fact_id + FROM memory_fact_relations + WHERE source_fact_id IN ({placeholders}) OR target_fact_id IN ({placeholders}) + ORDER BY confidence DESC, updated_at DESC + LIMIT ?" + ), + values, + ) + .await + .map_err(|e| db_error("related_fact_ids", e))?; + let mut related = BTreeSet::new(); + while let Some(row) = rows + .next() + .await + .map_err(|e| db_error("related_fact_ids", e))? + { + related.insert( + row.get::(0) + .map_err(|e| db_error("related_fact_ids", e))?, + ); + } + Ok(related.into_iter().collect()) + } + + pub async fn remove_fact_relation( + &self, + source_fact_id: i64, + target_fact_id: i64, + relation: FactRelationKind, + ) -> Result { + let changed = self + .conn + .execute( + "DELETE FROM memory_fact_relations + WHERE source_fact_id = ?1 AND target_fact_id = ?2 AND relation = ?3", + params![source_fact_id, target_fact_id, relation.as_str()], + ) + .await + .map_err(|e| db_error("remove_fact_relation", e))?; + Ok(changed > 0) + } + + pub async fn normalize_fact_tags(&self, fact_id: i64, tags: &[String]) -> Result> { + self.with_immediate_tx( + "normalize_fact_tags", + self.normalize_fact_tags_inner(fact_id, tags), + ) + .await + } + + async fn normalize_fact_tags_inner( + &self, + fact_id: i64, + tags: &[String], + ) -> Result> { + let fact = self.get_fact(fact_id).await?.ok_or_else(|| { + db_message("normalize_fact_tags", format!("fact {fact_id} not found")) + })?; + let normalized: Vec = tags + .iter() + .map(|tag| { + tag.trim() + .to_ascii_lowercase() + .split_whitespace() + .collect::>() + .join("_") + .replace('-', "_") + }) + .filter(|tag| !tag.is_empty()) + .collect::>() + .into_iter() + .collect(); + let tags_json = to_json_string(&normalized, "normalize_fact_tags")?; + self.conn + .execute( + "UPDATE memory_facts SET tags = ?1, updated_at = ?2 WHERE fact_id = ?3", + params![tags_json, current_timestamp(), fact_id], + ) + .await + .map_err(|e| db_error("normalize_fact_tags", e))?; + self.mark_fact_banks_dirty(fact.category).await?; + Ok(normalized) + } + + pub async fn update_entity_aliases( + &self, + entity_id: i64, + aliases: &[String], + ) -> Result> { + self.with_immediate_tx( + "update_entity_aliases", + self.update_entity_aliases_inner(entity_id, aliases), + ) + .await + } + + async fn update_entity_aliases_inner( + &self, + entity_id: i64, + aliases: &[String], + ) -> Result> { + let mut rows = self + .conn + .query( + "SELECT name, aliases FROM memory_entities WHERE entity_id = ?1", + params![entity_id], + ) + .await + .map_err(|e| db_error("update_entity_aliases", e))?; + let row = rows + .next() + .await + .map_err(|e| db_error("update_entity_aliases", e))? + .ok_or_else(|| { + db_message( + "update_entity_aliases", + format!("entity {entity_id} not found"), + ) + })?; + let canonical = row + .get::(0) + .map_err(|e| db_error("update_entity_aliases", e))?; + let stored = row + .get::(1) + .map_err(|e| db_error("update_entity_aliases", e))?; + let mut merged: BTreeMap = serde_json::from_str::>(&stored) + .unwrap_or_default() + .into_iter() + .chain(aliases.iter().cloned()) + .map(|alias| { + normalize_entity_alias(&alias) + .map(|value| (value.to_ascii_lowercase(), value)) + .map_err(|reason| db_message("update_entity_aliases", reason)) + }) + .collect::>()?; + merged.remove(&canonical.to_ascii_lowercase()); + let aliases: Vec = merged.into_values().collect(); + let aliases_json = to_json_string(&aliases, "update_entity_aliases")?; + self.conn + .execute( + "UPDATE memory_entities SET aliases = ?1, updated_at = ?2 WHERE entity_id = ?3", + params![aliases_json, current_timestamp(), entity_id], + ) + .await + .map_err(|e| db_error("update_entity_aliases", e))?; + self.mark_entity_fact_banks_dirty(entity_id, false).await?; + Ok(aliases) + } + + pub async fn merge_entities( + &self, + winner_entity_id: i64, + loser_entity_ids: Vec, + ) -> Result { + self.with_immediate_tx( + "merge_entities", + self.merge_entities_inner(winner_entity_id, loser_entity_ids), + ) + .await + } + + async fn merge_entities_inner( + &self, + winner_entity_id: i64, + loser_entity_ids: Vec, + ) -> Result { + let mut seen = BTreeSet::new(); + let mut aliases = Vec::new(); + let mut fact_ids = BTreeSet::new(); + for entity_id in std::iter::once(winner_entity_id).chain(loser_entity_ids.iter().copied()) { + if !seen.insert(entity_id) { + return Err(db_message( + "merge_entities", + "duplicate winner/loser entity id", + )); + } + let mut rows = self + .conn + .query( + "SELECT name, aliases FROM memory_entities WHERE entity_id = ?1", + params![entity_id], + ) + .await + .map_err(|e| db_error("merge_entities", e))?; + let row = rows + .next() + .await + .map_err(|e| db_error("merge_entities", e))? + .ok_or_else(|| { + db_message("merge_entities", format!("entity {entity_id} not found")) + })?; + if entity_id != winner_entity_id { + aliases.push( + row.get::(0) + .map_err(|e| db_error("merge_entities", e))?, + ); + } + aliases.extend( + serde_json::from_str::>( + &row.get::(1) + .map_err(|e| db_error("merge_entities", e))?, + ) + .unwrap_or_default(), + ); + let mut links = self + .conn + .query( + "SELECT fact_id FROM memory_fact_entities WHERE entity_id = ?1", + params![entity_id], + ) + .await + .map_err(|e| db_error("merge_entities", e))?; + while let Some(link) = links + .next() + .await + .map_err(|e| db_error("merge_entities", e))? + { + fact_ids.insert( + link.get::(0) + .map_err(|e| db_error("merge_entities", e))?, + ); + } + } + let aliases = self + .update_entity_aliases_inner(winner_entity_id, &aliases) + .await?; + for loser_id in &loser_entity_ids { + self.conn + .execute( + "INSERT OR IGNORE INTO memory_fact_entities (fact_id, entity_id) + SELECT fact_id, ?1 FROM memory_fact_entities WHERE entity_id = ?2", + params![winner_entity_id, *loser_id], + ) + .await + .map_err(|e| db_error("merge_entities", e))?; + self.conn + .execute( + "DELETE FROM memory_entities WHERE entity_id = ?1", + params![*loser_id], + ) + .await + .map_err(|e| db_error("merge_entities", e))?; + } + for fact_id in &fact_ids { + self.invalidate_fact_vector_and_mark_dirty(*fact_id).await?; + } + Ok(EntityGroomingResult { + winner_entity_id, + merged_entity_ids: loser_entity_ids, + aliases, + rewired_fact_count: fact_ids.len(), + }) + } + + pub async fn repair_fact_vector(&self, fact_id: i64) -> Result { + self.with_immediate_tx("repair_fact_vector", self.repair_fact_vector_inner(fact_id)) + .await + } + + async fn repair_fact_vector_inner(&self, fact_id: i64) -> Result { + let Some(fact) = self.get_fact(fact_id).await? else { + return Err(db_message( + "repair_fact_vector", + format!("fact {fact_id} not found"), + )); + }; + self.update_fact_vector(fact_id, &fact.content, &fact.entities, "repair_fact_vector") + .await?; + self.mark_fact_banks_dirty(fact.category).await?; + Ok(true) + } + + /// Applies a fully prevalidated, bounded grooming batch atomically, then + /// repairs derived vectors and dirty banks using the store's configured + /// encoder contract before commit. + pub async fn apply_grooming_batch( + &self, + operations: &[MemoryGroomingOperation], + min_confidence: f64, + ) -> Result { + let mut report = self + .with_immediate_tx( + "apply_grooming_batch", + self.apply_grooming_batch_inner(operations, min_confidence), + ) + .await?; + // Derived state is resumable. Keep its CPU-heavy work outside the + // logical mutation transaction and cap each pass. + report.derived_repair.missing_vectors_repaired = self.compute_missing_vectors(500).await?; + report.derived_repair.banks_rebuilt = self.rebuild_dirty_banks().await?; + Ok(report) + } + + async fn apply_grooming_batch_inner( + &self, + operations: &[MemoryGroomingOperation], + min_confidence: f64, + ) -> Result { + self.prevalidate_grooming_batch(operations, min_confidence) + .await?; + let mut report = MemoryGroomingReport::default(); + for operation in operations { + match operation { + MemoryGroomingOperation::NormalizeTags { fact_id, tags, .. } => { + self.normalize_fact_tags_inner(*fact_id, tags).await?; + report.normalized_tags += 1; + } + MemoryGroomingOperation::MergeEntities { + winner_entity_id, + loser_entity_ids, + .. + } => { + self.merge_entities_inner(*winner_entity_id, loser_entity_ids.clone()) + .await?; + report.merged_entities += 1; + } + MemoryGroomingOperation::AddAlias { + entity_id, alias, .. + } => { + self.update_entity_aliases_inner(*entity_id, std::slice::from_ref(alias)) + .await?; + report.aliases_added += 1; + } + MemoryGroomingOperation::LinkFacts { + source_fact_id, + target_fact_id, + relation, + confidence, + source, + metadata, + .. + } => { + self.upsert_fact_relation_inner( + *source_fact_id, + *target_fact_id, + *relation, + *confidence, + source, + metadata.clone(), + ) + .await?; + report.facts_linked += 1; + } + MemoryGroomingOperation::RepairVector { fact_id, .. } => { + self.repair_fact_vector_inner(*fact_id).await?; + report.vectors_repaired += 1; + } + } + } + Ok(report) + } + + async fn prevalidate_grooming_batch( + &self, + operations: &[MemoryGroomingOperation], + min_confidence: f64, + ) -> Result<()> { + if !(0.0..=1.0).contains(&min_confidence) || !min_confidence.is_finite() { + return Err(db_message( + "apply_grooming_batch", + "invalid confidence floor", + )); + } + let mut merged_entities = BTreeSet::new(); + let mut proposed_relations = HashMap::new(); + let existing_relations = self.list_fact_relations(None).await?; + for operation in operations { + let (confidence, evidence): (f64, &[i64]) = match operation { + MemoryGroomingOperation::NormalizeTags { + confidence, + evidence_fact_ids, + .. + } + | MemoryGroomingOperation::MergeEntities { + confidence, + evidence_fact_ids, + .. + } + | MemoryGroomingOperation::AddAlias { + confidence, + evidence_fact_ids, + .. + } + | MemoryGroomingOperation::LinkFacts { + confidence, + evidence_fact_ids, + .. + } + | MemoryGroomingOperation::RepairVector { + confidence, + evidence_fact_ids, + .. + } => (*confidence, evidence_fact_ids), + }; + if confidence < min_confidence || confidence > 1.0 || !confidence.is_finite() { + return Err(db_message( + "apply_grooming_batch", + format!("operation confidence {confidence} is outside the accepted range"), + )); + } + if evidence.is_empty() { + return Err(db_message( + "apply_grooming_batch", + "every grooming operation requires evidence_fact_ids", + )); + } + for fact_id in evidence { + if self.get_fact(*fact_id).await?.is_none() { + return Err(db_message( + "apply_grooming_batch", + format!("evidence fact {fact_id} does not exist"), + )); + } + } + match operation { + MemoryGroomingOperation::NormalizeTags { fact_id, .. } + | MemoryGroomingOperation::RepairVector { fact_id, .. } => { + if self.get_fact(*fact_id).await?.is_none() { + return Err(db_message( + "apply_grooming_batch", + format!("fact {fact_id} does not exist"), + )); + } + } + MemoryGroomingOperation::MergeEntities { + winner_entity_id, + loser_entity_ids, + evidence_fact_ids, + .. + } => { + if loser_entity_ids.is_empty() { + return Err(db_message( + "apply_grooming_batch", + "entity merge has no losers", + )); + } + for entity_id in std::iter::once(winner_entity_id).chain(loser_entity_ids) { + if !merged_entities.insert(*entity_id) { + return Err(db_message( + "apply_grooming_batch", + "an entity appears in more than one merge role", + )); + } + if !self.entity_exists(*entity_id).await? { + return Err(db_message( + "apply_grooming_batch", + format!("entity {entity_id} does not exist"), + )); + } + if !self + .entity_linked_to_evidence(*entity_id, evidence_fact_ids) + .await? + { + return Err(db_message( + "apply_grooming_batch", + format!( + "entity {entity_id} is not linked to the supplied evidence facts" + ), + )); + } + } + } + MemoryGroomingOperation::AddAlias { + entity_id, + alias, + evidence_fact_ids, + .. + } => { + if !self.entity_exists(*entity_id).await? { + return Err(db_message( + "apply_grooming_batch", + format!("entity {entity_id} does not exist"), + )); + } + if !self + .entity_linked_to_evidence(*entity_id, evidence_fact_ids) + .await? + { + return Err(db_message( + "apply_grooming_batch", + format!( + "entity {entity_id} is not linked to the supplied evidence facts" + ), + )); + } + normalize_entity_alias(alias) + .map_err(|reason| db_message("apply_grooming_batch", reason))?; + } + MemoryGroomingOperation::LinkFacts { + source_fact_id, + target_fact_id, + relation, + .. + } => { + if source_fact_id == target_fact_id { + return Err(db_message( + "apply_grooming_batch", + "self-relations are not allowed", + )); + } + if self.get_fact(*source_fact_id).await?.is_none() + || self.get_fact(*target_fact_id).await?.is_none() + { + return Err(db_message( + "apply_grooming_batch", + "linked facts must both exist", + )); + } + let key = (*source_fact_id, *target_fact_id); + if let Some(other) = proposed_relations.insert(key, *relation) { + if relations_conflict(other, *relation) { + return Err(db_message( + "apply_grooming_batch", + "batch proposes contradictory relation kinds for the same facts", + )); + } + } + if existing_relations.iter().any(|existing| { + existing.source_fact_id == *source_fact_id + && existing.target_fact_id == *target_fact_id + && relations_conflict(existing.relation, *relation) + }) { + return Err(db_message( + "apply_grooming_batch", + "stored relation contradicts the proposed relation kind", + )); + } + } + } + } + Ok(()) + } + + async fn entity_exists(&self, entity_id: i64) -> Result { + let mut rows = self + .conn + .query( + "SELECT 1 FROM memory_entities WHERE entity_id = ?1 LIMIT 1", + params![entity_id], + ) + .await + .map_err(|e| db_error("entity_exists", e))?; + Ok(rows + .next() + .await + .map_err(|e| db_error("entity_exists", e))? + .is_some()) + } + + async fn entity_linked_to_evidence(&self, entity_id: i64, fact_ids: &[i64]) -> Result { + if fact_ids.is_empty() { + return Ok(false); + } + let placeholders = std::iter::repeat_n("?", fact_ids.len()) + .collect::>() + .join(","); + let mut values = Vec::with_capacity(fact_ids.len() + 1); + values.push(libsql::Value::Integer(entity_id)); + values.extend(fact_ids.iter().copied().map(libsql::Value::Integer)); + let mut rows = self + .conn + .query( + &format!( + "SELECT 1 FROM memory_fact_entities WHERE entity_id = ? AND fact_id IN ({placeholders}) LIMIT 1" + ), + values, + ) + .await + .map_err(|e| db_error("entity_linked_to_evidence", e))?; + Ok(rows + .next() + .await + .map_err(|e| db_error("entity_linked_to_evidence", e))? + .is_some()) + } + + async fn get_fact_relation( + &self, + source_fact_id: i64, + target_fact_id: i64, + relation: FactRelationKind, + ) -> Result> { + let mut rows = self + .conn + .query( + "SELECT source_fact_id, target_fact_id, relation, confidence, source, + metadata, created_at, updated_at + FROM memory_fact_relations + WHERE source_fact_id = ?1 AND target_fact_id = ?2 AND relation = ?3", + params![source_fact_id, target_fact_id, relation.as_str()], + ) + .await + .map_err(|e| db_error("get_fact_relation", e))?; + rows.next() + .await + .map_err(|e| db_error("get_fact_relation", e))? + .map(|row| relation_from_row(&row, "get_fact_relation")) + .transpose() + } + + async fn rewire_fact_relations_inner(&self, winner_id: i64, loser_ids: &[i64]) -> Result<()> { + if loser_ids.is_empty() { + return Ok(()); + } + let loser_set: BTreeSet = loser_ids.iter().copied().collect(); + let relations = self.list_fact_relations(None).await?; + self.conn + .execute( + &format!( + "DELETE FROM memory_fact_relations WHERE source_fact_id IN ({0}) OR target_fact_id IN ({0})", + std::iter::repeat_n("?", loser_ids.len()).collect::>().join(",") + ), + loser_ids.iter().copied().map(libsql::Value::Integer).collect::>(), + ) + .await + .map_err(|e| db_error("merge_facts", e))?; + for relation in relations.into_iter().filter(|relation| { + loser_set.contains(&relation.source_fact_id) + || loser_set.contains(&relation.target_fact_id) + }) { + let source_fact_id = if loser_set.contains(&relation.source_fact_id) { + winner_id + } else { + relation.source_fact_id + }; + let target_fact_id = if loser_set.contains(&relation.target_fact_id) { + winner_id + } else { + relation.target_fact_id + }; + if source_fact_id != target_fact_id { + self.upsert_fact_relation_inner( + source_fact_id, + target_fact_id, + relation.relation, + relation.confidence, + &relation.source, + relation.metadata, + ) + .await?; + } + } + Ok(()) + } + + async fn mark_entity_fact_banks_dirty(&self, entity_id: i64, invalidate: bool) -> Result<()> { + let mut rows = self + .conn + .query( + "SELECT fact_id FROM memory_fact_entities WHERE entity_id = ?1", + params![entity_id], + ) + .await + .map_err(|e| db_error("mark_entity_fact_banks_dirty", e))?; + let mut fact_ids = Vec::new(); + while let Some(row) = rows + .next() + .await + .map_err(|e| db_error("mark_entity_fact_banks_dirty", e))? + { + fact_ids.push( + row.get::(0) + .map_err(|e| db_error("mark_entity_fact_banks_dirty", e))?, + ); + } + for fact_id in fact_ids { + if invalidate { + self.invalidate_fact_vector_and_mark_dirty(fact_id).await?; + } else if let Some(fact) = self.get_fact(fact_id).await? { + self.mark_fact_banks_dirty(fact.category).await?; + } + } + Ok(()) + } + + async fn invalidate_fact_vector_and_mark_dirty(&self, fact_id: i64) -> Result<()> { + if let Some(fact) = self.get_fact(fact_id).await? { + self.conn + .execute( + "UPDATE memory_facts SET hrr_vector = NULL WHERE fact_id = ?1", + params![fact_id], + ) + .await + .map_err(|e| db_error("invalidate_fact_vector", e))?; + self.mark_fact_banks_dirty(fact.category).await?; + } + Ok(()) + } + pub(crate) fn conn(&self) -> &Connection { self.conn } @@ -1296,9 +2157,9 @@ impl<'a> MemoryStore<'a> { self.conn .execute( "INSERT OR IGNORE INTO memory_entities ( - name, normalized_name, entity_type, aliases, created_at + name, normalized_name, entity_type, aliases, created_at, updated_at ) - VALUES (?1, ?2, 'unknown', '[]', ?3)", + VALUES (?1, ?2, 'unknown', '[]', ?3, ?3)", params![name, normalized.as_str(), current_timestamp(),], ) .await @@ -1590,6 +2451,34 @@ fn fact_from_row(row: &libsql::Row, operation: &str, entities: Vec) -> R }) } +fn relation_from_row(row: &libsql::Row, operation: &str) -> Result { + let relation = row + .get::(2) + .map_err(|e| db_error(operation, e))? + .parse::() + .map_err(|e| db_message(operation, e))?; + let metadata = serde_json::from_str(&row.get::(5).map_err(|e| db_error(operation, e))?) + .map_err(|e| db_message(operation, format!("failed to parse relation metadata: {e}")))?; + Ok(FactRelationRecord { + source_fact_id: row.get::(0).map_err(|e| db_error(operation, e))?, + target_fact_id: row.get::(1).map_err(|e| db_error(operation, e))?, + relation, + confidence: row.get::(3).map_err(|e| db_error(operation, e))?, + source: row.get::(4).map_err(|e| db_error(operation, e))?, + metadata, + created_at: row.get::(6).map_err(|e| db_error(operation, e))?, + updated_at: row.get::(7).map_err(|e| db_error(operation, e))?, + }) +} + +fn relations_conflict(left: FactRelationKind, right: FactRelationKind) -> bool { + matches!( + (left, right), + (FactRelationKind::Supports, FactRelationKind::Contradicts) + | (FactRelationKind::Contradicts, FactRelationKind::Supports) + ) +} + fn serialize_vector(vector: &[f64], operation: &str) -> Result> { HolographicEncoder::serialize(vector) .map_err(|e| db_message(operation, format!("failed to serialize vector: {e}"))) diff --git a/src/memory/types.rs b/src/memory/types.rs index e5bad35c0..5c12793ef 100644 --- a/src/memory/types.rs +++ b/src/memory/types.rs @@ -127,6 +127,114 @@ pub struct EntityRecord { pub updated_at: i64, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FactRelationKind { + Supports, + Contradicts, + Supersedes, + DerivedFrom, +} + +impl FactRelationKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::Supports => "supports", + Self::Contradicts => "contradicts", + Self::Supersedes => "supersedes", + Self::DerivedFrom => "derived_from", + } + } +} + +impl fmt::Display for FactRelationKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for FactRelationKind { + type Err = String; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "supports" => Ok(Self::Supports), + "contradicts" => Ok(Self::Contradicts), + "supersedes" => Ok(Self::Supersedes), + "derived_from" => Ok(Self::DerivedFrom), + other => Err(format!("unsupported fact relation kind: {other}")), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FactRelationRecord { + pub source_fact_id: i64, + pub target_fact_id: i64, + pub relation: FactRelationKind, + pub confidence: f64, + pub source: String, + pub metadata: Value, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct EntityGroomingResult { + pub winner_entity_id: i64, + pub merged_entity_ids: Vec, + pub aliases: Vec, + pub rewired_fact_count: usize, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum MemoryGroomingOperation { + NormalizeTags { + fact_id: i64, + tags: Vec, + evidence_fact_ids: Vec, + confidence: f64, + }, + MergeEntities { + winner_entity_id: i64, + loser_entity_ids: Vec, + evidence_fact_ids: Vec, + confidence: f64, + }, + AddAlias { + entity_id: i64, + alias: String, + evidence_fact_ids: Vec, + confidence: f64, + }, + LinkFacts { + source_fact_id: i64, + target_fact_id: i64, + relation: FactRelationKind, + evidence_fact_ids: Vec, + confidence: f64, + source: String, + #[serde(default)] + metadata: Value, + }, + RepairVector { + fact_id: i64, + evidence_fact_ids: Vec, + confidence: f64, + }, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct MemoryGroomingReport { + pub normalized_tags: usize, + pub merged_entities: usize, + pub aliases_added: usize, + pub facts_linked: usize, + pub vectors_repaired: usize, + pub derived_repair: MemoryRepairStats, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct FactSearchResult { pub fact: FactRecord, @@ -186,7 +294,7 @@ pub struct TrustHistoryEntry { pub note: Option, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct MemoryRepairStats { pub missing_vectors_repaired: usize, pub banks_rebuilt: usize, diff --git a/src/memory/user.rs b/src/memory/user.rs new file mode 100644 index 000000000..5c1ffca39 --- /dev/null +++ b/src/memory/user.rs @@ -0,0 +1,20 @@ +//! Profile-level durable memory for conversations without a code project. + +use std::path::{Path, PathBuf}; + +use crate::db::Database; +use crate::errors::Result; + +pub const USER_MEMORY_DB_FILENAME: &str = "user-memory.db"; + +pub fn user_memory_db_path(profile_root: &Path) -> PathBuf { + profile_root.join(USER_MEMORY_DB_FILENAME) +} + +pub async fn open_user_memory_db(profile_root: &Path) -> Result { + let path = user_memory_db_path(profile_root); + if path.is_file() { + return Database::open(&path).await.map(|(db, _)| db); + } + Database::initialize(&path).await.map(|(db, _)| db) +} diff --git a/src/migrate/consolidate/sqlite.rs b/src/migrate/consolidate/sqlite.rs index e4db61435..89f7905fb 100644 --- a/src/migrate/consolidate/sqlite.rs +++ b/src/migrate/consolidate/sqlite.rs @@ -222,6 +222,32 @@ async fn merge_one_graph_tx(conn: &Connection, offset: &GraphMergeOffsets) -> Re SELECT s.fact_id, t.fact_id FROM source.memory_facts s JOIN memory_facts t ON t.content = s.content; + INSERT INTO memory_fact_relations ( + source_fact_id, target_fact_id, relation, confidence, + source, metadata, created_at, updated_at + ) + SELECT source_map.target_id, target_map.target_id, relation.relation, + relation.confidence, relation.source, relation.metadata, + relation.created_at, relation.updated_at + FROM source.memory_fact_relations AS relation + JOIN consolidation_fact_map AS source_map + ON source_map.source_id = relation.source_fact_id + JOIN consolidation_fact_map AS target_map + ON target_map.source_id = relation.target_fact_id + WHERE source_map.target_id != target_map.target_id + ON CONFLICT(source_fact_id, target_fact_id, relation) DO UPDATE SET + confidence = CASE + WHEN excluded.updated_at >= memory_fact_relations.updated_at + THEN excluded.confidence ELSE memory_fact_relations.confidence END, + source = CASE + WHEN excluded.updated_at >= memory_fact_relations.updated_at + THEN excluded.source ELSE memory_fact_relations.source END, + metadata = CASE + WHEN excluded.updated_at >= memory_fact_relations.updated_at + THEN excluded.metadata ELSE memory_fact_relations.metadata END, + created_at = MIN(memory_fact_relations.created_at, excluded.created_at), + updated_at = MAX(memory_fact_relations.updated_at, excluded.updated_at); + INSERT OR IGNORE INTO memory_entities ( entity_id, name, normalized_name, entity_type, aliases, created_at ) diff --git a/src/migrate/consolidate/tests.rs b/src/migrate/consolidate/tests.rs index f6b26baf1..11c5232b6 100644 --- a/src/migrate/consolidate/tests.rs +++ b/src/migrate/consolidate/tests.rs @@ -13,7 +13,9 @@ use tempfile::TempDir; use super::*; use crate::db::Database; use crate::memory::store::MemoryStore; -use crate::memory::types::{AddFactRequest, FeedbackAction, FeedbackRequest, MemoryCategory}; +use crate::memory::types::{ + AddFactRequest, FactRelationKind, FeedbackAction, FeedbackRequest, MemoryCategory, +}; use crate::sessions::{SessionMessageRecord, SessionRecord}; use crate::tracedecay::{TraceDecay, TraceDecayOpenOptions}; @@ -276,6 +278,7 @@ async fn many_branch_plan_retains_constant_database_handles_and_bounded_scratch( #[tokio::test] async fn interrupted_apply_retries_without_duplicates_and_cuts_over_last() { let fixture = fixture().await; + add_fact_relation_to_shard(&fixture, &fixture.source_id).await; let options = fixture.options(); let source_root = fixture.profile.join("projects").join(&fixture.source_id); let target_root = fixture.profile.join("projects").join(&fixture.target_id); @@ -344,7 +347,13 @@ async fn interrupted_apply_retries_without_duplicates_and_cuts_over_last() { let sessions = applied .destination_data_root .join(storage::SESSIONS_DB_FILENAME); - assert_eq!(sqlite::count_rows(&graph, "memory_facts").await.unwrap(), 2); + assert_eq!(sqlite::count_rows(&graph, "memory_facts").await.unwrap(), 3); + assert_eq!( + sqlite::count_rows(&graph, "memory_fact_relations") + .await + .unwrap(), + 1 + ); assert_eq!( sqlite::count_rows(&graph, "memory_feedback_events") .await @@ -462,7 +471,13 @@ async fn interrupted_apply_retries_without_duplicates_and_cuts_over_last() { let retried = apply(&options, &report.confirmation_token).await.unwrap(); assert_eq!(retried.state, ConsolidationState::Applied); - assert_eq!(sqlite::count_rows(&graph, "memory_facts").await.unwrap(), 2); + assert_eq!(sqlite::count_rows(&graph, "memory_facts").await.unwrap(), 3); + assert_eq!( + sqlite::count_rows(&graph, "memory_fact_relations") + .await + .unwrap(), + 1 + ); assert_eq!(sqlite::count_rows(&sessions, "sessions").await.unwrap(), 2); } @@ -2263,6 +2278,7 @@ fn graph_table_disposition(table: &str) -> Option<&'static str> { match table { "memory_entities" | "memory_fact_entities" + | "memory_fact_relations" | "memory_facts" | "memory_feedback_events" | "memory_oplog" => Some("merged"), @@ -2528,6 +2544,51 @@ async fn add_fact_to_shard( graph.close(); } +async fn add_fact_relation_to_shard(fixture: &Fixture, project_id: &str) { + let layout = layout_for_id(&fixture.project, &fixture.profile, project_id).unwrap(); + let (graph, _) = Database::open(&layout.graph_db_path).await.unwrap(); + let memory = MemoryStore::new(graph.conn()); + let source_fact_id = memory + .list_facts(None, Some(0.0), 10) + .await + .unwrap() + .into_iter() + .next() + .expect("fixture source fact") + .fact_id; + let target_fact_id = memory + .add_fact( + AddFactRequest { + content: "relation target fact".to_string(), + category: MemoryCategory::Project, + source: Some("consolidation-test".to_string()), + tags: vec!["relation".to_string()], + entities: vec!["TraceDecay".to_string()], + trust: Some(0.75), + metadata: json!({"project_id": project_id}), + }, + 0.5, + ) + .await + .unwrap() + .fact + .expect("relation target fact should be stored") + .fact_id; + memory + .upsert_fact_relation( + source_fact_id, + target_fact_id, + FactRelationKind::Supports, + 0.9, + "consolidation-test", + json!({"evidence": "fixture"}), + ) + .await + .unwrap(); + graph.checkpoint().await.unwrap(); + graph.close(); +} + fn add_branch_links(fixture: &Fixture, project_id: &str, count: usize) { let layout = layout_for_id(&fixture.project, &fixture.profile, project_id).unwrap(); let mut meta = branch_meta::load_branch_meta(&layout.data_root).unwrap(); diff --git a/src/migrate/hermes.rs b/src/migrate/hermes.rs index e94f6deab..bd0c58cb7 100644 --- a/src/migrate/hermes.rs +++ b/src/migrate/hermes.rs @@ -2437,7 +2437,7 @@ mod tests { } #[tokio::test] - async fn legacy_only_upgrade_keeps_plugin_until_default_install_succeeds() { + async fn named_profile_upgrade_refreshes_in_place_without_default_cutover() { let temp = tempfile::tempdir().unwrap(); let user_home = temp.path().join("home"); let profile_root = temp.path().join("tracedecay-profile"); @@ -2466,14 +2466,11 @@ mod tests { project_root: None, dashboard: false, }; - let Err(error) = HermesIntegration.update_plugin(&ctx) else { - panic!("conflicting default config must fail the cutover"); - }; - assert!( - error - .to_string() - .contains("memory provider already configured") - ); + let outcome = HermesIntegration.update_plugin(&ctx).unwrap(); + assert!(matches!( + outcome, + UpdatePluginOutcome::Refreshed(paths) if paths == vec![legacy_plugin.clone()] + )); assert!(legacy_plugin.join("plugin.yaml").is_file()); assert_eq!( fs::read_to_string(legacy_profile.join("config.yaml")).unwrap(), @@ -2493,13 +2490,16 @@ mod tests { ); fs::write(&default_config, "").unwrap(); let outcome = HermesIntegration.update_plugin(&ctx).unwrap(); - assert!(matches!(outcome, UpdatePluginOutcome::Refreshed(_))); + assert!(matches!( + outcome, + UpdatePluginOutcome::Refreshed(paths) if paths == vec![legacy_plugin.clone()] + )); + assert!(legacy_plugin.join("plugin.yaml").is_file()); assert!( - user_home + !user_home .join(".hermes/plugins/tracedecay/plugin.yaml") - .is_file() + .exists() ); - assert!(!legacy_plugin.join("plugin.yaml").exists()); let layout = crate::storage::resolve_layout(&project, &profile_root).unwrap(); let target = GlobalDb::open_read_only_at(&layout.sessions_db_path) diff --git a/src/sessions/claude.rs b/src/sessions/claude.rs index ac8e3ec22..d6639585e 100644 --- a/src/sessions/claude.rs +++ b/src/sessions/claude.rs @@ -75,6 +75,12 @@ pub(crate) const CWD_PROBE_LINES: usize = 8; /// Claude Code transcript locator + parser. pub struct ClaudeSource { projects_dir: PathBuf, + user_scope: Option, +} + +struct UserClaudeScope { + session_id: Option, + registered_roots: Vec, } impl ClaudeSource { @@ -89,8 +95,41 @@ impl ClaudeSource { pub fn with_home(home: &Path) -> Self { Self { projects_dir: home.join(".claude").join("projects"), + user_scope: None, } } + + /// Restricts ingestion to transcript rows that cannot be attributed to any + /// registered project. `session_id` bounds a live hook ingest; `None` + /// performs a historical sweep. + #[must_use] + pub fn for_user_scope( + mut self, + session_id: Option, + registered_roots: Vec, + ) -> Self { + self.user_scope = Some(UserClaudeScope { + session_id, + registered_roots, + }); + self + } +} + +/// Ingests projectless Claude transcript evidence into the profile session +/// store. Registered-project rows are excluded even when a Claude session +/// crosses workspace boundaries. +pub async fn ingest_user_sessions( + db: &crate::global_db::GlobalDb, + profile_root: &Path, + session_id: Option, + registered_roots: Vec, +) -> crate::sessions::shared::TranscriptIngestStats { + let Some(source) = ClaudeSource::new() else { + return crate::sessions::shared::TranscriptIngestStats::default(); + }; + let source = source.for_user_scope(session_id, registered_roots); + crate::sessions::source::ingest_source(db, &source, profile_root, None).await } impl TranscriptSource for ClaudeSource { @@ -133,6 +172,19 @@ impl TranscriptSource for ClaudeSource { }, |info| info.session_id.clone(), ); + if self + .user_scope + .as_ref() + .and_then(|scope| scope.session_id.as_deref()) + .is_some_and(|expected| { + expected != session_id + && subagent + .as_ref() + .is_none_or(|info| expected != info.parent_session_id) + }) + { + return None; + } // Session-level facts folded across every new line (PR links seen in the // session, the set of files edited) so the draft can carry a compact @@ -142,10 +194,22 @@ impl TranscriptSource for ClaudeSource { for line in &new.lines { let record = &line.value; let line_cwd = record_cwd(record).or_else(|| session_cwd.clone()); - if !line_cwd - .as_deref() - .is_some_and(|cwd| path_belongs_to_project(cwd, project_root)) - { + let include = self.user_scope.as_ref().map_or_else( + || { + line_cwd + .as_deref() + .is_some_and(|cwd| path_belongs_to_project(cwd, project_root)) + }, + |scope| { + line_cwd.as_deref().is_none_or(|cwd| { + !scope + .registered_roots + .iter() + .any(|root| path_belongs_to_project(cwd, root)) + }) + }, + ); + if !include { continue; } // Conversational turns and system hook signals first; structured @@ -195,7 +259,10 @@ impl TranscriptSource for ClaudeSource { // persist the advanced cursor; returning `None` would pin the cursor // at 0 and re-read + re-filter the whole file on every sweep. - let project = project_root.to_string_lossy().to_string(); + let project = self.user_scope.as_ref().map_or_else( + || project_root.to_string_lossy().to_string(), + |_| "user".to_string(), + ); let draft = SessionDraft { session_id, project_key: project.clone(), diff --git a/src/sessions/cline_like.rs b/src/sessions/cline_like.rs index d40a80ec7..cb806e7e3 100644 --- a/src/sessions/cline_like.rs +++ b/src/sessions/cline_like.rs @@ -45,6 +45,7 @@ const CLINE_LIKE_LOCATION_KEYS: TranscriptLocationMetadataKeys = pub struct ClineLikeSource { provider: &'static str, storage_roots: Vec, + user_registered_roots: Option>, } impl ClineLikeSource { @@ -76,6 +77,7 @@ impl ClineLikeSource { crate::agents::vscode_data_dir(home) .join("User/globalStorage/saoudrizwan.claude-dev/tasks"), ], + user_registered_roots: None, } } @@ -86,6 +88,7 @@ impl ClineLikeSource { crate::agents::vscode_data_dir(home) .join("User/globalStorage/rooveterinaryinc.roo-cline/tasks"), ], + user_registered_roots: None, } } @@ -97,8 +100,15 @@ impl ClineLikeSource { .join("User/globalStorage/kilocode.kilo-code/tasks"), home.join(".kilocode/cli/global/tasks"), ], + user_registered_roots: None, } } + + #[must_use] + pub fn for_user_scope(mut self, registered_roots: Vec) -> Self { + self.user_registered_roots = Some(registered_roots); + self + } } impl TranscriptSource for ClineLikeSource { @@ -125,7 +135,18 @@ impl TranscriptSource for ClineLikeSource { let ui_path = task_dir.join("ui_messages.json"); let changed = read_changed_with_companion(path, &ui_path, prev)?; let metadata = read_task_metadata(task_dir)?; - let location_cwd = metadata_project_location(&metadata, project_root)?; + let location_cwd = if let Some(roots) = &self.user_registered_roots { + let paths = metadata_project_paths(&metadata); + if paths + .iter() + .any(|path| roots.iter().any(|root| path_belongs_to_project(path, root))) + { + return None; + } + paths.into_iter().next()? + } else { + metadata_project_location(&metadata, project_root)? + }; let document: Value = match serde_json::from_str(&changed.contents) { Ok(document) => document, @@ -180,7 +201,10 @@ impl TranscriptSource for ClineLikeSource { } } - let project = project_root.to_string_lossy().to_string(); + let project = self.user_registered_roots.as_ref().map_or_else( + || project_root.to_string_lossy().to_string(), + |_| "user".to_string(), + ); let draft = SessionDraft { session_id: task_id.to_string(), project_key: project.clone(), diff --git a/src/sessions/codex.rs b/src/sessions/codex.rs index ff487ed66..768c31698 100644 --- a/src/sessions/codex.rs +++ b/src/sessions/codex.rs @@ -93,6 +93,12 @@ struct CodexMeta { pub struct CodexSource { sessions_dir: PathBuf, archived_sessions_dir: PathBuf, + user_scope: Option, +} + +struct UserCodexScope { + session_id: Option, + registered_roots: Vec, } impl CodexSource { @@ -109,8 +115,23 @@ impl CodexSource { Self { sessions_dir: codex_home.join("sessions"), archived_sessions_dir: codex_home.join("archived_sessions"), + user_scope: None, } } + + /// Restricts ingestion to sessions that cannot be attributed to a registered project. + #[must_use] + pub fn for_user_scope( + mut self, + session_id: Option, + registered_roots: Vec, + ) -> Self { + self.user_scope = Some(UserCodexScope { + session_id, + registered_roots, + }); + self + } } impl TranscriptSource for CodexSource { @@ -140,7 +161,19 @@ impl TranscriptSource for CodexSource { // `session_meta` (line 1) is authoritative for cwd + session id; without // it we cannot safely attribute the rollout to a project, so skip. let meta = session_meta(path)?; - if !path_belongs_to_project(&meta.cwd, project_root) { + if let Some(scope) = &self.user_scope { + if scope + .session_id + .as_deref() + .is_some_and(|session_id| session_id != meta.session_id) + || scope + .registered_roots + .iter() + .any(|root| path_belongs_to_project(&meta.cwd, root)) + { + return None; + } + } else if !path_belongs_to_project(&meta.cwd, project_root) { return None; } @@ -305,13 +338,26 @@ impl TranscriptSource for CodexSource { messages.push(message); } - let project = project_root.to_string_lossy().to_string(); + if let Some(scope) = &self.user_scope { + messages.retain(|message| user_scope_message_allowed(message, &scope.registered_roots)); + } + + let project = self.user_scope.as_ref().map_or_else( + || project_root.to_string_lossy().to_string(), + |_| "user".to_string(), + ); let draft = SessionDraft { session_id: meta.session_id.clone(), project_key: project.clone(), project_path: project, title: title_from_messages(&messages), - metadata_json: context::session_metadata_json(&meta, Some(&structured.summary)), + // The summary is session-wide and may include evidence observed + // after Codex changed cwd into a registered project. User scope + // stores only the filtered message rows, never that mixed summary. + metadata_json: context::session_metadata_json( + &meta, + self.user_scope.is_none().then_some(&structured.summary), + ), parent_session_id: meta.parent_session_id.clone(), is_subagent: meta.is_subagent, agent_id: meta.agent_id.clone(), @@ -326,6 +372,27 @@ impl TranscriptSource for CodexSource { } } +fn user_scope_message_allowed( + message: &SessionMessageRecord, + registered_roots: &[PathBuf], +) -> bool { + let turn_cwd = message + .metadata_json + .as_deref() + .and_then(|metadata| serde_json::from_str::(metadata).ok()) + .and_then(|metadata| { + metadata + .get("codex_turn_cwd") + .and_then(Value::as_str) + .map(PathBuf::from) + }); + turn_cwd.is_none_or(|cwd| { + !registered_roots + .iter() + .any(|root| path_belongs_to_project(&cwd, root)) + }) +} + /// Read the leading `session_meta` line of a rollout for cwd/session-id/model. fn session_meta(path: &Path) -> Option { let file = std::fs::File::open(path).ok()?; diff --git a/src/sessions/cursor.rs b/src/sessions/cursor.rs index 27720259c..9cc489fac 100644 --- a/src/sessions/cursor.rs +++ b/src/sessions/cursor.rs @@ -130,6 +130,7 @@ struct CursorEventSource { event: Value, transcript_path: PathBuf, include_subagents: bool, + user_scope: bool, } impl TranscriptSource for CursorEventSource { @@ -157,7 +158,14 @@ impl TranscriptSource for CursorEventSource { max_new_bytes: Option, ) -> Option { let parent_session_id = event_session_id(&self.event, &self.transcript_path); - parse_cursor_jsonl(&self.event, &parent_session_id, path, prev, max_new_bytes) + parse_cursor_jsonl( + &self.event, + &parent_session_id, + path, + prev, + max_new_bytes, + self.user_scope, + ) } } @@ -173,6 +181,7 @@ fn parse_cursor_jsonl( path: &Path, prev: StoredCursor, max_new_bytes: Option, + user_scope: bool, ) -> Option { let new = stream_new_jsonl(path, prev, max_new_bytes)?; let subagent = cursor_subagent_identity(path, parent_session_id); @@ -226,7 +235,11 @@ fn parse_cursor_jsonl( parent_tool_use_id: None, } } else { - let (project_key, project_path) = event_project(event); + let (project_key, project_path) = if user_scope { + ("user".to_string(), "user".to_string()) + } else { + event_project(event) + }; let (draft_parent_session_id, agent_id) = subagent .map_or((None, None), |(_session_id, agent_id)| { (Some(parent_session_id.to_string()), Some(agent_id)) @@ -304,6 +317,7 @@ pub async fn ingest_cursor_transcript_event_capped( event, transcript_path, include_subagents: true, + user_scope: false, }; let stats = ingest_source(db, &source, &project_root, max_new_bytes).await; CursorTranscriptIngestStats { @@ -312,6 +326,75 @@ pub async fn ingest_cursor_transcript_event_capped( } } +pub async fn ingest_cursor_user_transcript_event_capped( + event_json: &str, + db: &GlobalDb, + max_new_bytes: Option, +) -> CursorTranscriptIngestStats { + ingest_cursor_user_transcript_event_capped_with_registered_roots( + event_json, + db, + max_new_bytes, + &[], + ) + .await +} + +/// User-scope live ingest guarded by a registry snapshot. The unguarded +/// wrapper remains useful for isolated parsing without a profile registry. +pub async fn ingest_cursor_user_transcript_event_capped_with_registered_roots( + event_json: &str, + db: &GlobalDb, + max_new_bytes: Option, + registered_roots: &[PathBuf], +) -> CursorTranscriptIngestStats { + let Ok(event) = serde_json::from_str::(event_json) else { + return CursorTranscriptIngestStats::default(); + }; + let Some(transcript_path) = event + .get("transcript_path") + .and_then(Value::as_str) + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + else { + return CursorTranscriptIngestStats::default(); + }; + let registered_slugs = registered_roots + .iter() + .filter_map(|root| cursor_project_slug(root)) + .collect::>(); + if cursor_transcript_project_slug(&transcript_path) + .is_some_and(|slug| registered_slugs.contains(slug)) + { + return CursorTranscriptIngestStats::default(); + } + let placeholder = transcript_path + .parent() + .map_or_else(|| transcript_path.clone(), Path::to_path_buf); + let source = CursorEventSource { + event, + transcript_path, + include_subagents: true, + user_scope: true, + }; + let stats = ingest_source(db, &source, &placeholder, max_new_bytes).await; + CursorTranscriptIngestStats { + sessions_upserted: stats.sessions_upserted, + messages_upserted: stats.messages_upserted, + } +} + +fn cursor_transcript_project_slug(path: &Path) -> Option<&str> { + let components = path.components().collect::>(); + let transcripts = components + .iter() + .position(|component| component.as_os_str() == "agent-transcripts")?; + components + .get(transcripts.checked_sub(1)?)? + .as_os_str() + .to_str() +} + /// `agent-transcripts//subagents/.jsonl` is the deepest layout /// Cursor writes; a little headroom tolerates future nesting. const MAX_SWEEP_SCAN_DEPTH: u8 = 4; @@ -335,6 +418,7 @@ pub struct CursorSweepSource { /// ([`crate::sessions::cursor_composer`]). Transcript files whose stem is /// one of these are skipped so the two Cursor sources never double-ingest. skip_session_ids: std::collections::HashSet, + user_registered_slugs: Option>, } impl CursorSweepSource { @@ -350,6 +434,7 @@ impl CursorSweepSource { Self { cursor_projects_dir: home.join(".cursor").join("projects"), skip_session_ids: std::collections::HashSet::new(), + user_registered_slugs: None, } } @@ -360,6 +445,17 @@ impl CursorSweepSource { self.skip_session_ids = ids; self } + + #[must_use] + pub fn for_user_scope(mut self, registered_roots: &[PathBuf]) -> Self { + self.user_registered_slugs = Some( + registered_roots + .iter() + .filter_map(|root| cursor_project_slug(root)) + .collect(), + ); + self + } } impl TranscriptSource for CursorSweepSource { @@ -368,6 +464,28 @@ impl TranscriptSource for CursorSweepSource { } fn transcript_paths(&self, project_root: &Path) -> Vec { + if let Some(registered_slugs) = &self.user_registered_slugs { + let Ok(entries) = std::fs::read_dir(&self.cursor_projects_dir) else { + return Vec::new(); + }; + return entries + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|slug| !registered_slugs.contains(slug)) + }) + .flat_map(|entry| { + collect_files_with_ext( + &entry.path().join("agent-transcripts"), + "jsonl", + MAX_SWEEP_SCAN_DEPTH, + ) + }) + .collect(); + } let Some(slug) = cursor_project_slug(project_root) else { return Vec::new(); }; @@ -441,12 +559,27 @@ impl TranscriptSource for CursorSweepSource { // the same session id a live hook would carry (Cursor names parent // transcripts `.jsonl`) and the project root as `cwd` so // `event_project` scopes the session exactly like the hook path. - let event = serde_json::json!({ - "session_id": parent_session_id, - "cwd": project_root.to_string_lossy(), - "tracedecay_location_provenance": "sweep_project_root", - }); - parse_cursor_jsonl(&event, &parent_session_id, path, prev, max_new_bytes) + let user_scope = self.user_registered_slugs.is_some(); + let event = if user_scope { + serde_json::json!({ + "session_id": parent_session_id, + "tracedecay_location_provenance": "user_sweep", + }) + } else { + serde_json::json!({ + "session_id": parent_session_id, + "cwd": project_root.to_string_lossy(), + "tracedecay_location_provenance": "sweep_project_root", + }) + }; + parse_cursor_jsonl( + &event, + &parent_session_id, + path, + prev, + max_new_bytes, + user_scope, + ) } } diff --git a/src/sessions/cursor_composer.rs b/src/sessions/cursor_composer.rs index 7ba582504..3b527baa1 100644 --- a/src/sessions/cursor_composer.rs +++ b/src/sessions/cursor_composer.rs @@ -123,13 +123,36 @@ impl CursorComposerSource { let mut workspace_paths: HashMap = HashMap::new(); self.ingest_state_vscdb( db, - project_root, + Some(project_root), + &[], envelope_cap, &mut outcome, &mut workspace_paths, ) .await; - self.ingest_chat_store_dbs(db, project_root, &workspace_paths, &mut outcome) + self.ingest_chat_store_dbs(db, Some(project_root), &[], &workspace_paths, &mut outcome) + .await; + outcome + } + + pub async fn ingest_user( + &self, + db: &GlobalDb, + registered_roots: &[PathBuf], + envelope_cap: usize, + ) -> CursorComposerSweepOutcome { + let mut outcome = CursorComposerSweepOutcome::default(); + let mut workspace_paths = HashMap::new(); + self.ingest_state_vscdb( + db, + None, + registered_roots, + envelope_cap, + &mut outcome, + &mut workspace_paths, + ) + .await; + self.ingest_chat_store_dbs(db, None, registered_roots, &workspace_paths, &mut outcome) .await; outcome } @@ -137,7 +160,8 @@ impl CursorComposerSource { async fn ingest_state_vscdb( &self, db: &GlobalDb, - project_root: &Path, + project_root: Option<&Path>, + registered_roots: &[PathBuf], envelope_cap: usize, outcome: &mut CursorComposerSweepOutcome, workspace_paths: &mut HashMap, @@ -184,9 +208,23 @@ impl CursorComposerSource { .entry(ws_hash) .or_insert_with(|| project.path.clone()); } - if !path_belongs_to_project(Path::new(&project.path), project_root) { - continue; - } + let selected_project = match project_root { + Some(root) if path_belongs_to_project(Path::new(&project.path), root) => { + ComposerProject { + path: project.path.clone(), + } + } + Some(_) => continue, + None if registered_roots + .iter() + .any(|root| path_belongs_to_project(Path::new(&project.path), root)) => + { + continue; + } + None => ComposerProject { + path: "user".to_string(), + }, + }; // Own this session for JSONL dedupe regardless of the per-pass cap. outcome.owned_session_ids.insert(composer_id.to_string()); @@ -214,7 +252,7 @@ impl CursorComposerSource { if messages.is_empty() { continue; } - let session = composer_session(composer_id, &envelope, &project, &messages); + let session = composer_session(composer_id, &envelope, &selected_project, &messages); let advanced = ParseOffset { byte_offset: watermark, mtime: last_updated, @@ -269,7 +307,8 @@ impl CursorComposerSource { async fn ingest_chat_store_dbs( &self, db: &GlobalDb, - project_root: &Path, + project_root: Option<&Path>, + registered_roots: &[PathBuf], workspace_paths: &HashMap, outcome: &mut CursorComposerSweepOutcome, ) { @@ -282,11 +321,19 @@ impl CursorComposerSource { } let ws_hash = ws_entry.file_name().to_string_lossy().to_string(); // Scope by ws-hash -> project mapping harvested from the envelopes. - let project_path = match workspace_paths.get(&ws_hash) { - Some(path) if path_belongs_to_project(Path::new(path), project_root) => { + let project_path = match (workspace_paths.get(&ws_hash), project_root) { + (Some(path), Some(root)) if path_belongs_to_project(Path::new(path), root) => { path.clone() } - _ => continue, + (Some(_), Some(_)) | (None, _) => continue, + (Some(path), None) + if registered_roots + .iter() + .any(|root| path_belongs_to_project(Path::new(path), root)) => + { + continue; + } + (Some(_), None) => "user".to_string(), }; let Ok(agent_entries) = std::fs::read_dir(ws_entry.path()) else { continue; diff --git a/src/sessions/hermes.rs b/src/sessions/hermes.rs index 01b684e50..9404239f0 100644 --- a/src/sessions/hermes.rs +++ b/src/sessions/hermes.rs @@ -6,6 +6,10 @@ //! `~/.hermes/profiles/` for named profiles. A profile maps to exactly //! one ingest target only when provenance proves a real code project: a //! legacy `plugins.tracedecay.project_root` pin or the session row's `cwd`. +//! For projectless/gateway sessions, one completed turn may instead prove its +//! project through structured tool-call routing (`project_path`, +//! `project_root`, or a nested project selector). Only that turn is projected; +//! an entire long-running multi-project chat is never assigned by inference. //! Profile directories are never `TraceDecay` project identities. //! //! Unlike the file-based adapters this source holds *many* sessions in one @@ -49,6 +53,7 @@ const HERMES_LOCATION_KEYS: TranscriptLocationMetadataKeys = TranscriptLocationM /// the cursor advance after every committed chunk, so an interrupted sweep /// resumes where it stopped. const CHUNK_ROWS: usize = 2000; +const CORRELATION_CURSOR_VERSION: &str = "turn-project-v2"; /// Ingests Hermes sessions proven to belong to `project_root` into `db`. /// @@ -83,6 +88,37 @@ pub async fn ingest_homes( stats } +/// Ingests historical Hermes turns that have no registered-project +/// attribution into the profile-level user session store. +pub async fn ingest_user_sessions( + db: &GlobalDb, + registered_roots: &[PathBuf], +) -> TranscriptIngestStats { + let homes = crate::sessions::home_dir() + .map(|home| vec![home.join(".hermes")]) + .unwrap_or_default(); + ingest_user_homes(db, &homes, registered_roots).await +} + +pub async fn ingest_user_homes( + db: &GlobalDb, + hermes_homes: &[PathBuf], + registered_roots: &[PathBuf], +) -> TranscriptIngestStats { + let mut stats = TranscriptIngestStats::default(); + for source in all_profile_sources(hermes_homes) { + match try_ingest_user_state_db(db, &source, registered_roots).await { + Ok(source_stats) => stats = stats.merge(source_stats), + Err(error) => tracing::debug!( + state_db = %source.state_db.display(), + error, + "skipping projectless Hermes transcript source" + ), + } + } + stats +} + /// Strict one-time import for a legacy profile whose project pin was already /// resolved by the migration layer. Unlike the normal catch-up sweep, any /// open/query/write failure is returned so callers retain the pin and source. @@ -131,6 +167,37 @@ struct HermesProfileSource { legacy_project_pin: Option, } +fn all_profile_sources(hermes_homes: &[PathBuf]) -> Vec { + let mut out = Vec::new(); + let mut seen = BTreeSet::new(); + for home in hermes_homes { + let mut profiles = vec![(home.clone(), None)]; + if let Ok(entries) = std::fs::read_dir(home.join("profiles")) { + profiles.extend(entries.filter_map(|entry| { + let path = entry.ok()?.path(); + path.is_dir().then(|| { + let name = path.file_name()?.to_str()?.to_string(); + Some((path, Some(name))) + })? + })); + } + for (profile_dir, profile) in profiles { + let state_db = profile_dir.join("state.db"); + if state_db.is_file() && seen.insert(state_db.clone()) { + out.push(HermesProfileSource { + state_db, + profile, + legacy_project_pin: read_config_pinned_project_root( + &profile_dir.join("config.yaml"), + ) + .map(PathBuf::from), + }); + } + } + } + out +} + fn candidate_state_dbs(hermes_homes: &[PathBuf], project_root: &Path) -> Vec { let mut out = Vec::new(); let mut seen = BTreeSet::new(); @@ -274,8 +341,9 @@ async fn try_ingest_state_db( let state_db = &source.state_db; let conn = open_read_only_strict(state_db).await?; let path_str = state_db.to_string_lossy().to_string(); + let cursor_path = format!("{path_str}#{CORRELATION_CURSOR_VERSION}"); let mut cursor = { - let prev = db.get_parse_offset(&path_str).await.unwrap_or_default(); + let prev = db.get_parse_offset(&cursor_path).await.unwrap_or_default(); StoredCursor { position: prev.byte_offset, mtime: prev.mtime, @@ -308,14 +376,14 @@ async fn try_ingest_state_db( if batches.is_empty() { // Only non-conversation rows (e.g. `session_meta`) — still advance // the cursor so the next sweep does not re-read them. - db.set_parse_offset(&path_str, offset).await; + db.set_parse_offset(&cursor_path, offset).await; } else { let message_count: u64 = batches .iter() .map(|batch| batch.messages.len() as u64) .sum(); if !db - .upsert_transcript_projection_batches(&batches, &path_str, offset) + .upsert_transcript_projection_batches(&batches, &cursor_path, offset) .await { return Err(format!( @@ -336,6 +404,71 @@ async fn try_ingest_state_db( } } +async fn try_ingest_user_state_db( + db: &GlobalDb, + source: &HermesProfileSource, + registered_roots: &[PathBuf], +) -> Result { + let mut stats = TranscriptIngestStats::default(); + let state_db = &source.state_db; + let conn = open_read_only_strict(state_db).await?; + let path_str = state_db.to_string_lossy().to_string(); + let cursor_path = format!("{path_str}#user-turn-v1"); + let mut cursor = { + let prev = db.get_parse_offset(&cursor_path).await.unwrap_or_default(); + StoredCursor { + position: prev.byte_offset, + mtime: prev.mtime, + file_id: prev.file_id, + } + }; + let select_sql = select_new_messages_sql( + &message_columns(&conn).await, + &table_columns(&conn, "sessions").await, + ); + loop { + let new = read_new_rows_strict(&conn, &select_sql, cursor).await?; + let row_count = new.items.len(); + if row_count == 0 { + return Ok(stats); + } + let next_cursor = StoredCursor { + position: new.new_cursor.position, + mtime: file_mtime_secs(state_db), + file_id: 0, + }; + let offset = ParseOffset { + byte_offset: next_cursor.position, + mtime: next_cursor.mtime, + file_id: 0, + }; + let batches = build_user_batches(db, &new.items, &path_str, source, registered_roots).await; + if batches.is_empty() { + db.set_parse_offset(&cursor_path, offset).await; + } else { + let message_count = batches + .iter() + .map(|batch| batch.messages.len() as u64) + .sum::(); + if !db + .upsert_transcript_projection_batches(&batches, &cursor_path, offset) + .await + { + return Err(format!( + "could not persist projectless Hermes rows from '{}'", + state_db.display() + )); + } + stats.messages_upserted = stats.messages_upserted.saturating_add(message_count); + stats.sessions_upserted = stats.sessions_upserted.saturating_add(batches.len() as u64); + } + cursor = next_cursor; + if row_count < CHUNK_ROWS { + return Ok(stats); + } + } +} + /// Opens a Hermes `state.db` strictly read-only so the sweep can never write /// to (or create) another agent's live store. async fn open_read_only_strict(path: &Path) -> Result { @@ -423,6 +556,7 @@ async fn build_batches( ) -> Vec { let mut order = Vec::new(); let mut by_session: HashMap = HashMap::new(); + let turn_locations = turn_project_locations(rows, project_root, source); for row in rows { if row.role == "session_meta" || row.role.is_empty() { @@ -433,7 +567,7 @@ async fn build_batches( // them as live history would misrepresent the conversation. continue; } - let Some(location) = session_location(row, project_root, source) else { + let Some(location) = turn_locations.get(&row.id) else { continue; }; let Some(message) = message_from_row(row, state_db_path, source, &location) else { @@ -460,6 +594,235 @@ async fn build_batches( batches } +async fn build_user_batches( + db: &GlobalDb, + rows: &[HermesRow], + state_db_path: &str, + source: &HermesProfileSource, + registered_roots: &[PathBuf], +) -> Vec { + let mut order = Vec::new(); + let mut by_session: HashMap = HashMap::new(); + let locations = user_turn_locations(rows, source, registered_roots); + for row in rows { + if row.role == "session_meta" || row.role.is_empty() || row.active == 0 { + continue; + } + let Some(location) = locations.get(&row.id) else { + continue; + }; + let Some(message) = message_from_row(row, state_db_path, source, location) else { + continue; + }; + let batch = by_session.entry(row.session_id.clone()).or_insert_with(|| { + order.push(row.session_id.clone()); + TranscriptBatch { + session: session_from_row(row, state_db_path, Path::new("user"), source, location), + messages: Vec::new(), + } + }); + batch.messages.push(message); + } + let mut batches = Vec::with_capacity(order.len()); + for session_id in order { + if let Some(mut batch) = by_session.remove(&session_id) { + merge_with_existing(db, &mut batch).await; + batches.push(batch); + } + } + batches +} + +fn user_turn_locations( + rows: &[HermesRow], + source: &HermesProfileSource, + registered_roots: &[PathBuf], +) -> HashMap { + let mut by_session: HashMap<&str, Vec<&HermesRow>> = HashMap::new(); + for row in rows { + by_session.entry(&row.session_id).or_default().push(row); + } + let belongs_to_registered = |path: &Path| { + registered_roots + .iter() + .any(|root| path_belongs_to_project(path, root)) + }; + let mut locations = HashMap::new(); + for session_rows in by_session.into_values() { + if source + .legacy_project_pin + .as_deref() + .is_some_and(|pin| belongs_to_registered(pin)) + { + continue; + } + let recorded_cwd = session_rows.iter().find_map(|row| { + let cwd = PathBuf::from(row.session_cwd.as_deref()?.trim()); + cwd.is_absolute().then_some(cwd) + }); + let fallback = source + .legacy_project_pin + .clone() + .or_else(|| { + recorded_cwd + .as_ref() + .filter(|cwd| !belongs_to_registered(cwd)) + .cloned() + }) + // Only genuinely locationless sessions fall back to the profile + // directory. A registered cwd must never be relabeled as user. + .or_else(|| { + recorded_cwd + .is_none() + .then(|| source.state_db.parent().map(Path::to_path_buf)) + .flatten() + }); + let mut turn = Vec::new(); + for row in session_rows { + if row.role == "user" && !turn.is_empty() { + assign_user_turn(&turn, fallback.as_deref(), registered_roots, &mut locations); + turn.clear(); + } + turn.push(row); + } + assign_user_turn(&turn, fallback.as_deref(), registered_roots, &mut locations); + } + locations +} + +fn assign_user_turn( + rows: &[&HermesRow], + fallback: Option<&Path>, + registered_roots: &[PathBuf], + locations: &mut HashMap, +) { + let explicit = rows + .iter() + .flat_map(|row| structured_tool_project_paths(row)) + .collect::>(); + if explicit.iter().any(|path| { + registered_roots + .iter() + .any(|root| path_belongs_to_project(path, root)) + }) { + return; + } + let cwd = explicit + .last() + .cloned() + .or_else(|| fallback.map(Path::to_path_buf)); + let Some(cwd) = cwd else { + return; + }; + let location = HermesSessionLocation { + cwd, + provenance: "user_scope", + }; + for row in rows { + locations.insert(row.id, location.clone()); + } +} + +fn turn_project_locations( + rows: &[HermesRow], + project_root: &Path, + source: &HermesProfileSource, +) -> HashMap { + let mut by_session: HashMap<&str, Vec<&HermesRow>> = HashMap::new(); + for row in rows { + by_session.entry(&row.session_id).or_default().push(row); + } + let mut locations = HashMap::new(); + for session_rows in by_session.into_values() { + let fallback = session_rows + .iter() + .find_map(|row| session_location(row, project_root, source)); + let mut turn = Vec::new(); + for row in session_rows { + if row.role == "user" && !turn.is_empty() { + assign_turn_location(&turn, project_root, fallback.as_ref(), &mut locations); + turn.clear(); + } + turn.push(row); + } + assign_turn_location(&turn, project_root, fallback.as_ref(), &mut locations); + } + locations +} + +fn assign_turn_location( + rows: &[&HermesRow], + project_root: &Path, + fallback: Option<&HermesSessionLocation>, + locations: &mut HashMap, +) { + let explicit_paths = rows + .iter() + .rev() + .flat_map(|row| structured_tool_project_paths(row)) + .collect::>(); + let location = if explicit_paths.is_empty() { + fallback.cloned() + } else { + explicit_paths + .into_iter() + .find(|path| path_belongs_to_project(path, project_root)) + .map(|cwd| HermesSessionLocation { + cwd, + provenance: "tool_project_path", + }) + }; + let Some(location) = location else { + return; + }; + for row in rows { + locations.insert(row.id, location.clone()); + } +} + +fn structured_tool_project_paths(row: &HermesRow) -> Vec { + let Some(raw) = row.tool_calls.as_deref() else { + return Vec::new(); + }; + let Ok(calls) = serde_json::from_str::(raw) else { + return Vec::new(); + }; + let mut paths = Vec::new(); + let calls = calls.as_array().map(Vec::as_slice).unwrap_or(&[]); + for call in calls { + let arguments = call + .pointer("/function/arguments") + .or_else(|| call.get("arguments")); + let parsed; + let arguments = match arguments { + Some(Value::String(raw)) => { + parsed = serde_json::from_str::(raw).unwrap_or(Value::Null); + &parsed + } + Some(value) => value, + None => continue, + }; + for value in [ + arguments.get("project_root"), + arguments.get("project_path"), + arguments.pointer("/project_selector/path"), + arguments.get("cwd"), + arguments.get("workdir"), + ] + .into_iter() + .flatten() + .filter_map(Value::as_str) + { + let path = PathBuf::from(value); + if path.is_absolute() { + paths.push(path); + } + } + } + paths +} + +#[derive(Clone)] struct HermesSessionLocation { cwd: PathBuf, provenance: &'static str, diff --git a/src/sessions/kiro.rs b/src/sessions/kiro.rs index 32f392c89..fd04b6640 100644 --- a/src/sessions/kiro.rs +++ b/src/sessions/kiro.rs @@ -48,6 +48,7 @@ const MAX_WORKSPACE_DIRS: usize = 256; pub struct KiroSource { agent_dir: PathBuf, workspace_storage_dir: PathBuf, + user_registered_roots: Option>, } impl KiroSource { @@ -64,8 +65,15 @@ impl KiroSource { Self { agent_dir: data_dir.join("User/globalStorage/kiro.kiroagent"), workspace_storage_dir: data_dir.join("User/workspaceStorage"), + user_registered_roots: None, } } + + #[must_use] + pub fn for_user_scope(mut self, registered_roots: Vec) -> Self { + self.user_registered_roots = Some(registered_roots); + self + } } impl TranscriptSource for KiroSource { @@ -74,6 +82,18 @@ impl TranscriptSource for KiroSource { } fn transcript_paths(&self, project_root: &Path) -> Vec { + if let Some(registered_roots) = &self.user_registered_roots { + let mut out = collect_user_workspace_session_files( + &self.agent_dir.join("workspace-sessions"), + registered_roots, + ); + out.extend(collect_user_agent_storage_files( + &self.agent_dir, + &self.workspace_storage_dir, + registered_roots, + )); + return out; + } let mut out = Vec::new(); out.extend(collect_workspace_session_files( &self.agent_dir.join("workspace-sessions"), @@ -95,7 +115,14 @@ impl TranscriptSource for KiroSource { _max_new_bytes: Option, ) -> Option { let location_cwd = transcript_location_path(path, &self.workspace_storage_dir)?; - if !path_belongs_to_project(&location_cwd, project_root) { + if let Some(roots) = &self.user_registered_roots { + if roots + .iter() + .any(|root| path_belongs_to_project(&location_cwd, root)) + { + return None; + } + } else if !path_belongs_to_project(&location_cwd, project_root) { return None; } @@ -133,7 +160,10 @@ impl TranscriptSource for KiroSource { )); } - let project = project_root.to_string_lossy().to_string(); + let project = self.user_registered_roots.as_ref().map_or_else( + || project_root.to_string_lossy().to_string(), + |_| "user".to_string(), + ); let draft = SessionDraft { session_id: session_id.clone(), project_key: project.clone(), @@ -154,6 +184,55 @@ impl TranscriptSource for KiroSource { } } +fn collect_user_workspace_session_files( + sessions_root: &Path, + registered_roots: &[PathBuf], +) -> Vec { + let Ok(entries) = std::fs::read_dir(sessions_root) else { + return Vec::new(); + }; + let mut workspace_dirs = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + if !path.is_dir() { + return None; + } + let workspace = + decode_workspace_sessions_dir(entry.file_name().to_string_lossy().as_ref())?; + if registered_roots + .iter() + .any(|root| path_belongs_to_project(&workspace, root)) + { + return None; + } + let mtime = entry + .metadata() + .ok() + .and_then(|meta| meta.modified().ok()) + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map_or(0, |duration| duration.as_secs()); + Some((mtime, path)) + }) + .collect::>(); + workspace_dirs.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime)); + workspace_dirs.truncate(MAX_WORKSPACE_DIRS); + + let mut out = Vec::new(); + for (_, workspace_dir) in workspace_dirs { + let Ok(entries) = std::fs::read_dir(workspace_dir) else { + continue; + }; + out.extend( + entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_file() && path.extension().is_none_or(|ext| ext == "json")), + ); + } + out +} + /// Incrementally ingests Kiro transcripts for `project_root` into `db`. pub async fn ingest_kiro_for_project( db: &crate::global_db::GlobalDb, @@ -273,6 +352,58 @@ fn collect_agent_storage_files( out } +fn collect_user_agent_storage_files( + agent_dir: &Path, + workspace_storage_dir: &Path, + registered_roots: &[PathBuf], +) -> Vec { + let Ok(entries) = std::fs::read_dir(agent_dir) else { + return Vec::new(); + }; + let mut workspace_dirs = entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name(); + let name = name.to_string_lossy(); + let path = entry.path(); + if name == "workspace-sessions" + || name.starts_with('.') + || !path.is_dir() + || name.len() != 32 + { + return None; + } + let workspace = workspace_path_from_hash(workspace_storage_dir, &name)?; + if registered_roots + .iter() + .any(|root| path_belongs_to_project(&workspace, root)) + { + return None; + } + let mtime = entry + .metadata() + .ok() + .and_then(|meta| meta.modified().ok()) + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map_or(0, |duration| duration.as_secs()); + Some((mtime, path)) + }) + .collect::>(); + workspace_dirs.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime)); + workspace_dirs.truncate(MAX_WORKSPACE_DIRS); + + let mut out = Vec::new(); + for (_, workspace_dir) in workspace_dirs { + out.extend( + collect_files_with_ext(&workspace_dir, "chat", MAX_SCAN_DEPTH) + .into_iter() + .filter(|path| path.is_file()), + ); + collect_extensionless_execution_files(&workspace_dir, MAX_SCAN_DEPTH, &mut out); + } + out +} + fn collect_extensionless_execution_files(dir: &Path, max_depth: u8, out: &mut Vec) { if max_depth == 0 { return; diff --git a/src/sessions/mod.rs b/src/sessions/mod.rs index 04d554522..6639f8528 100644 --- a/src/sessions/mod.rs +++ b/src/sessions/mod.rs @@ -33,6 +33,142 @@ pub mod workflow_state; pub use providers::{ProviderScope, SessionProvider}; pub use shared::SESSION_TRANSCRIPT_STALLED_INGEST_WARNING_BYTES; +pub const USER_SESSIONS_DB_FILENAME: &str = "user-sessions.db"; + +pub fn user_sessions_db_path(profile_root: &Path) -> PathBuf { + profile_root.join(USER_SESSIONS_DB_FILENAME) +} + +pub async fn open_user_session_db(profile_root: &Path) -> Option { + GlobalDb::open_at(&user_sessions_db_path(profile_root)).await +} + +/// All registry paths that may identify project-owned transcript evidence. +pub async fn registered_project_roots() -> Vec { + try_registered_project_roots().await.unwrap_or_default() +} + +/// Returns `None` when the registry cannot be opened. User-scope ingestion +/// must fail closed in that case: an empty root set is valid for a fresh +/// profile, while an unavailable registry cannot safely prove that evidence +/// is projectless. +pub async fn try_registered_project_roots() -> Option> { + let global = GlobalDb::open().await?; + let mut roots = global + .list_project_paths() + .await + .into_iter() + .map(PathBuf::from) + .collect::>(); + roots.sort(); + roots.dedup(); + Some(roots) +} + +/// Ingests Codex sessions that have no registered-project attribution into the +/// profile user session store. `session_id` bounds live hook work to one host +/// session; `None` performs historical backfill. +pub async fn ingest_user_codex_sessions(session_id: Option) -> TranscriptIngestStats { + let Ok(profile_root) = crate::storage::default_profile_root() else { + return TranscriptIngestStats::default(); + }; + let Some(db) = open_user_session_db(&profile_root).await else { + return TranscriptIngestStats::default(); + }; + let Some(source) = codex::CodexSource::new() else { + return TranscriptIngestStats::default(); + }; + let Some(registered_roots) = try_registered_project_roots().await else { + return TranscriptIngestStats::default(); + }; + let source = source.for_user_scope(session_id, registered_roots); + ingest_source(&db, &source, &profile_root, None).await +} + +pub async fn ingest_user_cursor_sessions() -> TranscriptIngestStats { + let Ok(profile_root) = crate::storage::default_profile_root() else { + return TranscriptIngestStats::default(); + }; + let Some(db) = open_user_session_db(&profile_root).await else { + return TranscriptIngestStats::default(); + }; + let Some(registered_roots) = try_registered_project_roots().await else { + return TranscriptIngestStats::default(); + }; + let (composer_stats, owned) = if let Some(source) = cursor_composer::CursorComposerSource::new() + { + let outcome = source + .ingest_user( + &db, + ®istered_roots, + cursor_composer::DEFAULT_COMPOSER_ENVELOPE_CAP, + ) + .await; + ( + TranscriptIngestStats { + sessions_upserted: outcome.sessions_upserted, + messages_upserted: outcome.messages_upserted, + }, + outcome.owned_session_ids, + ) + } else { + ( + TranscriptIngestStats::default(), + std::collections::HashSet::default(), + ) + }; + let Some(source) = cursor::CursorSweepSource::new() else { + return composer_stats; + }; + let source = source + .with_skip_session_ids(owned) + .for_user_scope(®istered_roots); + composer_stats.merge(ingest_source(&db, &source, &profile_root, None).await) +} + +pub async fn ingest_user_global_sources() -> TranscriptIngestStats { + let codex = ingest_user_codex_sessions(None).await; + let cursor = ingest_user_cursor_sessions().await; + let stats = codex.merge(cursor); + let Ok(profile_root) = crate::storage::default_profile_root() else { + return stats; + }; + let Some(db) = open_user_session_db(&profile_root).await else { + return stats; + }; + let Some(roots) = try_registered_project_roots().await else { + return stats; + }; + let hermes = hermes::ingest_user_sessions(&db, &roots).await; + let mut stats = stats.merge(hermes); + let claude = claude::ingest_user_sessions(&db, &profile_root, None, roots.clone()).await; + stats = stats.merge(claude); + let mut sources: Vec> = Vec::new(); + if let Some(source) = vibe::VibeSource::new() { + sources.push(Box::new(source.for_user_scope(roots.clone()))); + } + if let Some(source) = cline_like::ClineLikeSource::cline() { + sources.push(Box::new(source.for_user_scope(roots.clone()))); + } + if let Some(source) = cline_like::ClineLikeSource::roo_code() { + sources.push(Box::new(source.for_user_scope(roots.clone()))); + } + if let Some(source) = cline_like::ClineLikeSource::kilo() { + sources.push(Box::new(source.for_user_scope(roots.clone()))); + } + if let Some(source) = kiro::KiroSource::new() { + sources.push(Box::new(source.for_user_scope(roots))); + } + for source in sources { + let source_stats = ingest_source(&db, source.as_ref(), &profile_root, None).await; + stats = stats.merge(source_stats); + } + if stats.messages_upserted > 0 { + crate::hooks::schedule_user_session_review("all", None); + } + stats +} + const FILE_TRANSCRIPT_PROVIDERS: &[SessionProvider] = &[ SessionProvider::Claude, SessionProvider::Codex, @@ -67,6 +203,9 @@ pub async fn ingest_global_sources_for_provider( project_root: &Path, provider: Option, ) -> TranscriptIngestStats { + // Keep the profile-level projectless history current alongside every + // project sweep. Its independent DB cursors make repeated calls no-ops. + let _ = ingest_user_global_sources().await; let mut sources: Vec> = Vec::new(); match provider { None => { diff --git a/src/sessions/vibe.rs b/src/sessions/vibe.rs index b141b6d6c..9bae017f4 100644 --- a/src/sessions/vibe.rs +++ b/src/sessions/vibe.rs @@ -12,6 +12,7 @@ //! `meta.json` to `project_root`. use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; use serde_json::Value; @@ -27,6 +28,8 @@ use crate::sessions::source::{ const PROVIDER: &str = "vibe"; const MAX_SCAN_DEPTH: u8 = 4; +/// Bound global history enumeration so one large Vibe profile cannot stall ingest. +const MAX_SESSION_FILES: usize = 512; const VIBE_LOCATION_KEYS: TranscriptLocationMetadataKeys = TranscriptLocationMetadataKeys::new( "vibe_session_cwd", "vibe_session_worktree", @@ -36,6 +39,7 @@ const VIBE_LOCATION_KEYS: TranscriptLocationMetadataKeys = TranscriptLocationMet /// Vibe session locator + parser. pub struct VibeSource { session_root: PathBuf, + user_registered_roots: Option>, } impl VibeSource { @@ -56,8 +60,15 @@ impl VibeSource { pub fn with_vibe_home(vibe_home: &Path) -> Self { Self { session_root: vibe_home.join("logs").join("session"), + user_registered_roots: None, } } + + #[must_use] + pub fn for_user_scope(mut self, registered_roots: Vec) -> Self { + self.user_registered_roots = Some(registered_roots); + self + } } impl TranscriptSource for VibeSource { @@ -66,12 +77,23 @@ impl TranscriptSource for VibeSource { } fn transcript_paths(&self, _project_root: &Path) -> Vec { - collect_files_with_ext(&self.session_root, "jsonl", MAX_SCAN_DEPTH) + let mut paths = collect_files_with_ext(&self.session_root, "jsonl", MAX_SCAN_DEPTH) .into_iter() .filter(|path| { path.file_name().and_then(|name| name.to_str()) == Some("messages.jsonl") }) - .collect() + .map(|path| { + let mtime = std::fs::metadata(&path) + .ok() + .and_then(|meta| meta.modified().ok()) + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map_or(0, |duration| duration.as_secs()); + (mtime, path) + }) + .collect::>(); + paths.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); + paths.truncate(MAX_SESSION_FILES); + paths.into_iter().map(|(_, path)| path).collect() } fn parse_new( @@ -83,7 +105,14 @@ impl TranscriptSource for VibeSource { ) -> Option { let meta_path = path.parent()?.join("meta.json"); let meta = read_meta(&meta_path)?; - if !path_belongs_to_project(&meta.working_directory, project_root) { + if let Some(roots) = &self.user_registered_roots { + if roots + .iter() + .any(|root| path_belongs_to_project(&meta.working_directory, root)) + { + return None; + } + } else if !path_belongs_to_project(&meta.working_directory, project_root) { return None; } @@ -95,7 +124,10 @@ impl TranscriptSource for VibeSource { } } - let project = project_root.to_string_lossy().to_string(); + let project = self.user_registered_roots.as_ref().map_or_else( + || project_root.to_string_lossy().to_string(), + |_| "user".to_string(), + ); let draft = SessionDraft { session_id: meta.session_id.clone(), project_key: project.clone(), diff --git a/src/tool_command.rs b/src/tool_command.rs index e6f451eb6..7b13faae0 100644 --- a/src/tool_command.rs +++ b/src/tool_command.rs @@ -118,6 +118,30 @@ pub(crate) async fn run( return Ok(()); } + if user_memory_dispatch(&def.name, &tool_args) { + let handshake = DaemonHandshake::for_current_client(None, None, false, false)?; + let result = tracedecay::mcp::tools::handle_user_memory_tool( + &def.name, + tool_args, + &handshake.client_identity.profile_root, + ) + .await?; + print_tool_output(&result.value, raw_json); + return Ok(()); + } + + if user_lcm_dispatch(&def.name, &tool_args) { + let handshake = DaemonHandshake::for_current_client(None, None, false, false)?; + let result = tracedecay::mcp::tools::handle_user_lcm_tool( + &def.name, + tool_args, + &handshake.client_identity.profile_root, + ) + .await?; + print_tool_output(&result.value, raw_json); + return Ok(()); + } + let explicit_project = project.or(parsed_project); dispatch_daemon_tool( DaemonToolDispatch::project_scoped(explicit_project, &def.name), @@ -128,6 +152,18 @@ pub(crate) async fn run( .await } +fn user_memory_dispatch(tool_name: &str, args: &Value) -> bool { + matches!( + tool_name, + "tracedecay_fact_store" | "tracedecay_fact_feedback" | "tracedecay_memory_status" + ) && args.get("memory_scope").and_then(Value::as_str) == Some("user") +} + +fn user_lcm_dispatch(tool_name: &str, args: &Value) -> bool { + tool_name.starts_with("tracedecay_lcm_") + && args.get("storage_scope").and_then(Value::as_str) == Some("user") +} + struct DaemonToolDispatch { project_path: Option, allow_init: bool, diff --git a/src/tool_command/tests.rs b/src/tool_command/tests.rs index bcd3208bb..0e47ce45b 100644 --- a/src/tool_command/tests.rs +++ b/src/tool_command/tests.rs @@ -395,7 +395,7 @@ fn removed_storage_routing_keys_fail_validation() { } #[test] -fn lcm_cli_help_has_no_hermes_storage_routing_flags() { +fn lcm_cli_help_exposes_scope_without_hermes_profile_routing() { for tool_name in [ "lcm_status", "lcm_load_session", @@ -410,7 +410,9 @@ fn lcm_cli_help_has_no_hermes_storage_routing_flags() { "hermes_skill_bridge", ] { let help = render_tool_cli_help(&def(tool_name)); - assert!(!help.contains("--storage-scope"), "{tool_name}: {help}"); + if tool_name.starts_with("lcm_") { + assert!(help.contains("--storage-scope"), "{tool_name}: {help}"); + } assert!(!help.contains("--hermes-home"), "{tool_name}: {help}"); assert!(!help.contains("hermes_profile"), "{tool_name}: {help}"); } diff --git a/src/update_cmd.rs b/src/update_cmd.rs index c5eb3895c..1959fadf1 100644 --- a/src/update_cmd.rs +++ b/src/update_cmd.rs @@ -26,12 +26,11 @@ pub(crate) async fn refresh_generated_plugins() -> tracedecay::errors::Result<() let mut refreshed_any = false; let mut failures: Vec = Vec::new(); for ag in tracedecay::agents::all_integrations() { - let hermes_was_installed = ag.id() == "hermes" - && (ag.has_tracedecay(&home) - || tracedecay::agents::hermes::has_legacy_plugin_install(&home)); - if hermes_was_installed { - crate::agent_cmd::migrate_legacy_hermes_data(&home).await?; - } + let hermes_was_installed = ag.id() == "hermes" && ag.has_tracedecay(&home); + // Generated-plugin refresh never rewrites Hermes profile config, so it + // must not be blocked by an unresolved historical session migration. + // Migration remains mandatory on install/uninstall paths that can + // remove a legacy project pin. let ctx = tracedecay::agents::InstallContext { home: home.clone(), tracedecay_bin: tracedecay_bin.clone(), diff --git a/src/user_config.rs b/src/user_config.rs index 9c1accbf6..3c3b7d9a5 100644 --- a/src/user_config.rs +++ b/src/user_config.rs @@ -154,6 +154,22 @@ pub fn config_path() -> Option { crate::config::user_data_dir().map(|dir| dir.join("config.toml")) } +/// Whether the user config explicitly contains an `[automation]` table. +/// Missing automation configuration is distinct from an explicit disabled +/// configuration for profile-level projectless self-improvement. +pub fn automation_is_configured() -> bool { + let Some(path) = config_path() else { + return false; + }; + let Ok(contents) = std::fs::read_to_string(path) else { + return false; + }; + toml::from_str::(&contents) + .ok() + .and_then(|value| value.as_table().cloned()) + .is_some_and(|table| table.contains_key("automation")) +} + /// Errors returned by [`UserConfig::save`] / [`UserConfig::save_with_recovery`]. /// /// Distinguishes the ways a save can fail so callers can surface an actionable diff --git a/tests/agent_suite/agent_test.rs b/tests/agent_suite/agent_test.rs index 1849ebd3e..3d2284a27 100644 --- a/tests/agent_suite/agent_test.rs +++ b/tests/agent_suite/agent_test.rs @@ -1124,6 +1124,7 @@ fn test_hermes_user_install_writes_single_plugin() { assert!(manifest.contains("tracedecay_lcm_compress")); assert!(manifest.contains("provides_hooks:")); assert!(manifest.contains("pre_llm_call")); + assert!(manifest.contains("post_tool_call")); assert!(manifest.contains("provides_commands:")); assert!(manifest.contains("/tracedecay_status")); @@ -1133,11 +1134,13 @@ fn test_hermes_user_install_writes_single_plugin() { assert!(init_py.contains("ctx.register_memory_provider(")); assert!(init_py.contains("register_tool = getattr(ctx, \"register_tool\", None)")); assert!(init_py.contains("ctx.register_hook(\"pre_llm_call\"")); + assert!(init_py.contains("ctx.register_hook(\"post_tool_call\"")); assert!(init_py.contains("getattr(ctx, \"register_command\", None)")); assert!(init_py.contains("getattr(ctx, \"register_skill\", None)")); - assert!(init_py.contains("register_skill(\"tracedecay\"")); + assert!(init_py.contains("register_skill(skill_name, skill_path)")); assert!(init_py.contains("class TraceDecayContextEngine")); - assert!(!init_py.contains("storage_scope")); + assert!(init_py.contains("routed.setdefault(\"storage_scope\", \"user\")")); + assert!(!init_py.contains("hermes_profile")); assert!(!init_py.contains("hermes_home\": self.hermes_home")); assert!(!init_py.contains("HERMES_HOME")); assert!(init_py.contains("tracedecay_lcm_compress")); @@ -1265,7 +1268,7 @@ fn test_hermes_plugin_init_snapshot_matches_embedded_asset() { hasher.update(body.as_bytes()); assert_eq!( hex::encode(hasher.finalize()), - "3cd56428c8d7cf40a1877ce85eb6103a8b9dc35fe8194d360586a588d1dbc879", + "88db26663abdb759748ce77900bddf2746a97ba73635f2a69a909ed1a9d008be", "templates/plugin_init.py payload hash changed — verify the edit is intentional and update this snapshot" ); } @@ -1281,7 +1284,7 @@ fn test_hermes_generated_python_registers_lcm_context_engine() { assert!(init_py.contains("class TraceDecayContextEngine")); assert!(init_py.contains("ctx.register_context_engine")); - assert!(!init_py.contains("storage_scope")); + assert!(init_py.contains("routed.setdefault(\"storage_scope\", \"user\")")); assert!(init_py.contains("def call_tracedecay_json")); assert!(init_py.contains("tracedecay_lcm_status")); assert!(init_py.contains("\"tracedecay_lcm_preflight\",")); @@ -1553,14 +1556,15 @@ 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 provider.project_root == os.getcwd() +assert provider.project_root is None assert provider.project_root != provider.hermes_home assert provider.session_id == "session-123" # Hermes home remains host config only; TraceDecay routing stays on cwd. provider.initialize("session-only") assert provider.hermes_home == str(plugin_dir.parent.parent) -assert provider.project_root == os.getcwd() +assert provider.project_root is None assert provider.session_id == "session-only" +provider.project_root = os.getcwd() # Collapsed schema surface: fact_store(action=...) covers the nine legacy # fixed-action aliases, which stay dispatchable but cost no schema footprint. @@ -1594,13 +1598,13 @@ assert json.loads(feedback_result)["name"] == "tracedecay_fact_feedback" assert json.loads(search_result)["name"] == "tracedecay_fact_store" assert json.loads(status_result)["name"] == "tracedecay_memory_status" assert calls[0][0] == "tracedecay_fact_store" -assert calls[0][1] == {"action": "list"} +assert calls[0][1] == {"action": "list", "memory_scope": "project"} assert calls[0][2]["request_id"] == "r1" assert calls[1][0] == "tracedecay_fact_feedback" assert calls[2][0] == "tracedecay_fact_store" -assert calls[2][1] == {"query": "Project Phoenix", "action": "search"} +assert calls[2][1] == {"query": "Project Phoenix", "action": "search", "memory_scope": "project"} assert calls[3][0] == "tracedecay_memory_status" -assert calls[3][1] == {} +assert calls[3][1] == {"memory_scope": "project"} class LegacyCtx: context_engine_tool_handlers_receive_messages = True @@ -6333,6 +6337,9 @@ assert "--project" in argv, argv assert argv[argv.index("--project") + 1] == str(healthy_cwd), argv # The context engine filters the legacy pin but layers host-behavior settings. +plugin._resolved_project_scope = lambda path: ( + str(healthy_cwd) if os.path.realpath(str(path)) == os.path.realpath(str(healthy_cwd)) else None +) engine = plugin.TraceDecayContextEngine() assert engine.project_root is None, engine.project_root engine.on_session_start(session_id="s1", cwd=str(healthy_cwd)) diff --git a/tests/agent_suite/managed_skill_archive_test.rs b/tests/agent_suite/managed_skill_archive_test.rs index 855d718b4..514e05706 100644 --- a/tests/agent_suite/managed_skill_archive_test.rs +++ b/tests/agent_suite/managed_skill_archive_test.rs @@ -2,7 +2,7 @@ use tracedecay::automation::managed_skills::{ ManagedSkillDraft, ManagedSkillProvenance, ManagedSkillSource, ManagedSkillState, ManagedSupportFile, SkillInstallTarget, approve_managed_skill, archive_managed_skill, create_managed_skill_draft, discard_pending_managed_skill_update, load_managed_skill, - managed_skill_dir, save_managed_skill, stage_managed_skill_archive, + managed_skill_dir, set_managed_skill_pinned, stage_managed_skill_archive, }; fn draft() -> ManagedSkillDraft { @@ -115,7 +115,7 @@ async fn staged_managed_skill_archive_rejects_pinned_stale_and_duplicates() { create_managed_skill_draft(&profile_root, draft()) .await .unwrap(); - let mut active = approve_managed_skill(&profile_root, "repo-hygiene") + let active = approve_managed_skill(&profile_root, "repo-hygiene") .await .unwrap(); let base_checksum = active.metadata.checksum.clone(); @@ -125,8 +125,9 @@ async fn staged_managed_skill_archive_rejects_pinned_stale_and_duplicates() { .unwrap_err(); assert!(err.to_string().contains("is stale")); - active.set_pinned(true); - save_managed_skill(&profile_root, &active).await.unwrap(); + set_managed_skill_pinned(&profile_root, "repo-hygiene", true) + .await + .unwrap(); let err = stage_managed_skill_archive(&profile_root, "repo-hygiene", &base_checksum, None) .await .unwrap_err(); @@ -135,8 +136,9 @@ async fn staged_managed_skill_archive_rejects_pinned_stale_and_duplicates() { .contains("pinned and exempt from staged archive") ); - active.set_pinned(false); - save_managed_skill(&profile_root, &active).await.unwrap(); + set_managed_skill_pinned(&profile_root, "repo-hygiene", false) + .await + .unwrap(); stage_managed_skill_archive(&profile_root, "repo-hygiene", &base_checksum, None) .await .unwrap(); diff --git a/tests/agent_suite/managed_skills_test.rs b/tests/agent_suite/managed_skills_test.rs index d07ccf9fa..2b30f76c4 100644 --- a/tests/agent_suite/managed_skills_test.rs +++ b/tests/agent_suite/managed_skills_test.rs @@ -4,7 +4,8 @@ use tracedecay::automation::managed_skills::{ approve_managed_skill, archive_managed_skill, create_managed_skill_draft, disable_managed_skill, discard_pending_managed_skill_update, list_managed_skills, load_managed_skill, managed_skill_dir, restore_managed_skill, save_managed_skill, - set_managed_skill_state, stage_managed_skill_update, update_managed_skill, + set_managed_skill_pinned, set_managed_skill_state, stage_managed_skill_update, + update_managed_skill, }; use tracedecay::automation::skill_usage::{ SkillUsageAction, SkillUsageEvent, ingest_analytics_events, load_skill_usage_records, @@ -190,7 +191,10 @@ fn rejects_frontmatter_breaking_metadata_and_oversized_body() { let mut hermes_target = draft(); hermes_target.targets = vec![SkillInstallTarget::Hermes]; - assert!(hermes_target.materialize().is_err()); + assert_eq!( + hermes_target.materialize().unwrap().metadata.targets, + vec![SkillInstallTarget::Hermes] + ); } #[test] @@ -526,6 +530,30 @@ async fn managed_skill_lifecycle_helpers_keep_activation_explicit() { assert_eq!(restored.metadata.state, ManagedSkillState::PendingApproval); } +#[tokio::test] +async fn concurrent_managed_skill_mutations_preserve_both_changes() { + let temp = tempfile::tempdir().unwrap(); + let profile_root = temp.path().join("profile"); + create_managed_skill_draft(&profile_root, draft()) + .await + .unwrap(); + + let pin_root = profile_root.clone(); + let activate_root = profile_root.clone(); + let (pinned, activated) = tokio::join!( + set_managed_skill_pinned(&pin_root, "repo-hygiene", true), + set_managed_skill_state(&activate_root, "repo-hygiene", ManagedSkillState::Active,) + ); + pinned.unwrap(); + activated.unwrap(); + + let persisted = load_managed_skill(&profile_root, "repo-hygiene") + .await + .unwrap(); + assert!(persisted.metadata.pinned); + assert_eq!(persisted.metadata.state, ManagedSkillState::Active); +} + #[tokio::test] async fn managed_skill_save_syncs_usage_lifecycle_metadata() { let temp = tempfile::tempdir().unwrap(); @@ -657,14 +685,10 @@ async fn managed_skill_update_restages_content_changes_for_approval() { approve_managed_skill(&profile_root, "repo-hygiene") .await .unwrap(); - let mut before_update = load_managed_skill(&profile_root, "repo-hygiene") + let before_update = load_managed_skill(&profile_root, "repo-hygiene") .await .unwrap(); let created_at = before_update.metadata.created_at; - before_update.metadata.updated_at = 1; - save_managed_skill(&profile_root, &before_update) - .await - .unwrap(); let updated = update_managed_skill( &profile_root, @@ -684,7 +708,7 @@ async fn managed_skill_update_restages_content_changes_for_approval() { assert_eq!(updated.metadata.state, ManagedSkillState::PendingApproval); assert!(updated.metadata.pinned); assert_eq!(updated.metadata.created_at, created_at); - assert!(updated.metadata.updated_at > 1); + assert!(updated.metadata.updated_at >= created_at); assert_eq!( updated.metadata.summary, "Updated summary from review evidence." @@ -890,9 +914,7 @@ async fn staged_managed_skill_update_rejects_no_op_patch() { async fn managed_skill_usage_ledger_records_views_uses_and_patches() { let temp = tempfile::tempdir().unwrap(); let profile_root = temp.path().join("profile"); - let mut skill = create_managed_skill_draft(&profile_root, draft()) - .await - .unwrap(); + let mut skill = draft().materialize().unwrap(); skill.set_pinned(true); save_managed_skill(&profile_root, &skill).await.unwrap(); diff --git a/tests/agent_suite/skill_targets_test.rs b/tests/agent_suite/skill_targets_test.rs index 62da73460..c0b3bb5b3 100644 --- a/tests/agent_suite/skill_targets_test.rs +++ b/tests/agent_suite/skill_targets_test.rs @@ -50,6 +50,7 @@ fn managed_skill_defaults_target_supported_hosts() { SkillInstallTarget::OpenCode, SkillInstallTarget::Kimi, SkillInstallTarget::Kiro, + SkillInstallTarget::Hermes, ] ); } @@ -868,10 +869,11 @@ command = "other" } #[tokio::test] -async fn hermes_target_is_host_owned_and_not_exported_by_tracedecay() { +async fn hermes_target_uses_native_plugin_overlay() { let temp = tempfile::tempdir().unwrap(); let profile_root = temp.path().join("profile"); let prompt_path = temp.path().join("HERMES.md"); + let plugin_root = temp.path().join("plugin"); create_managed_skill_draft(&profile_root, draft("repo-hygiene", "Repository hygiene")) .await @@ -886,8 +888,13 @@ async fn hermes_target_is_host_owned_and_not_exported_by_tracedecay() { assert!(err.contains("Hermes owns profile skills")); assert!(!prompt_path.exists()); - let err = install_managed_skills(&profile_root, SkillInstallTarget::Hermes, &prompt_path) - .unwrap_err() - .to_string(); - assert!(err.contains("does not export managed skills into Hermes")); + let summary = + install_managed_skills(&profile_root, SkillInstallTarget::Hermes, &plugin_root).unwrap(); + assert_eq!(summary.exported_count, 1); + assert_eq!(summary.exported[0].id, "repo-hygiene"); + assert!( + plugin_root + .join("skills/agent-managed/repo-hygiene/SKILL.md") + .is_file() + ); } diff --git a/tests/agent_suite/skill_usage_test.rs b/tests/agent_suite/skill_usage_test.rs index 22a22ff94..004a73dfd 100644 --- a/tests/agent_suite/skill_usage_test.rs +++ b/tests/agent_suite/skill_usage_test.rs @@ -369,11 +369,13 @@ async fn stale_scoring_explains_archive_candidates_and_exclusions() { ) .await .unwrap(); - let mut pinned = pinned_skill.clone(); - pinned.set_pinned(true); - tracedecay::automation::managed_skills::save_managed_skill(&profile_root, &pinned) - .await - .unwrap(); + let pinned = tracedecay::automation::managed_skills::set_managed_skill_pinned( + &profile_root, + &pinned_skill.metadata.id, + true, + ) + .await + .unwrap(); let user_skill = create_managed_skill_draft( &profile_root, draft("user-skill", ManagedSkillSource::UserDraft), diff --git a/tests/agent_suite/update_plugin_test.rs b/tests/agent_suite/update_plugin_test.rs index a611d77d8..23f10a70d 100644 --- a/tests/agent_suite/update_plugin_test.rs +++ b/tests/agent_suite/update_plugin_test.rs @@ -190,7 +190,7 @@ fn write_retired_codex_skill(plugin_dir: &Path, name: &str) { // --------------------------------------------------------------------------- #[test] -fn hermes_update_plugin_refreshes_user_install_and_retires_legacy_named_install() { +fn hermes_update_plugin_refreshes_default_and_named_profile_installs() { let home = TempDir::new().unwrap(); let _agent_env = AgentEnvLock::pin(home.path()); let _hermes_home = EnvVarGuard::unset("HERMES_HOME"); @@ -198,7 +198,7 @@ fn hermes_update_plugin_refreshes_user_install_and_retires_legacy_named_install( hermes.install(&ctx(home.path(), OLD_BIN)).unwrap(); - // A legacy named-profile install is retired once rather than refreshed. + // Named Hermes profiles are independent host homes and are refreshed too. let work_plugin = home.path().join(".hermes/profiles/work/plugins/tracedecay"); std::fs::create_dir_all(&work_plugin).unwrap(); std::fs::write(work_plugin.join("plugin.yaml"), "name: tracedecay\n").unwrap(); @@ -213,6 +213,7 @@ fn hermes_update_plugin_refreshes_user_install_and_retires_legacy_named_install( std::fs::write(&default_config, &customized).unwrap(); let default_config_before = bytes(&default_config); + let work_config_before = bytes(&work_config); let outcome = hermes.update_plugin(&ctx(home.path(), NEW_BIN)).unwrap(); let UpdatePluginOutcome::Refreshed(paths) = outcome else { @@ -220,10 +221,11 @@ fn hermes_update_plugin_refreshes_user_install_and_retires_legacy_named_install( }; let default_plugin = home.path().join(".hermes/plugins/tracedecay"); let work_plugin = home.path().join(".hermes/profiles/work/plugins/tracedecay"); - assert_eq!(paths, vec![default_plugin.clone()]); + assert_eq!(paths, vec![default_plugin.clone(), work_plugin.clone()]); // The supported user config remains byte-identical. assert_eq!(bytes(&default_config), default_config_before); + assert_eq!(bytes(&work_config), work_config_before); // Artifacts re-baked with the new binary path and current version stamp. assert!(text(&default_plugin.join("tools.py")).contains(NEW_BIN)); @@ -231,8 +233,9 @@ fn hermes_update_plugin_refreshes_user_install_and_retires_legacy_named_install( text(&default_plugin.join("plugin.yaml")) .contains(&format!("version: {}", env!("CARGO_PKG_VERSION"))) ); - assert!(!work_plugin.join("plugin.yaml").exists()); - assert!(!text(&work_config).contains("tracedecay")); + assert!(text(&work_plugin.join("tools.py")).contains(NEW_BIN)); + assert!(work_plugin.join("plugin.yaml").exists()); + assert!(text(&work_config).contains("tracedecay")); // Dashboard page refreshes without a Hermes profile/project default. let api = text(&default_plugin.join("dashboard/plugin_api.py")); @@ -286,7 +289,7 @@ fn hermes_update_plugin_reports_not_installed_when_nothing_is_detected() { } #[test] -fn hermes_update_plugin_moves_legacy_named_only_install_to_user_home() { +fn hermes_update_plugin_refreshes_named_only_install_in_place() { let home = TempDir::new().unwrap(); let _agent_env = AgentEnvLock::pin(home.path()); let _hermes_home = EnvVarGuard::unset("HERMES_HOME"); @@ -305,13 +308,13 @@ fn hermes_update_plugin_moves_legacy_named_only_install_to_user_home() { .update_plugin(&ctx(home.path(), NEW_BIN)) .unwrap(); - let default = home.path().join(".hermes/plugins/tracedecay"); assert!(matches!( outcome, - UpdatePluginOutcome::Refreshed(paths) if paths == vec![default.clone()] + UpdatePluginOutcome::Refreshed(paths) if paths == vec![legacy.clone()] )); - assert!(text(&default.join("tools.py")).contains(NEW_BIN)); - assert!(!legacy.join("plugin.yaml").exists()); + assert!(text(&legacy.join("tools.py")).contains(NEW_BIN)); + assert!(legacy.join("plugin.yaml").exists()); + assert!(!home.path().join(".hermes/plugins/tracedecay").exists()); } // --------------------------------------------------------------------------- diff --git a/tests/automation_runner_test/combined_review.rs b/tests/automation_runner_test/combined_review.rs index 64f3de268..1e8d88e4a 100644 --- a/tests/automation_runner_test/combined_review.rs +++ b/tests/automation_runner_test/combined_review.rs @@ -16,6 +16,7 @@ fn combined_options(profile_root: &Path) -> CombinedReviewAutomationOptions { ..SessionReflectorAutomationOptions::default() }, skill_writer: manual_skill_writer_options(profile_root), + trigger: AutomationTrigger::Scheduler, } } @@ -127,9 +128,9 @@ async fn combined_review_runner_records_both_tasks_from_one_backend_call() { .await .unwrap(); assert_eq!(records.len(), 2); - let now = current_timestamp(); for task in [AgentTaskKind::SessionReflector, AgentTaskKind::SkillWriter] { - let decision = schedule_decision(&config, task, &records, SessionActivity::at(now), now); + let now = current_timestamp(); + let decision = schedule_decision(&config, task, &records, SessionActivity::none(), now); assert_eq!( decision.skip_reason(), Some("scheduler_interval_not_elapsed"), diff --git a/tests/automation_runner_test/config.rs b/tests/automation_runner_test/config.rs index ca7b9fc7a..f81563fc0 100644 --- a/tests/automation_runner_test/config.rs +++ b/tests/automation_runner_test/config.rs @@ -4,8 +4,8 @@ use tempfile::tempdir; use tracedecay::automation::config::{ AutomationBackend, AutomationConfig, AutomationConfigPatch, AutomationHostMode, - AutomationTaskPatch, effective_config, load_project_config, merge_project_config, - save_project_config, + AutomationTaskPatch, effective_config, effective_user_automation_config, load_project_config, + merge_project_config, save_project_config, }; use tracedecay::user_config::UserConfig; @@ -18,7 +18,7 @@ fn automation_defaults_are_conservative() { assert_eq!(config.host_mode, AutomationHostMode::Standalone); assert_eq!(config.timeout_secs, 60); assert_eq!(config.scheduler_tick_secs, 60); - assert!(!config.auto_apply_memory_ops); + assert!(config.auto_apply_memory_ops); assert!(!config.auto_enable_skills); assert!(!config.tasks.memory_curator.enabled); assert!(!config.tasks.session_reflector.enabled); @@ -43,6 +43,53 @@ fn user_config_omits_default_automation_when_serialized() { ); } +#[tokio::test] +async fn projectless_automation_defaults_to_enabled_autonomous_review() { + let temp = tempdir().unwrap(); + let config = effective_user_automation_config(temp.path(), &AutomationConfig::default(), false) + .await + .unwrap(); + + assert!(config.enabled); + assert_eq!(config.backend, AutomationBackend::CodexAppServer); + assert!(config.auto_apply_memory_ops); + assert!(config.auto_enable_skills); + assert!(config.tasks.memory_curator.enabled); + assert!(config.tasks.session_reflector.enabled); + assert!(config.tasks.skill_writer.enabled); +} + +#[tokio::test] +async fn projectless_automation_preserves_explicit_global_disable() { + let temp = tempdir().unwrap(); + let config = effective_user_automation_config(temp.path(), &AutomationConfig::default(), true) + .await + .unwrap(); + + assert!(!config.enabled); + assert_eq!(config.backend, AutomationBackend::Disabled); +} + +#[tokio::test] +async fn projectless_automation_sidecar_can_explicitly_disable_defaults() { + let temp = tempdir().unwrap(); + let dashboard_root = tracedecay::automation::runner::user_automation_root(temp.path()); + save_project_config( + &dashboard_root, + &AutomationConfigPatch { + enabled: Some(false), + ..AutomationConfigPatch::default() + }, + ) + .await + .unwrap(); + + let config = effective_user_automation_config(temp.path(), &AutomationConfig::default(), false) + .await + .unwrap(); + assert!(!config.enabled); +} + #[test] fn effective_config_applies_project_sidecar_over_global_defaults() { let global = AutomationConfig { @@ -74,7 +121,7 @@ fn effective_config_applies_project_sidecar_over_global_defaults() { config.tasks.memory_curator.schedule.as_deref(), Some("manual") ); - assert!(!config.auto_apply_memory_ops); + assert!(config.auto_apply_memory_ops); assert!(!config.auto_enable_skills); } diff --git a/tests/automation_runner_test/jobs.rs b/tests/automation_runner_test/jobs.rs index 84a6f5705..28abf1ca0 100644 --- a/tests/automation_runner_test/jobs.rs +++ b/tests/automation_runner_test/jobs.rs @@ -651,6 +651,87 @@ async fn scheduler_user_job_uses_explicit_profile_root_for_attached_skills() { assert_eq!(run.report["status"], json!("delivered")); } +#[tokio::test] +async fn user_job_does_not_attach_archived_managed_skills() { + let temp = tempdir().unwrap(); + let dashboard_root = temp.path().join("dashboard"); + let profile_root = temp.path().join("profile"); + let project_root = temp.path().join("project"); + fs::create_dir_all(&profile_root).unwrap(); + fs::create_dir_all(&project_root).unwrap(); + create_managed_skill_draft( + &profile_root, + ManagedSkillDraft { + id: "archived-job-context".to_string(), + title: "Archived job context".to_string(), + summary: "Must not be attached after archival.".to_string(), + category: "workflow".to_string(), + targets: tracedecay::automation::managed_skills::default_managed_skill_targets(), + body_markdown: "ARCHIVED_SKILL_BODY_MUST_NOT_RUN".to_string(), + support_files: Vec::new(), + provenance: ManagedSkillProvenance { + source: ManagedSkillSource::UserDraft, + actor: "test".to_string(), + run_id: None, + }, + }, + ) + .await + .unwrap(); + approve_managed_skill(&profile_root, "archived-job-context") + .await + .unwrap(); + tracedecay::automation::managed_skills::archive_managed_skill( + &profile_root, + "archived-job-context", + ) + .await + .unwrap(); + + let mut job = sample_job("archived-profile-skill"); + job.skill_ids = vec!["archived-job-context".to_string()]; + + struct AssertArchivedSkillExcludedBackend; + impl AgentTaskBackend for AssertArchivedSkillExcludedBackend { + fn run_task( + &self, + request: &AgentTaskRequest, + ) -> tracedecay::errors::Result { + assert!(!request.prompt.contains("ARCHIVED_SKILL_BODY_MUST_NOT_RUN")); + assert_eq!(request.context["attached_skills"], json!([])); + assert_eq!( + request.context["missing_skills"], + json!(["archived-job-context"]) + ); + Ok(AgentTaskResponse { + run_id: request.run_id.clone(), + task: request.task, + output_text: "done".to_string(), + output_json: None, + model: Some("fixture-model".to_string()), + input_tokens: None, + output_tokens: None, + }) + } + } + + let run = run_user_job_with_backend( + &dashboard_root, + &enabled_job_config(), + &AssertArchivedSkillExcludedBackend, + &job, + UserJobRunOptions { + trigger: AutomationTrigger::Scheduler, + run_id: Some("archived-profile-skill-run".to_string()), + profile_root: Some(profile_root), + project_root: Some(project_root), + }, + ) + .await + .unwrap(); + assert_eq!(run.report["status"], json!("delivered")); +} + #[tokio::test] async fn user_job_backend_failure_records_failed_ledger_entry() { let temp = tempdir().unwrap(); diff --git a/tests/automation_runner_test/main.rs b/tests/automation_runner_test/main.rs index d4cade7f5..1b998b4a3 100644 --- a/tests/automation_runner_test/main.rs +++ b/tests/automation_runner_test/main.rs @@ -26,3 +26,4 @@ mod scheduler; mod session_reflector; mod skill_writer; mod skill_writer_consolidation; +mod user_scope; diff --git a/tests/automation_runner_test/memory_curator.rs b/tests/automation_runner_test/memory_curator.rs index af34addf2..444796722 100644 --- a/tests/automation_runner_test/memory_curator.rs +++ b/tests/automation_runner_test/memory_curator.rs @@ -130,7 +130,7 @@ async fn memory_curator_runner_validates_backend_ops_and_records_ledger() { ); assert_eq!( run.ledger_record.validation_report.as_ref().unwrap()["apply_policy"]["decision"], - json!("dry_run_only") + json!("auto_apply_allowed") ); assert_eq!( run.ledger_record.validation_report.as_ref().unwrap()["apply_policy"]["permanent_delete_count"], @@ -138,12 +138,13 @@ async fn memory_curator_runner_validates_backend_ops_and_records_ledger() { ); assert_eq!( run.ledger_record.validation_report.as_ref().unwrap()["apply_policy"]["mutates_store"], - json!(false) + json!(true) ); assert_eq!( run.report["automation_apply_policy"]["autonomous_memory_apply"], - json!(false) + json!(true) ); + assert!(!fact_exists(&cg, 102).await); assert_eq!( run.report["automation_apply_policy"]["require_dashboard_approval"], json!(false) @@ -213,7 +214,7 @@ async fn memory_curator_runner_validates_backend_ops_and_records_ledger() { ); assert_eq!( validation_payload["improvement_gate"]["criteria"]["auto_apply_allowed"], - json!(false) + json!(true) ); assert_eq!( validation_payload["improvement_gate"]["source_refs"] @@ -479,7 +480,7 @@ async fn memory_curator_runner_validates_backend_ops_and_records_ledger() { assert_eq!(handoff_payload["readiness"]["eval_count"], json!(2)); assert_eq!( handoff_payload["readiness"]["auto_apply_allowed"], - json!(false) + json!(true) ); assert_eq!( handoff_payload["machine_summary"]["next_stage"], @@ -487,7 +488,7 @@ async fn memory_curator_runner_validates_backend_ops_and_records_ledger() { ); assert_eq!( handoff_payload["validation_requirements"]["must_not_auto_apply"], - json!(true) + json!(false) ); assert_eq!( handoff_payload["source_refs"][0]["kind"], @@ -544,9 +545,53 @@ async fn memory_curator_runner_validates_backend_ops_and_records_ledger() { assert_eq!(records[0].rejected_count, 1); assert_eq!(records[0].artifacts.len(), 6); assert!( - fact_exists(&cg, 102).await, - "dry-run memory curator must not delete accepted ops before approval" + !fact_exists(&cg, 102).await, + "default memory curation must apply accepted validated ops" + ); +} + +#[tokio::test] +async fn scheduler_memory_curator_applies_validated_ops_despite_legacy_preview_setting() { + let temp = tempdir().unwrap(); + let cg = init_project(temp.path()).await; + seed_duplicate_facts(&cg).await; + let backend = JsonBackend::new(json!({ + "ops": [{ + "cluster_id": "cluster-0000", + "op": "delete", + "fact_id": 102, + "confidence": 0.98, + "reason": "near duplicate of fact 101" + }] + })); + let mut config = scheduler_config(None, None); + config.auto_apply_memory_ops = false; + config.tasks.memory_curator.interval_secs = Some(1); + + let run = run_memory_curator_with_backend( + &cg, + &config, + &backend, + MemoryCuratorAutomationOptions { + trigger: AutomationTrigger::Scheduler, + max_clusters: 4, + min_confidence: 0.5, + run_id: None, + }, + ) + .await + .unwrap(); + + assert_eq!(run.report["llm_apply"]["applied"], json!(1)); + assert_eq!( + run.report["automation_apply_policy"]["decision"], + json!("auto_apply_allowed") + ); + assert_eq!( + run.report["automation_apply_policy"]["validated_before_apply"], + json!(true) ); + assert!(!fact_exists(&cg, 102).await); } #[tokio::test] @@ -867,7 +912,7 @@ async fn memory_curator_runner_preserves_review_gate_when_auto_apply_applies_zer ); assert_eq!( run.report["automation_apply_policy"]["decision"], - json!("dry_run_only") + json!("apply_incomplete") ); assert_eq!( run.report["automation_apply_policy"]["mutates_store"], diff --git a/tests/automation_runner_test/scheduler.rs b/tests/automation_runner_test/scheduler.rs index dff3b217b..bfcd26940 100644 --- a/tests/automation_runner_test/scheduler.rs +++ b/tests/automation_runner_test/scheduler.rs @@ -9,8 +9,8 @@ use tracedecay::automation::run_ledger::{ }; use tracedecay::automation::scheduler::{ AutomationSchedule, AutomationSchedulerControl, AutomationTaskLock, SessionActivity, - load_scheduler_control, load_session_activity, parse_schedule, save_scheduler_control, - schedule_decision, scheduler_control_path, + host_receipt_decision, load_scheduler_control, load_session_activity, parse_schedule, + save_scheduler_control, schedule_decision, scheduler_control_path, }; use tracedecay::global_db::GlobalDb; @@ -87,6 +87,45 @@ fn scheduler_parses_manual_aliases_and_intervals() { assert!(parse_schedule(Some("after lunch")).is_err()); } +#[test] +fn host_receipt_bypasses_schedule_but_preserves_enablement_and_idle_gates() { + let mut config = automation_config(None, None); + assert!( + host_receipt_decision( + &config, + AgentTaskKind::SessionReflector, + &[], + SessionActivity::at(100), + 200, + ) + .is_due() + ); + config.tasks.session_reflector.min_idle_secs = Some(120); + assert_eq!( + host_receipt_decision( + &config, + AgentTaskKind::SessionReflector, + &[], + SessionActivity::at(100), + 200, + ) + .skip_reason(), + Some("scheduler_idle_window_active") + ); + config.enabled = false; + assert_eq!( + host_receipt_decision( + &config, + AgentTaskKind::SessionReflector, + &[], + SessionActivity::none(), + 200, + ) + .skip_reason(), + Some("automation_disabled") + ); +} + #[tokio::test] async fn scheduler_control_sidecar_round_trips_pause_state() { let tmp = tempdir().unwrap(); @@ -178,6 +217,32 @@ fn scheduler_uses_interval_and_latest_successful_ledger_record() { ); } +#[test] +fn fresh_session_activity_bypasses_interval_for_all_host_evidence_tasks() { + let config = automation_config(Some("every 10m"), None); + for task in [AgentTaskKind::SessionReflector, AgentTaskKind::SkillWriter] { + let records = vec![record( + "previous-success", + task, + AutomationRunStatus::Succeeded, + 1_000, + )]; + assert!( + schedule_decision( + &config, + task, + &records, + SessionActivity { + last_activity_secs: Some(1_100), + }, + 1_101, + ) + .is_due(), + "fresh completed-turn evidence should wake {task:?} without waiting for its repair interval" + ); + } +} + #[test] fn scheduler_ignores_non_terminal_lifecycle_records_for_interval_decisions() { let config = automation_config(Some("every 10m"), None); @@ -522,11 +587,9 @@ fn scheduler_skips_session_evidence_tasks_without_new_activity() { assert!( schedule_decision(&config, task, &records, SessionActivity::at(1_650), 1_700).is_due() ); - // The interval gate still wins while it has not elapsed. - assert_eq!( - schedule_decision(&config, task, &records, SessionActivity::at(1_050), 1_100) - .skip_reason(), - Some("scheduler_interval_not_elapsed") + // Fresh completed-turn evidence bypasses the periodic repair interval. + assert!( + schedule_decision(&config, task, &records, SessionActivity::at(1_050), 1_100).is_due() ); } } diff --git a/tests/automation_runner_test/skill_writer_consolidation.rs b/tests/automation_runner_test/skill_writer_consolidation.rs index a2f85d3c4..27aa8a12e 100644 --- a/tests/automation_runner_test/skill_writer_consolidation.rs +++ b/tests/automation_runner_test/skill_writer_consolidation.rs @@ -1,7 +1,7 @@ use crate::support::*; #[tokio::test] -async fn skill_writer_runner_stages_consolidations_without_auto_apply() { +async fn skill_writer_runner_auto_applies_safe_consolidations() { let _env_lock = ENV_LOCK.lock().await; let temp = tempdir().unwrap(); let profile_root = temp.path().join("profile"); @@ -69,13 +69,16 @@ async fn skill_writer_runner_stages_consolidations_without_auto_apply() { let source = approve_managed_skill(&profile_root, "automation-run-checks") .await .unwrap(); - let mut pinned = approve_managed_skill(&profile_root, "pinned-automation-guide") - .await - .unwrap(); - pinned.set_pinned(true); - tracedecay::automation::managed_skills::save_managed_skill(&profile_root, &pinned) + let pinned = approve_managed_skill(&profile_root, "pinned-automation-guide") .await .unwrap(); + tracedecay::automation::managed_skills::set_managed_skill_pinned( + &profile_root, + &pinned.metadata.id, + true, + ) + .await + .unwrap(); let backend = SkillJsonBackend::with_activation_policy( json!({ @@ -120,10 +123,7 @@ async fn skill_writer_runner_stages_consolidations_without_auto_apply() { let consolidation = &run.report["staged_consolidations"][0]; assert_eq!(consolidation["action"], json!("merge")); - assert_eq!( - consolidation["approval_status"], - json!("staged_consolidation") - ); + assert_eq!(consolidation["approval_status"], json!("auto_applied")); assert_eq!( consolidation["target_skill_id"], json!("automation-run-review") @@ -137,7 +137,7 @@ async fn skill_writer_runner_stages_consolidations_without_auto_apply() { json!("automation-run-checks") ); assert_eq!(consolidation["resulting_state"], json!("archived")); - assert_eq!(consolidation["target_update_staged"], json!(true)); + assert_eq!(consolidation["target_update_staged"], json!(false)); assert!( run.report["rejected_skills"][0]["reason"] .as_str() @@ -158,38 +158,30 @@ async fn skill_writer_runner_stages_consolidations_without_auto_apply() { let staged_source = load_managed_skill(&profile_root, "automation-run-checks") .await .unwrap(); - assert_eq!(staged_source.metadata.state, ManagedSkillState::Active); - let source_pending = staged_source.pending_update.as_ref().unwrap(); + assert_eq!(staged_source.metadata.state, ManagedSkillState::Archived); assert_eq!( - source_pending.resulting_state, - Some(ManagedSkillState::Archived) + staged_source.metadata.absorbed_into.as_deref(), + Some("automation-run-review") ); assert!( - source_pending - .staged_reason + staged_source + .metadata + .archived_reason .as_deref() - .is_some_and(|reason| reason.contains("merged into 'automation-run-review'")) + .is_some_and(|reason| reason.contains("overlap")) ); let staged_target = load_managed_skill(&profile_root, "automation-run-review") .await .unwrap(); assert_eq!(staged_target.metadata.state, ManagedSkillState::Active); - assert_eq!(staged_target.metadata.checksum, target.metadata.checksum); - assert!(staged_target.pending_update.is_some()); + assert_ne!(staged_target.metadata.checksum, target.metadata.checksum); + assert!(staged_target.pending_update.is_none()); let untouched_pinned = load_managed_skill(&profile_root, "pinned-automation-guide") .await .unwrap(); assert_eq!(untouched_pinned.metadata.state, ManagedSkillState::Active); assert!(untouched_pinned.pending_update.is_none()); - let archived = approve_managed_skill(&profile_root, "automation-run-checks") - .await - .unwrap(); - assert_eq!(archived.metadata.state, ManagedSkillState::Archived); - assert_eq!(archived.body_markdown, source.body_markdown); - let merged = approve_managed_skill(&profile_root, "automation-run-review") - .await - .unwrap(); - assert_eq!(merged.metadata.state, ManagedSkillState::Active); - assert!(merged.body_markdown.contains("approval gates")); + assert_eq!(staged_source.body_markdown, source.body_markdown); + assert!(staged_target.body_markdown.contains("approval gates")); } diff --git a/tests/automation_runner_test/user_scope.rs b/tests/automation_runner_test/user_scope.rs new file mode 100644 index 000000000..9a59e8778 --- /dev/null +++ b/tests/automation_runner_test/user_scope.rs @@ -0,0 +1,320 @@ +use crate::support::*; +use tracedecay::automation::config::effective_user_automation_config; +use tracedecay::automation::runner::{ + run_user_memory_curator_with_backend, run_user_session_reflector_with_backend, + run_user_skill_writer_with_backend, user_automation_root, +}; +use tracedecay::db::Database; +use tracedecay::memory::user::{open_user_memory_db, user_memory_db_path}; +use tracedecay::sessions::user_sessions_db_path; + +async fn seed_user_session(profile_root: &Path) { + let db = GlobalDb::open_at(&user_sessions_db_path(profile_root)) + .await + .expect("user sessions db open"); + seed_session_message_in_db( + &db, + profile_root, + SeedSessionMessage { + provider: "hermes", + session_id: "user-session-1", + message_id: "user-message-1", + role: "user", + timestamp: 1_715_100_001, + text: "Remember this preference: always keep general conversations in user memory and review recurring automation workflows.", + source: Some("hermes"), + }, + ) + .await; +} + +fn enabled_user_config() -> AutomationConfig { + AutomationConfig { + enabled: true, + backend: AutomationBackend::CodexAppServer, + host_mode: AutomationHostMode::Standalone, + auto_apply_memory_ops: false, + tasks: AutomationTaskSet { + memory_curator: AutomationTaskConfig { + enabled: true, + schedule: Some("manual".to_string()), + ..AutomationTaskConfig::default() + }, + session_reflector: AutomationTaskConfig { + enabled: true, + schedule: Some("manual".to_string()), + ..AutomationTaskConfig::default() + }, + skill_writer: AutomationTaskConfig { + enabled: true, + schedule: Some("manual".to_string()), + ..AutomationTaskConfig::default() + }, + }, + ..AutomationConfig::default() + } +} + +#[tokio::test] +async fn projectless_reflection_reads_user_sessions_and_writes_user_memory() { + let temp = tempdir().unwrap(); + let profile_root = temp.path(); + seed_user_session(profile_root).await; + let backend = SessionJsonBackend::new(json!({ + "facts": [{ + "content": "The user wants general projectless conversations stored in profile-level user memory", + "category": "user_pref", + "tags": ["memory", "projectless"], + "entities": ["TraceDecay"], + "trust": 0.9, + "source_span": {"session_id": "user-session-1", "message_id": "user-message-1"}, + "reason": "The user explicitly stated this durable preference" + }] + })); + let config = + effective_user_automation_config(profile_root, &AutomationConfig::default(), false) + .await + .unwrap(); + + let run = run_user_session_reflector_with_backend( + profile_root, + &config, + &backend, + SessionReflectorAutomationOptions { + provider: "hermes".to_string(), + query: "user memory".to_string(), + ..SessionReflectorAutomationOptions::default() + }, + ) + .await + .unwrap(); + + assert_eq!(run.report["status"], json!("auto_applied")); + assert_eq!(run.report["dry_run"], json!(false)); + let user_db = open_user_memory_db(profile_root).await.unwrap(); + let count: i64 = user_db + .conn() + .query("SELECT COUNT(*) FROM memory_facts", ()) + .await + .unwrap() + .next() + .await + .unwrap() + .unwrap() + .get(0) + .unwrap(); + assert_eq!(count, 1); + assert!(user_memory_db_path(profile_root).is_file()); + assert!( + user_automation_root(profile_root) + .join("automation_runs.jsonl") + .is_file() + ); + assert!( + !profile_root + .join("dashboard/automation_runs.jsonl") + .exists() + ); +} + +#[tokio::test] +async fn projectless_skill_writer_reads_user_sessions_and_uses_user_ledger() { + let temp = tempdir().unwrap(); + let profile_root = temp.path(); + seed_user_session(profile_root).await; + let backend = SkillJsonBackend::new(json!({"skills": []})); + + let run = run_user_skill_writer_with_backend( + profile_root, + &enabled_user_config(), + &backend, + SkillWriterAutomationOptions { + provider: "hermes".to_string(), + query: "automation workflows".to_string(), + ..SkillWriterAutomationOptions::default() + }, + ) + .await + .unwrap(); + + assert_eq!(backend.calls(), 1); + assert_eq!(run.ledger_record.status, AutomationRunStatus::Succeeded); + let records = load_run_records(&user_automation_root(profile_root), 10) + .await + .unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].task, AgentTaskKind::SkillWriter); +} + +#[tokio::test] +async fn projectless_memory_curator_applies_validated_delete_to_user_memory() { + let temp = tempdir().unwrap(); + let profile_root = temp.path(); + let db = open_user_memory_db(profile_root).await.unwrap(); + seed_user_duplicate_facts(&db).await; + drop(db); + let backend = JsonBackend::new(json!({ + "ops": [{ + "cluster_id": "cluster-0000", + "op": "delete", + "fact_id": 202, + "confidence": 0.99, + "reason": "The older duplicate is no longer relevant" + }] + })); + + let run = run_user_memory_curator_with_backend( + profile_root, + &enabled_user_config(), + &backend, + MemoryCuratorAutomationOptions::default(), + ) + .await + .unwrap(); + + assert_eq!(run.report["dry_run"], json!(false)); + assert_eq!(run.report["llm_apply"]["applied"], json!(1)); + let db = Database::open(&user_memory_db_path(profile_root)) + .await + .unwrap() + .0; + let count: i64 = db + .conn() + .query("SELECT COUNT(*) FROM memory_facts WHERE fact_id = 202", ()) + .await + .unwrap() + .next() + .await + .unwrap() + .unwrap() + .get(0) + .unwrap(); + assert_eq!(count, 0); +} + +#[tokio::test] +async fn projectless_memory_curator_merges_and_updates_user_memory() { + let temp = tempdir().unwrap(); + let profile_root = temp.path(); + let db = open_user_memory_db(profile_root).await.unwrap(); + seed_user_duplicate_facts(&db).await; + drop(db); + let backend = JsonBackend::new(json!({ + "ops": [{ + "cluster_id": "cluster-0000", + "op": "merge", + "winner_id": 201, + "loser_ids": [202], + "merged_content": "General projectless conversations belong in profile-level user memory", + "confidence": 0.99, + "reason": "Consolidate the duplicate preference into its current wording" + }] + })); + + let run = run_user_memory_curator_with_backend( + profile_root, + &enabled_user_config(), + &backend, + MemoryCuratorAutomationOptions::default(), + ) + .await + .unwrap(); + + assert_eq!(run.report["llm_apply"]["applied"], json!(1)); + let db = Database::open(&user_memory_db_path(profile_root)) + .await + .unwrap() + .0; + let mut rows = db + .conn() + .query( + "SELECT fact_id, content FROM memory_facts ORDER BY fact_id", + (), + ) + .await + .unwrap(); + let row = rows.next().await.unwrap().unwrap(); + assert_eq!(row.get::(0).unwrap(), 201); + assert_eq!( + row.get::(1).unwrap(), + "General projectless conversations belong in profile-level user memory" + ); + assert!(rows.next().await.unwrap().is_none()); +} + +#[tokio::test] +async fn projectless_memory_curator_grooms_user_memory() { + let temp = tempdir().unwrap(); + let profile_root = temp.path(); + let db = open_user_memory_db(profile_root).await.unwrap(); + seed_user_duplicate_facts(&db).await; + drop(db); + let backend = JsonBackend::new(json!({ + "ops": [{ + "cluster_id": "cluster-0000", + "op": "normalize_tags", + "fact_id": 201, + "tags": ["memory", "projectless"], + "evidence_fact_ids": [201, 202], + "confidence": 0.99, + "reason": "Normalize the reviewed preference tags" + }] + })); + + let run = run_user_memory_curator_with_backend( + profile_root, + &enabled_user_config(), + &backend, + MemoryCuratorAutomationOptions::default(), + ) + .await + .unwrap(); + + assert_eq!(run.report["llm_apply"]["applied"], json!(1)); + let db = Database::open(&user_memory_db_path(profile_root)) + .await + .unwrap() + .0; + let tags: String = db + .conn() + .query("SELECT tags FROM memory_facts WHERE fact_id = 201", ()) + .await + .unwrap() + .next() + .await + .unwrap() + .unwrap() + .get(0) + .unwrap(); + assert_eq!(tags, "[\"memory\",\"projectless\"]"); +} + +async fn seed_user_duplicate_facts(db: &Database) { + let vectors = [ + HolographicEncoder::serialize(&[0.20, 0.35, 0.50]).unwrap(), + HolographicEncoder::serialize(&[0.21, 0.34, 0.49]).unwrap(), + ]; + for (index, fact_id) in [201_i64, 202_i64].into_iter().enumerate() { + db.conn() + .execute( + "INSERT INTO memory_facts + (fact_id, content, category, tags, trust_score, retrieval_count, helpful_count, + created_at, updated_at, hrr_vector, hrr_algebra, hrr_dim, access_count) + VALUES (?1, ?2, 'user_pref', '[\"memory\"]', 0.95, 0, 0, ?3, ?3, ?4, + 'amari_fhrr', ?5, 0)", + libsql::params![ + fact_id, + if index == 0 { + "General conversations belong in user memory" + } else { + "General conversations should belong in user memory" + }, + 1_700_000_000_i64 + fact_id, + libsql::Value::Blob(vectors[index].clone()), + HolographicEncoder::DIMENSIONS as i64, + ], + ) + .await + .unwrap(); + } +} diff --git a/tests/core_cli_suite/tool_daemon_test.rs b/tests/core_cli_suite/tool_daemon_test.rs index 592e64fa5..afb68c940 100644 --- a/tests/core_cli_suite/tool_daemon_test.rs +++ b/tests/core_cli_suite/tool_daemon_test.rs @@ -691,7 +691,7 @@ fn tool_cli_skips_daemon_notifications_until_matching_response() { } #[test] -fn tool_cli_rejects_removed_storage_scope_argument() { +fn tool_cli_rejects_invalid_storage_scope_argument() { let home = TempDir::new().unwrap(); let outside_cwd = TempDir::new().unwrap(); let home_path = canonical_existing_path(home.path()); @@ -716,14 +716,15 @@ fn tool_cli_rejects_removed_storage_scope_argument() { assert!( !output.status.success(), - "removed storage_scope must fail before dispatch\nstdout:\n{}\nstderr:\n{}", + "invalid storage_scope must fail before dispatch\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("unknown parameter") && stderr.contains("--storage-scope"), - "rejection should name the removed argument:\n{stderr}" + stderr.contains("storage-scope") + && (stderr.contains("invalid value") || stderr.contains("project, user")), + "rejection should name the valid scopes:\n{stderr}" ); } diff --git a/tests/hermes_suite/dashboard.rs b/tests/hermes_suite/dashboard.rs index bd9e945d6..f75d97423 100644 --- a/tests/hermes_suite/dashboard.rs +++ b/tests/hermes_suite/dashboard.rs @@ -12,6 +12,10 @@ use std::path::{Path, PathBuf}; use tracedecay::agents::{AgentIntegration, HermesIntegration, InstallContext}; +use tracedecay::automation::managed_skills::{ + ManagedSkillDraft, ManagedSkillProvenance, ManagedSkillSource, approve_managed_skill, + create_managed_skill_draft, default_managed_skill_targets, +}; fn make_ctx(home: &Path, dashboard: bool) -> InstallContext { InstallContext { @@ -32,6 +36,49 @@ fn read(path: &Path) -> String { .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())) } +#[tokio::test] +async fn install_and_update_reconcile_active_managed_skills() { + let (env, _) = super::common::IsolatedEnv::acquire().await; + let home = env.home(); + let profile_root = home.join(".tracedecay"); + create_managed_skill_draft( + &profile_root, + ManagedSkillDraft { + id: "repo-hygiene".to_string(), + title: "Repository hygiene".to_string(), + summary: "Use when checking repository hygiene.".to_string(), + category: "workflow".to_string(), + targets: default_managed_skill_targets(), + body_markdown: "Check repository hygiene.".to_string(), + support_files: Vec::new(), + provenance: ManagedSkillProvenance { + source: ManagedSkillSource::UserDraft, + actor: "test".to_string(), + run_id: None, + }, + }, + ) + .await + .unwrap(); + approve_managed_skill(&profile_root, "repo-hygiene") + .await + .unwrap(); + + let ctx = make_ctx(home, false); + HermesIntegration.install(&ctx).unwrap(); + let skill_path = + home.join(".hermes/plugins/tracedecay/skills/agent-managed/repo-hygiene/SKILL.md"); + assert!(skill_path.is_file()); + + std::fs::remove_file(&skill_path).unwrap(); + let outcome = HermesIntegration.update_plugin(&ctx).unwrap(); + assert!(matches!( + outcome, + tracedecay::agents::UpdatePluginOutcome::Refreshed(_) + )); + assert!(skill_path.is_file()); +} + const DIST_FILES: &[&str] = &[ "index.js", "holographic.js", diff --git a/tests/hermes_suite/lcm_bridge.rs b/tests/hermes_suite/lcm_bridge.rs index 3e39a1979..83614f8b5 100644 --- a/tests/hermes_suite/lcm_bridge.rs +++ b/tests/hermes_suite/lcm_bridge.rs @@ -200,7 +200,7 @@ class NoToolCtx: ctx = NoToolCtx() plugin.register(ctx) -assert [name for name, _ in ctx.hooks] == ["pre_llm_call"] +assert [name for name, _ in ctx.hooks] == ["pre_llm_call", "post_tool_call"] assert len(ctx.memory_providers) == 1 assert len(ctx.context_engines) == 1 assert isinstance(ctx.context_engines[0], plugin.TraceDecayContextEngine) @@ -240,7 +240,7 @@ ctx = RaisingToolCtx() plugin.register(ctx) assert ctx.tool_calls -assert [name for name, _ in ctx.hooks] == ["pre_llm_call"] +assert [name for name, _ in ctx.hooks] == ["pre_llm_call", "post_tool_call"] assert len(ctx.memory_providers) == 1 assert len(ctx.context_engines) == 1 "#, @@ -387,7 +387,6 @@ def fake_call_tracedecay_tool(name, args, **kwargs): return json.dumps({"ok": True, "tool": name}) plugin.tools.call_tracedecay_tool = fake_call_tracedecay_tool - native_result = engine.handle_tool_call( "lcm_grep", { @@ -604,6 +603,7 @@ def fake_call_tracedecay_tool(name, args, **kwargs): return json.dumps({"content": [{"type": "text", "text": json.dumps({"status": "ok"})}]}) plugin.tools.call_tracedecay_tool = fake_call_tracedecay_tool +plugin._resolved_project_scope = lambda path: None engine = plugin.TraceDecayContextEngine() engine.initialize(session_id="session-1") @@ -612,7 +612,7 @@ status = engine.get_status() assert "storage_scope" not in status assert "hermes_home" not in status assert "lcm_project_root" not in status -assert status["project_root"] == os.getcwd() +assert status["project_root"] is None engine.handle_tool_call( "lcm_grep", @@ -622,17 +622,20 @@ engine.handle_tool_call( assert calls[0][0] == "tracedecay_lcm_preflight" assert "project_root" not in calls[0][1] -assert calls[0][2]["project_root"] == os.getcwd() +assert calls[0][1]["storage_scope"] == "user" +assert calls[0][2] == {} assert calls[0][1]["messages"] == [{"role": "user", "content": "profile current turn"}] assert calls[1][0] == "tracedecay_lcm_grep" assert "project_root" not in calls[1][1] -assert calls[1][2]["project_root"] == os.getcwd() +assert calls[1][1]["storage_scope"] == "user" +assert calls[1][2] == {} os.environ["HERMES_HOME"] = "/tmp/another-hermes-home" other = plugin.TraceDecayContextEngine() other.initialize(session_id="session-2") other.status() -assert calls[-1][2]["project_root"] == os.getcwd() +assert calls[-1][1]["storage_scope"] == "user" +assert calls[-1][2] == {} "#, "generated context engine must not use HERMES_HOME as a TraceDecay storage identity", ); @@ -652,7 +655,6 @@ def fake_call_tracedecay_tool(name, args, **kwargs): return json.dumps({"status": "ok", "tool": name}) plugin.tools.call_tracedecay_tool = fake_call_tracedecay_tool - engine = plugin.TraceDecayContextEngine() engine.initialize(session_id="session-1", project_root="/tmp/project") @@ -1086,6 +1088,7 @@ def fake_call_tracedecay_tool(name, args, **kwargs): return "{}" plugin.tools.call_tracedecay_tool = fake_call_tracedecay_tool +plugin._resolved_project_scope = lambda path: None profile_engine = plugin.TraceDecayContextEngine() profile_engine.on_session_start(session_id="session-1", hermes_home="/tmp/hermes") @@ -1094,7 +1097,8 @@ name, args, kwargs = calls.pop() assert name == "tracedecay_lcm_preflight" assert args["session_id"] == "session-1" assert "project_root" not in args -assert kwargs["project_root"] == os.getcwd() +assert args["storage_scope"] == "user" +assert kwargs == {} project_engine = plugin.TraceDecayContextEngine() project_engine.on_session_start( @@ -1111,7 +1115,7 @@ assert kwargs == {"project_root": "/tmp/project"} project_engine = plugin.TraceDecayContextEngine() project_engine.initialize(session_id="initial", project_root="/tmp/project") -project_engine.on_session_start(session_id="next") +project_engine.on_session_start(session_id="next", project_root="/tmp/project") project_engine.should_compress_preflight(messages=[], current_tokens=789) name, args, kwargs = calls.pop() assert name == "tracedecay_lcm_preflight" @@ -1127,7 +1131,8 @@ name, args, kwargs = calls.pop() assert name == "tracedecay_lcm_preflight" assert args["session_id"] == "next" assert "project_root" not in args -assert kwargs["project_root"] == os.getcwd() +assert args["storage_scope"] == "user" +assert kwargs == {} class LegacyCtx: def register_tool(self, *args, **kwargs): @@ -2155,6 +2160,7 @@ def fake_call_tracedecay_tool(name, args, **kwargs): return '{"content":[{"type":"text","text":"{\\"status\\":\\"ok\\"}"}]}' plugin.tools.call_tracedecay_tool = fake_call_tracedecay_tool +plugin._project_scope_available = lambda root: True provider = plugin.TracedecayMemoryProvider() provider.initialize(session_id="session-1", hermes_home="/tmp/hermes", project_root="/tmp/project") @@ -3933,9 +3939,12 @@ def fake_retrieve_call(name, args, **kwargs): if name == "tracedecay_lcm_preflight": return envelope({"truncated": True, "handle": "payload-1"}) if name == "tracedecay_retrieve": - assert args == {"handle": "payload-1"} - assert kwargs == {"project_root": "/tmp/project"} - return envelope({"content": json.dumps({"should_compress": True, "source": "retrieved"})}) + if args == {"handle": "payload-1"}: + assert kwargs == {"project_root": "/tmp/project"} + return envelope({"content": json.dumps({"should_compress": True, "source": "retrieved"})}) + assert args == {"handle": "payload-ignored"} + assert kwargs == {} + return envelope({"count": 1, "facts": [{"fact": {"content": "retrieved fact"}}]}) if name == "tracedecay_fact_store": return envelope({"truncated": True, "handle": "payload-ignored"}) raise AssertionError(f"unexpected tool call: {name}") @@ -3945,12 +3954,13 @@ retrieved = plugin.call_tracedecay_json("tracedecay_lcm_preflight", {}, project_ assert retrieved == {"should_compress": True, "source": "retrieved"} assert [call[0] for call in calls] == ["tracedecay_lcm_preflight", "tracedecay_retrieve"] -not_contract_critical = plugin.call_tracedecay_json("tracedecay_fact_store", {}) -assert not_contract_critical == {"truncated": True, "handle": "payload-ignored"} +retrieved_fact = plugin.call_tracedecay_json("tracedecay_fact_store", {}) +assert retrieved_fact == {"count": 1, "facts": [{"fact": {"content": "retrieved fact"}}]} assert [call[0] for call in calls] == [ "tracedecay_lcm_preflight", "tracedecay_retrieve", "tracedecay_fact_store", + "tracedecay_retrieve", ] plugin.tools.call_tracedecay_tool = fake_call_tracedecay_tool @@ -4367,6 +4377,7 @@ class Ctx: ctx = Ctx() plugin.register(ctx) engine = ctx.context_engines[0] +plugin._resolved_project_scope = lambda path: path # Host runtime config applies at registration time. assert engine.project_root == "/tmp/pinned-project", engine.project_root @@ -4379,6 +4390,11 @@ assert engine.project_root == "/somewhere/else", engine.project_root engine.on_session_start(session_id="s2", project_root="/explicit/root") assert engine.project_root == "/explicit/root", engine.project_root +# Returning to a prior session restores its route instead of inheriting the +# most recently active session or the host default. +engine.on_session_start(session_id="s1") +assert engine.project_root == "/somewhere/else", engine.project_root + # Without explicit runtime context, cwd is the fallback. unpinned = plugin.TraceDecayContextEngine(config={}) assert unpinned.project_root is None @@ -4492,6 +4508,7 @@ assert payload["project_root"] == "/tmp/hermes-profile", payload # Engine resolution never consults the legacy profile pin. engine = plugin.TraceDecayContextEngine(config={}) assert engine.project_root is None, engine.project_root +plugin._resolved_project_scope = lambda path: path engine.on_session_start(session_id="s1", cwd="/somewhere/else") assert engine.project_root == "/somewhere/else", engine.project_root diff --git a/tests/mcp_suite/mcp_handler_test.rs b/tests/mcp_suite/mcp_handler_test.rs index 54c3c74d6..3d06d1e4f 100644 --- a/tests/mcp_suite/mcp_handler_test.rs +++ b/tests/mcp_suite/mcp_handler_test.rs @@ -1647,11 +1647,10 @@ fn lcm_tool_schemas_are_registered_with_stable_names() { .iter() .find(|tool| tool.name == scoped) .unwrap_or_else(|| panic!("{scoped} definition")); - assert!( - tool.input_schema["properties"] - .get("storage_scope") - .is_none(), - "{scoped} must use only the active project session store" + assert_eq!( + tool.input_schema["properties"]["storage_scope"]["enum"], + json!(["project", "user"]), + "{scoped} must expose only the project and user session stores" ); assert!( tool.input_schema["properties"].get("hermes_home").is_none(), @@ -7998,6 +7997,137 @@ async fn memory_status_project_selector_reports_registered_project_memory() { ); } +#[tokio::test] +async fn user_memory_scope_is_profile_level_and_isolated_from_project_memory() { + let (active, target, _env) = setup_cross_project_memory_projects().await; + + handle_tool_call( + &active, + "tracedecay_fact_store", + json!({ + "action": "add", + "content": "Project-only routing decision", + "category": "project" + }), + None, + None, + ) + .await + .unwrap(); + handle_tool_call( + &active, + "tracedecay_fact_store", + json!({ + "action": "add", + "content": "User prefers concise technical answers", + "category": "user_pref", + "memory_scope": "user" + }), + None, + None, + ) + .await + .unwrap(); + + let project_facts = handle_tool_call( + &active, + "tracedecay_fact_store", + json!({"action": "list", "format": "json", "min_trust": 0.0}), + None, + None, + ) + .await + .unwrap(); + let user_facts = handle_tool_call( + &active, + "tracedecay_fact_store", + json!({ + "action": "list", + "format": "json", + "min_trust": 0.0, + "memory_scope": "user" + }), + None, + None, + ) + .await + .unwrap(); + let project_facts = extract_json(&project_facts.value).to_string(); + let user_facts = extract_json(&user_facts.value).to_string(); + assert!(project_facts.contains("Project-only routing decision")); + assert!(!project_facts.contains("User prefers concise technical answers")); + assert!(user_facts.contains("User prefers concise technical answers")); + assert!(!user_facts.contains("Project-only routing decision")); + + close_test_graph(target).await; + close_test_graph(active).await; +} + +#[tokio::test] +async fn hermes_live_preflight_projects_stable_turns_into_the_active_project() { + let (cg, _env, _dir) = setup_empty_project().await; + handle_tool_call( + &cg, + "tracedecay_lcm_preflight", + json!({ + "provider": "hermes", + "session_id": "hermes-live-session", + "transcript_projection": true, + "messages": [ + { + "id": "hermes-live-user-1", + "role": "user", + "content": "Correlate this Hermes turn with orchard routing", + "timestamp": 1_783_700_000.0 + }, + { + "id": "hermes-live-assistant-1", + "role": "assistant", + "content": "The turn is routed to the active project.", + "timestamp": 1_783_700_001.0 + } + ] + }), + None, + None, + ) + .await + .unwrap(); + + let search = handle_tool_call( + &cg, + "tracedecay_message_search", + json!({ + "query": "orchard routing", + "provider": "hermes", + "catch_up": false, + "format": "json" + }), + None, + None, + ) + .await + .unwrap(); + let search = extract_json(&search.value); + assert_eq!(search["count"].as_u64(), Some(1)); + assert_eq!( + search["results"][0]["session"]["session_id"], + "hermes-live-session" + ); + assert_eq!( + search["results"][0]["session"]["project_path"], + cg.project_root().to_string_lossy().as_ref() + ); + assert_eq!( + search["results"][0]["message"]["metadata_json"] + .as_str() + .and_then(|metadata| serde_json::from_str::(metadata).ok()) + .and_then(|metadata| metadata["location_provenance"].as_str().map(str::to_string)) + .as_deref(), + Some("host_live_route") + ); +} + #[tokio::test] async fn memory_fact_store_update_rejects_secret_like_content_with_diff_report() { let (cg, _env, _dir) = setup_empty_project().await; @@ -10377,12 +10507,20 @@ async fn lcm_doctor_retention_reports_candidates_without_deleting() { } #[tokio::test] -async fn lcm_tools_reject_removed_storage_routing_arguments() { +async fn lcm_tools_reject_invalid_storage_routing_arguments() { let dir = test_temp_dir(); let (cg, _env) = init_test_project(dir.path()).await; - for (removed, value) in [ - ("storage_scope", json!("hermes_profile")), - ("hermes_home", json!("/tmp/hermes")), + for (removed, value, expected) in [ + ( + "storage_scope", + json!("hermes_profile"), + "storage_scope must be one of", + ), + ( + "hermes_home", + json!("/tmp/hermes"), + "unknown parameter `hermes_home`", + ), ] { let mut args = json!({"provider": "cursor"}); args.as_object_mut() @@ -10392,12 +10530,52 @@ async fn lcm_tools_reject_removed_storage_routing_arguments() { handle_tool_call(&cg, "tracedecay_lcm_status", args, None, None).await, ); assert!( - error.contains(&format!("unknown parameter `{removed}`")), - "removed argument should fail clearly: {error}" + error.contains(expected), + "invalid {removed} should fail clearly: {error}" ); } } +#[tokio::test] +async fn user_scoped_lcm_preflight_ingests_without_a_project() { + let profile = TempDir::new().unwrap(); + let result = tracedecay::mcp::tools::handle_user_lcm_tool( + "tracedecay_lcm_preflight", + json!({ + "storage_scope": "user", + "provider": "hermes", + "session_id": "untethered-session", + "messages": [{ + "id": "untethered-message-1", + "role": "user", + "content": "Remember this general preference" + }], + "transcript_projection": true, + "format": "json" + }), + profile.path(), + ) + .await + .unwrap(); + let payload: Value = serde_json::from_str(extract_text(&result.value)).unwrap(); + assert_eq!(payload["status"], "ok"); + + let db = + GlobalDb::open_read_only_at(&tracedecay::sessions::user_sessions_db_path(profile.path())) + .await + .unwrap(); + assert!( + db.lcm_load_raw_message("hermes", "untethered-message-1") + .await + .is_some() + ); + let session = db + .get_session("hermes", "untethered-session") + .await + .unwrap(); + assert_eq!(session.project_key, "user"); +} + #[tokio::test] async fn lcm_session_handlers_expose_bounded_read_apis_and_placeholders() { let dir = test_temp_dir(); diff --git a/tests/mcp_suite/mcp_test.rs b/tests/mcp_suite/mcp_test.rs index cd6f10cb5..3178ee2c4 100644 --- a/tests/mcp_suite/mcp_test.rs +++ b/tests/mcp_suite/mcp_test.rs @@ -107,10 +107,18 @@ fn test_tool_definitions_count() { + usize::from(tracedecay::mcp::tools::ast_grep_available()); assert_eq!(tools.len(), expected); for tool in &tools { - for removed in ["storage_scope", "hermes_home"] { + assert!( + tool.input_schema["properties"].get("hermes_home").is_none(), + "{} must not expose Hermes host-home routing", + tool.name + ); + let storage_scope = tool.input_schema["properties"].get("storage_scope"); + if tool.name.starts_with("tracedecay_lcm_") { + assert_eq!(storage_scope.unwrap()["enum"], json!(["project", "user"])); + } else { assert!( - tool.input_schema["properties"].get(removed).is_none(), - "{} must not expose removed Hermes storage parameter {removed}", + storage_scope.is_none(), + "{} exposes LCM storage scope", tool.name ); } diff --git a/tests/memory_suite/memory_test.rs b/tests/memory_suite/memory_test.rs index 0257fe256..5a5c17d72 100644 --- a/tests/memory_suite/memory_test.rs +++ b/tests/memory_suite/memory_test.rs @@ -9,8 +9,8 @@ use tracedecay::memory::trust::{ DEFAULT_TRUST, apply_feedback, clamp_trust, trust_bucket, trust_distribution, }; use tracedecay::memory::types::{ - AddFactDiffKind, AddFactRequest, FactRecord, FeedbackAction, FeedbackRequest, MemoryCategory, - SearchFactsRequest, UpdateFactRequest, + AddFactDiffKind, AddFactRequest, FactRecord, FactRelationKind, FeedbackAction, FeedbackRequest, + MemoryCategory, MemoryGroomingOperation, SearchFactsRequest, UpdateFactRequest, }; use tracedecay::tracedecay::TraceDecay; @@ -21,6 +21,234 @@ async fn make_project() -> (TempDir, TraceDecay) { (tmp, cg) } +#[tokio::test] +async fn fact_relations_rewire_and_deduplicate_when_facts_merge() { + let (db, _tmp) = make_memory_store().await; + let store = MemoryStore::new(db.conn()); + let a = store + .add_fact( + fact_request("alpha relation fact", MemoryCategory::Project, 0.9), + DEFAULT_TRUST, + ) + .await + .unwrap() + .fact + .unwrap(); + let b = store + .add_fact( + fact_request("beta relation fact", MemoryCategory::Project, 0.9), + DEFAULT_TRUST, + ) + .await + .unwrap() + .fact + .unwrap(); + let c = store + .add_fact( + fact_request("gamma relation fact", MemoryCategory::Project, 0.9), + DEFAULT_TRUST, + ) + .await + .unwrap() + .fact + .unwrap(); + + store + .upsert_fact_relation( + a.fact_id, + c.fact_id, + FactRelationKind::Supports, + 0.6, + "test", + serde_json::json!({"evidence": "a"}), + ) + .await + .unwrap(); + store + .upsert_fact_relation( + b.fact_id, + c.fact_id, + FactRelationKind::Supports, + 0.8, + "test", + serde_json::json!({"evidence": "b"}), + ) + .await + .unwrap(); + + store + .merge_facts(a.fact_id, vec![b.fact_id], None) + .await + .unwrap(); + + let relations = store.list_fact_relations(None).await.unwrap(); + assert_eq!(relations.len(), 1); + assert_eq!(relations[0].source_fact_id, a.fact_id); + assert_eq!(relations[0].target_fact_id, c.fact_id); + assert_eq!(relations[0].relation, FactRelationKind::Supports); + assert_eq!(relations[0].confidence, 0.8); +} + +#[tokio::test] +async fn grooming_batch_prevalidates_before_mutating_and_rejects_conflicting_links() { + let (db, _tmp) = make_memory_store().await; + let store = MemoryStore::new(db.conn()); + let fact = store + .add_fact( + fact_request("batch validation fact", MemoryCategory::Project, 0.9), + DEFAULT_TRUST, + ) + .await + .unwrap() + .fact + .unwrap(); + let operations = vec![ + MemoryGroomingOperation::NormalizeTags { + fact_id: fact.fact_id, + tags: vec![" Needs Cleanup ".to_string()], + evidence_fact_ids: vec![fact.fact_id], + confidence: 0.9, + }, + MemoryGroomingOperation::LinkFacts { + source_fact_id: fact.fact_id, + target_fact_id: fact.fact_id, + relation: FactRelationKind::Supports, + evidence_fact_ids: vec![fact.fact_id], + confidence: 0.9, + source: "test".to_string(), + metadata: serde_json::json!({}), + }, + ]; + + assert!(store.apply_grooming_batch(&operations, 0.5).await.is_err()); + assert!( + store + .get_fact(fact.fact_id) + .await + .unwrap() + .unwrap() + .tags + .is_empty() + ); + assert!(store.list_fact_relations(None).await.unwrap().is_empty()); +} + +#[tokio::test] +async fn entity_grooming_rewires_links_supports_alias_retrieval_and_repairs_vectors() { + let (db, _tmp) = make_memory_store().await; + let store = MemoryStore::new(db.conn()); + let mut first_request = + fact_request("TraceDecay owns graph memory", MemoryCategory::Project, 0.9); + first_request.entities = vec!["TraceDecay".to_string()]; + let first = store + .add_fact(first_request, DEFAULT_TRUST) + .await + .unwrap() + .fact + .unwrap(); + let mut second_request = + fact_request("MemoryGraph stores relations", MemoryCategory::Project, 0.9); + second_request.entities = vec!["MemoryGraph".to_string()]; + let second = store + .add_fact(second_request, DEFAULT_TRUST) + .await + .unwrap() + .fact + .unwrap(); + let winner = entity_id(&db, "tracedecay").await; + let loser = entity_id(&db, "memorygraph").await; + db.conn() + .execute( + "UPDATE memory_facts SET hrr_vector = X'00', hrr_algebra = 'wrong', hrr_dim = 1, + hrr_precision = 'f64' WHERE fact_id = ?1", + libsql::params![first.fact_id], + ) + .await + .unwrap(); + + let report = store + .apply_grooming_batch( + &[ + MemoryGroomingOperation::MergeEntities { + winner_entity_id: winner, + loser_entity_ids: vec![loser], + evidence_fact_ids: vec![first.fact_id, second.fact_id], + confidence: 0.95, + }, + MemoryGroomingOperation::AddAlias { + entity_id: winner, + alias: "TD".to_string(), + evidence_fact_ids: vec![first.fact_id], + confidence: 0.95, + }, + MemoryGroomingOperation::RepairVector { + fact_id: first.fact_id, + evidence_fact_ids: vec![first.fact_id], + confidence: 1.0, + }, + ], + 0.5, + ) + .await + .unwrap(); + + assert_eq!(report.merged_entities, 1); + assert_eq!(report.aliases_added, 1); + assert!(report.derived_repair.banks_rebuilt >= 2); + assert_eq!( + scalar_i64( + &db, + "SELECT COUNT(*) FROM memory_entities WHERE entity_id = 0" + ) + .await, + 0 + ); + assert_eq!( + scalar_i64( + &db, + "SELECT COUNT(*) FROM memory_entities WHERE normalized_name = 'memorygraph'" + ) + .await, + 0 + ); + let retriever = FactRetriever::new(db.conn()); + let alias_hits = retriever.probe("TD", None, Some(0.0), 10).await.unwrap(); + assert!( + alias_hits + .iter() + .any(|hit| hit.fact.fact_id == first.fact_id) + ); + assert!( + alias_hits + .iter() + .any(|hit| hit.fact.fact_id == second.fact_id) + ); + let mut rows = db + .conn() + .query( + "SELECT hrr_algebra, hrr_dim, hrr_precision, length(hrr_vector) + FROM memory_facts WHERE fact_id = ?1", + libsql::params![first.fact_id], + ) + .await + .unwrap(); + let row = rows.next().await.unwrap().unwrap(); + assert_eq!(row.get::(0).unwrap(), "amari_fhrr"); + assert_eq!( + row.get::(1).unwrap(), + HolographicEncoder::DIMENSIONS as i64 + ); + assert_eq!( + row.get::(2).unwrap(), + HolographicEncoder::HRR_PRECISION + ); + assert_eq!( + row.get::(3).unwrap(), + HolographicEncoder::SERIALIZED_F32_BYTES as i64 + ); + assert!(dirty_bank_names(&db).await.is_empty()); +} + async fn make_memory_store() -> (Database, TempDir) { let tmp = TempDir::new().unwrap(); let db_path = tmp.path().join("tracedecay.db"); @@ -148,6 +376,18 @@ async fn dirty_bank_names(db: &Database) -> Vec { names } +async fn entity_id(db: &Database, normalized_name: &str) -> i64 { + let mut rows = db + .conn() + .query( + "SELECT entity_id FROM memory_entities WHERE normalized_name = ?1", + libsql::params![normalized_name], + ) + .await + .unwrap(); + rows.next().await.unwrap().unwrap().get::(0).unwrap() +} + async fn dirty_bank_updated_at(db: &Database, bank_name: &str) -> i64 { let mut rows = db .conn() diff --git a/tests/storage_suite/db_test.rs b/tests/storage_suite/db_test.rs index 5eb25b3a2..131367e1b 100644 --- a/tests/storage_suite/db_test.rs +++ b/tests/storage_suite/db_test.rs @@ -490,14 +490,14 @@ async fn test_migrate_v7_adds_and_backfills_attrs_start_line() { .expect("migrate failed"); assert!(migrated, "expected v7 migration to run"); - // user_version is now LATEST (= 16). + // user_version is now the latest schema version. let mut rows = conn .query("PRAGMA user_version", ()) .await .expect("read version"); let row = rows.next().await.expect("row").expect("some row"); let version: i64 = row.get(0).expect("version"); - assert_eq!(version, 17); + assert_eq!(version, 18); // attrs_start_line is backfilled from start_line for both rows. // Row a: start_line=42 -> attrs_start_line=42. diff --git a/tests/storage_suite/migration_test.rs b/tests/storage_suite/migration_test.rs index 6812cc1ca..5e2f89ad9 100644 --- a/tests/storage_suite/migration_test.rs +++ b/tests/storage_suite/migration_test.rs @@ -486,7 +486,7 @@ async fn test_create_schema_fresh_db() { .await .expect("create_schema should succeed"); - assert_eq!(get_user_version(&conn).await, 17); + assert_eq!(get_user_version(&conn).await, 18); assert_eq!( scalar_i64(&conn, "PRAGMA auto_vacuum").await, 2, @@ -507,6 +507,7 @@ async fn test_create_schema_fresh_db() { assert!(table_exists(&conn, "memory_banks").await); assert!(table_exists(&conn, "memory_bank_dirty").await); assert!(table_exists(&conn, "memory_feedback_events").await); + assert!(table_exists(&conn, "memory_fact_relations").await); assert!(table_exists(&conn, "memory_facts_fts").await); assert!(table_exists(&conn, "redundancy_pairs").await); assert!(index_exists(&conn, "idx_redundancy_pairs_node_b").await); @@ -524,7 +525,7 @@ async fn test_create_schema_idempotent() { .await .expect("second create_schema should succeed"); - assert_eq!(get_user_version(&conn).await, 17); + assert_eq!(get_user_version(&conn).await, 18); } /// migrate returns false when already at the latest version. @@ -538,7 +539,7 @@ async fn test_migrate_already_latest_returns_false() { !migrated, "migrate should return false when already at latest" ); - assert_eq!(get_user_version(&conn).await, 17); + assert_eq!(get_user_version(&conn).await, 18); } /// migrate from v0 (completely empty database) applies all migrations to latest. @@ -557,7 +558,7 @@ async fn test_migrate_from_v0() { migrated, "migrate should return true when migrations were applied" ); - assert_eq!(get_user_version(&conn).await, 17); + assert_eq!(get_user_version(&conn).await, 18); // All expected tables should exist assert!(table_exists(&conn, "nodes").await); @@ -598,7 +599,7 @@ async fn test_migrate_from_v1() { .expect("migrate from v1 should succeed"); assert!(migrated); - assert_eq!(get_user_version(&conn).await, 17); + assert_eq!(get_user_version(&conn).await, 18); // V2: metadata table assert!(table_exists(&conn, "metadata").await); @@ -634,7 +635,7 @@ async fn test_migrate_from_v2() { .expect("migrate from v2 should succeed"); assert!(migrated); - assert_eq!(get_user_version(&conn).await, 17); + assert_eq!(get_user_version(&conn).await, 18); // V3 columns assert!(column_exists(&conn, "nodes", "branches").await); @@ -664,7 +665,7 @@ async fn test_migrate_from_v3() { .expect("migrate from v3 should succeed"); assert!(migrated); - assert_eq!(get_user_version(&conn).await, 17); + assert_eq!(get_user_version(&conn).await, 18); // V4 columns assert!(column_exists(&conn, "nodes", "unsafe_blocks").await); @@ -692,7 +693,7 @@ async fn test_migrate_from_v4() { .expect("migrate from v4 should succeed"); assert!(migrated); - assert_eq!(get_user_version(&conn).await, 17); + assert_eq!(get_user_version(&conn).await, 18); assert!(index_exists(&conn, "idx_edges_unique").await); } @@ -822,7 +823,7 @@ async fn test_database_initialize_creates_latest_version() { .await .expect("Database::initialize should succeed"); - assert_eq!(get_user_version(db.conn()).await, 17); + assert_eq!(get_user_version(db.conn()).await, 18); } /// Database::open on an already-current database does not re-migrate. @@ -874,7 +875,7 @@ async fn test_database_open_migrates_v1_to_latest() { assert!(migrated, "opening a v1 database should trigger migration"); - assert_eq!(get_user_version(db.conn()).await, 17); + assert_eq!(get_user_version(db.conn()).await, 18); } /// After create_schema, all v5 columns on nodes exist. @@ -1017,7 +1018,7 @@ async fn test_v7_to_latest_upgrade_path() { let did_migrate = migrate(&conn).await.unwrap(); assert!(did_migrate, "expected migrate() to return true"); - assert_eq!(get_user_version(&conn).await, 17); + assert_eq!(get_user_version(&conn).await, 18); let mut rows = conn .query( @@ -1065,7 +1066,7 @@ async fn test_migrate_v16_adds_redundancy_pairs() { let migrated = migrate(&conn).await.expect("v16 migration should apply"); assert!(migrated, "expected migrate() to run the v16 addition"); - assert_eq!(get_user_version(&conn).await, 17); + assert_eq!(get_user_version(&conn).await, 18); assert!( table_exists(&conn, "redundancy_pairs").await, "v16 migration should create the redundancy_pairs table" @@ -1084,6 +1085,54 @@ async fn test_migrate_v16_adds_redundancy_pairs() { ); } +#[tokio::test] +async fn test_migrate_v18_preserves_memory_and_adds_bounded_relations() { + let (conn, _db, _dir) = create_schema_db().await; + conn.execute( + "INSERT INTO memory_facts (content, category, created_at, updated_at) + VALUES ('preserved fact', 'project', 11, 11)", + (), + ) + .await + .unwrap(); + conn.execute( + "INSERT INTO memory_entities (name, normalized_name, created_at, updated_at) + VALUES ('Preserved', 'preserved', 12, 12)", + (), + ) + .await + .unwrap(); + conn.execute("DROP TABLE memory_fact_relations", ()) + .await + .unwrap(); + conn.execute("ALTER TABLE memory_entities DROP COLUMN updated_at", ()) + .await + .unwrap(); + set_user_version(&conn, 17).await; + + assert!(migrate(&conn).await.unwrap()); + + assert_eq!(get_user_version(&conn).await, 18); + assert!(table_exists(&conn, "memory_fact_relations").await); + assert!(index_exists(&conn, "idx_memory_fact_relations_target").await); + assert_eq!( + scalar_i64(&conn, "SELECT COUNT(*) FROM memory_facts").await, + 1 + ); + assert_eq!( + scalar_i64(&conn, "SELECT COUNT(*) FROM memory_entities").await, + 1 + ); + assert_eq!( + scalar_i64( + &conn, + "SELECT updated_at FROM memory_entities WHERE normalized_name = 'preserved'" + ) + .await, + 12 + ); +} + #[tokio::test] async fn test_v11_create_schema_has_holographic_memory_schema() { let (conn, _db, _dir) = create_schema_db().await; @@ -1221,7 +1270,7 @@ async fn test_v10_to_v11_backfills_and_drops_legacy_memory_tables() { let did_migrate = migrate(&conn).await.expect("v10 to v11 should migrate"); assert!(did_migrate); - assert_eq!(get_user_version(&conn).await, 17); + assert_eq!(get_user_version(&conn).await, 18); assert!(!table_exists(&conn, "memory_decisions").await); assert!(!table_exists(&conn, "memory_code_areas").await); assert!(table_exists(&conn, "memory_facts").await); @@ -1241,7 +1290,7 @@ async fn test_v11_database_migrates_to_monotonic_v12() { let did_migrate = migrate(&conn).await.expect("v11 to v12 should migrate"); assert!(did_migrate); - assert_eq!(get_user_version(&conn).await, 17); + assert_eq!(get_user_version(&conn).await, 18); assert!(table_exists(&conn, "memory_bank_dirty").await); } @@ -1664,7 +1713,7 @@ async fn test_v13_drops_archive_columns_with_generated_column_dependency() { .await .expect("v13 must drop archive columns even with a generated-column dependency"); assert!(migrated, "expected migrate() to run the v13 cleanup"); - assert_eq!(get_user_version(&conn).await, 17); + assert_eq!(get_user_version(&conn).await, 18); let columns = column_names(&conn, "memory_facts").await; for col in [ @@ -1713,7 +1762,7 @@ async fn test_v14_adds_access_tracking_and_oplog() { let migrated = migrate(&conn).await.expect("v14 must apply cleanly"); assert!(migrated, "expected migrate() to run the v14 additions"); - assert_eq!(get_user_version(&conn).await, 17); + assert_eq!(get_user_version(&conn).await, 18); let columns = column_names(&conn, "memory_facts").await; for col in ["access_count", "last_recalled_at"] { @@ -1739,7 +1788,7 @@ async fn test_v14_adds_access_tracking_and_oplog() { .await .expect("v14 must be idempotent on an already-upgraded schema"); assert!(migrated_again); - assert_eq!(get_user_version(&conn).await, 17); + assert_eq!(get_user_version(&conn).await, 18); assert_eq!( scalar_i64(&conn, "SELECT COUNT(*) FROM memory_facts").await, 1 @@ -1769,7 +1818,7 @@ async fn test_v15_compacts_legacy_f64_vectors_and_enables_incremental_vacuum() { .await .expect("v15 must compact legacy vectors"); assert!(migrated); - assert_eq!(get_user_version(&conn).await, 17); + assert_eq!(get_user_version(&conn).await, 18); assert_eq!( scalar_i64(&conn, "PRAGMA auto_vacuum").await, 2, @@ -1812,7 +1861,7 @@ async fn test_v15_repairs_incremental_vacuum_when_version_already_latest() { ) .await .expect("failed to simulate pre-repair auto_vacuum mode"); - set_user_version(&conn, 17).await; + set_user_version(&conn, 18).await; assert_eq!( scalar_i64(&conn, "PRAGMA auto_vacuum").await, 0, @@ -1827,7 +1876,7 @@ async fn test_v15_repairs_incremental_vacuum_when_version_already_latest() { !migrated, "auto_vacuum repair should not report a schema migration" ); - assert_eq!(get_user_version(&conn).await, 17); + assert_eq!(get_user_version(&conn).await, 18); assert_eq!( scalar_i64(&conn, "PRAGMA auto_vacuum").await, 2, diff --git a/tests/transcript_ingest_suite/claude.rs b/tests/transcript_ingest_suite/claude.rs index 6d480eb07..d37b59bd3 100644 --- a/tests/transcript_ingest_suite/claude.rs +++ b/tests/transcript_ingest_suite/claude.rs @@ -1,6 +1,7 @@ use std::io::Write; use tempfile::TempDir; +use tracedecay::global_db::GlobalDb; use tracedecay::sessions::claude::ClaudeSource; use tracedecay::sessions::cursor::open_project_session_db; use tracedecay::sessions::git_correlation::{ @@ -59,6 +60,134 @@ fn write_claude_transcript( path } +fn write_claude_rows(home: &std::path::Path, session: &str, rows: &[serde_json::Value]) { + let dir = home.join(".claude/projects/-user-scope"); + std::fs::create_dir_all(&dir).unwrap(); + let contents = rows + .iter() + .map(serde_json::Value::to_string) + .collect::>() + .join("\n"); + std::fs::write( + dir.join(format!("{session}.jsonl")), + format!("{contents}\n"), + ) + .unwrap(); +} + +#[tokio::test] +async fn claude_user_scope_excludes_registered_project_rows() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let registered = tmp.path().join("registered"); + let general = tmp.path().join("general-chat"); + let profile = tmp.path().join("profile"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(®istered).unwrap(); + std::fs::create_dir_all(&general).unwrap(); + std::fs::create_dir_all(&profile).unwrap(); + + write_claude_rows( + &home, + "mixed-session", + &[ + serde_json::json!({ + "type": "user", "cwd": registered, "sessionId": "mixed-session", + "uuid": "project-row", "timestamp": "2026-01-01T00:00:00Z", + "message": {"role": "user", "content": "registered project secret decision"} + }), + serde_json::json!({ + "type": "assistant", "cwd": general, "sessionId": "mixed-session", + "uuid": "general-row", "timestamp": "2026-01-01T00:00:01Z", + "message": {"role": "assistant", "content": "general preference evidence"} + }), + serde_json::json!({ + "type": "user", "sessionId": "mixed-session", + "uuid": "missing-cwd-row", "timestamp": "2026-01-01T00:00:02Z", + "message": {"role": "user", "content": "registered session fallback evidence"} + }), + ], + ); + write_claude_rows( + &home, + "locationless-session", + &[serde_json::json!({ + "type": "user", "sessionId": "locationless-session", + "uuid": "locationless-row", "timestamp": "2026-01-01T00:00:03Z", + "message": {"role": "user", "content": "locationless general evidence"} + })], + ); + + let db = GlobalDb::open_at(&profile.join("user-sessions.db")) + .await + .unwrap(); + let source = ClaudeSource::with_home(&home).for_user_scope(None, vec![registered.clone()]); + let stats = ingest_source(&db, &source, &profile, None).await; + assert_eq!(stats.sessions_upserted, 2); + assert_eq!(stats.messages_upserted, 2); + assert_eq!( + db.get_session("claude", "mixed-session") + .await + .unwrap() + .project_path, + "user" + ); + assert!( + db.search_session_messages("claude", None, "registered project secret", 10) + .await + .is_empty(), + "registered-project evidence must never enter user-sessions.db" + ); + assert_eq!( + db.search_session_messages("claude", None, "preference", 10) + .await + .len(), + 1 + ); + assert_eq!( + db.search_session_messages("claude", None, "locationless", 10) + .await + .len(), + 1 + ); + assert!( + db.search_session_messages("claude", None, "registered session fallback", 10) + .await + .is_empty(), + "rows without cwd inherit the registered session cwd" + ); +} + +#[tokio::test] +async fn claude_user_scope_live_filter_only_ingests_requested_session() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let general = tmp.path().join("general-chat"); + let profile = tmp.path().join("profile"); + std::fs::create_dir_all(&general).unwrap(); + std::fs::create_dir_all(&profile).unwrap(); + for (session, content) in [("wanted", "wanted evidence"), ("other", "other evidence")] { + write_claude_rows( + &home, + session, + &[serde_json::json!({ + "type": "user", "cwd": general, "sessionId": session, + "uuid": format!("{session}-row"), "timestamp": "2026-01-01T00:00:00Z", + "message": {"role": "user", "content": content} + })], + ); + } + let db = GlobalDb::open_at(&profile.join("user-sessions.db")) + .await + .unwrap(); + let source = ClaudeSource::with_home(&home).for_user_scope(Some("wanted".into()), vec![]); + let stats = ingest_source(&db, &source, &profile, None).await; + assert_eq!(stats.sessions_upserted, 1); + assert_eq!(stats.messages_upserted, 1); + assert!(db.get_session("claude", "wanted").await.is_some()); + assert!(db.get_session("claude", "other").await.is_none()); +} + fn write_claude_subagent_transcript( home: &std::path::Path, parent_session: &str, diff --git a/tests/transcript_ingest_suite/cline_like.rs b/tests/transcript_ingest_suite/cline_like.rs index d95a7358f..efc012ced 100644 --- a/tests/transcript_ingest_suite/cline_like.rs +++ b/tests/transcript_ingest_suite/cline_like.rs @@ -511,3 +511,34 @@ async fn cline_like_task_for_other_project_is_skipped() { .await; assert_eq!(stats.messages_upserted, 0); } + +#[tokio::test] +async fn cline_like_user_scope_includes_only_unregistered_tasks() { + let tmp = TempDir::new().unwrap(); + let (home, project) = setup(&tmp); + let projectless = tmp.path().join("general-chat"); + std::fs::create_dir_all(&projectless).unwrap(); + let root = vscode_storage_root(&home, "saoudrizwan.claude-dev"); + write_task(&root, &project, "registered-task"); + write_task(&root, &projectless, "user-task"); + let mixed = write_task(&root, &project, "mixed-task"); + std::fs::write( + mixed.parent().unwrap().join("task_metadata.json"), + serde_json::json!({ + "workspacePath": project, + "otherDirectory": projectless + }) + .to_string(), + ) + .unwrap(); + + let db = open_project_session_db(&project).await.unwrap(); + let source = ClineLikeSource::cline_with_home(&home).for_user_scope(vec![project.clone()]); + let stats = ingest_source(&db, &source, tmp.path(), None).await; + assert_eq!(stats.messages_upserted, 2); + assert!(db.get_session("cline", "registered-task").await.is_none()); + assert!(db.get_session("cline", "mixed-task").await.is_none()); + let session = db.get_session("cline", "user-task").await.unwrap(); + assert_eq!(session.project_key, "user"); + assert_eq!(session.project_path, "user"); +} diff --git a/tests/transcript_ingest_suite/codex.rs b/tests/transcript_ingest_suite/codex.rs index 83a506ea1..af4e81cff 100644 --- a/tests/transcript_ingest_suite/codex.rs +++ b/tests/transcript_ingest_suite/codex.rs @@ -1,6 +1,7 @@ use std::io::Write; use tempfile::TempDir; +use tracedecay::global_db::GlobalDb; use tracedecay::global_db::ParseOffset; use tracedecay::sessions::codex::CodexSource; use tracedecay::sessions::cursor::open_project_session_db; @@ -24,6 +25,73 @@ fn write_jsonl(path: &std::path::Path, lines: &[serde_json::Value]) { .unwrap(); } +#[tokio::test] +async fn user_scope_ingests_only_codex_sessions_outside_registered_projects() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let registered = tmp.path().join("registered"); + let general = tmp.path().join("general-chat"); + std::fs::create_dir_all(®istered).unwrap(); + std::fs::create_dir_all(&general).unwrap(); + write_codex_rollout(&home, ®istered, "project-session"); + write_codex_rollout(&home, &general, "user-session"); + let db = GlobalDb::open_at(&tmp.path().join("user-sessions.db")) + .await + .unwrap(); + let source = CodexSource::with_home(&home).for_user_scope(None, vec![registered]); + + let stats = ingest_source(&db, &source, tmp.path(), None).await; + + assert_eq!(stats.sessions_upserted, 1); + assert!(db.get_session("codex", "user-session").await.is_some()); + assert!(db.get_session("codex", "project-session").await.is_none()); +} + +#[tokio::test] +async fn user_scope_excludes_codex_turns_after_switching_to_registered_project() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let registered = tmp.path().join("registered"); + let general = tmp.path().join("general-chat"); + std::fs::create_dir_all(®istered).unwrap(); + std::fs::create_dir_all(&general).unwrap(); + let path = write_codex_rollout(&home, &general, "mixed-session"); + let mut file = std::fs::OpenOptions::new().append(true).open(path).unwrap(); + writeln!( + file, + "{}", + serde_json::json!({ + "timestamp": "2026-01-01T00:00:03.000Z", + "type": "turn_context", + "payload": {"cwd": registered.to_string_lossy()} + }) + ) + .unwrap(); + writeln!( + file, + "{}", + serde_json::json!({ + "timestamp": "2026-01-01T00:00:04.000Z", + "type": "event_msg", + "payload": {"type": "agent_message", "message": "registered project secret"} + }) + ) + .unwrap(); + let db = GlobalDb::open_at(&tmp.path().join("user-sessions.db")) + .await + .unwrap(); + let source = CodexSource::with_home(&home).for_user_scope(None, vec![registered]); + + let stats = ingest_source(&db, &source, tmp.path(), None).await; + + assert!(stats.messages_upserted > 0); + assert!( + db.search_session_messages("codex", None, "registered project secret", 10) + .await + .is_empty() + ); +} + /// Writes a Codex rollout JSONL whose `session_meta.cwd` is `project`. Includes a /// `response_item` line that must be ignored (it duplicates the agent_message). fn write_codex_rollout( diff --git a/tests/transcript_ingest_suite/cursor.rs b/tests/transcript_ingest_suite/cursor.rs index c2dcb577a..f5be3d340 100644 --- a/tests/transcript_ingest_suite/cursor.rs +++ b/tests/transcript_ingest_suite/cursor.rs @@ -5,7 +5,9 @@ use tracedecay::global_db::GlobalDb; use tracedecay::hooks::cursor_pre_compact_for_event_with_config; use tracedecay::sessions::cursor::{ CursorSweepSource, cursor_project_slug, ingest_cursor_transcript_event, - ingest_cursor_transcript_event_capped, open_project_session_db, project_session_db_path, + ingest_cursor_transcript_event_capped, ingest_cursor_user_transcript_event_capped, + ingest_cursor_user_transcript_event_capped_with_registered_roots, open_project_session_db, + project_session_db_path, }; use tracedecay::sessions::cursor_agent::CursorAgentSummaryConfig; use tracedecay::sessions::lcm::{LcmDescribeRequest, LcmDescribeTarget}; @@ -48,6 +50,67 @@ fn write_cursor_parent_with_subagent(tmp: &TempDir) -> (std::path::PathBuf, std: (parent, subagent) } +#[tokio::test] +async fn projectless_cursor_event_uses_user_session_identity() { + let tmp = TempDir::new().unwrap(); + let transcript = tmp.path().join("general.jsonl"); + std::fs::write( + &transcript, + "{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Remember my general preference.\"}]}}\n", + ) + .unwrap(); + let event = serde_json::json!({ + "session_id": "general-session", + "transcript_path": transcript, + }); + let db = GlobalDb::open_at(&tmp.path().join("user-sessions.db")) + .await + .unwrap(); + + let stats = ingest_cursor_user_transcript_event_capped(&event.to_string(), &db, None).await; + + assert_eq!(stats.messages_upserted, 1); + let session = db.get_session("cursor", "general-session").await.unwrap(); + assert_eq!(session.project_path, "user"); +} + +#[tokio::test] +async fn user_cursor_event_rejects_registered_project_transcript_slug() { + let tmp = TempDir::new().unwrap(); + let registered = tmp.path().join("registered-project"); + let slug = cursor_project_slug(®istered).unwrap(); + let transcript_dir = tmp + .path() + .join(".cursor/projects") + .join(slug) + .join("agent-transcripts"); + std::fs::create_dir_all(&transcript_dir).unwrap(); + let transcript = transcript_dir.join("project-session.jsonl"); + std::fs::write( + &transcript, + "{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"registered project secret\"}]}}\n", + ) + .unwrap(); + let event = serde_json::json!({ + "session_id": "project-session", + "transcript_path": transcript, + }); + let db = GlobalDb::open_at(&tmp.path().join("user-sessions.db")) + .await + .unwrap(); + + let stats = ingest_cursor_user_transcript_event_capped_with_registered_roots( + &event.to_string(), + &db, + None, + &[registered], + ) + .await; + + assert_eq!(stats.messages_upserted, 0); + assert!(db.get_session("cursor", "project-session").await.is_none()); +} + #[tokio::test] // Intentional: this test pins process-wide HOME/TRACEDECAY_GLOBAL_DB while the // hook resolves its storage paths. diff --git a/tests/transcript_ingest_suite/hermes.rs b/tests/transcript_ingest_suite/hermes.rs index c02aa19f4..7f4b0c714 100644 --- a/tests/transcript_ingest_suite/hermes.rs +++ b/tests/transcript_ingest_suite/hermes.rs @@ -8,9 +8,10 @@ use std::path::{Path, PathBuf}; use serde_json::json; use tempfile::TempDir; +use tracedecay::global_db::GlobalDb; use tracedecay::sessions::SessionRecord; use tracedecay::sessions::cursor::open_project_session_db; -use tracedecay::sessions::hermes::{ingest_for_project, ingest_homes}; +use tracedecay::sessions::hermes::{ingest_for_project, ingest_homes, ingest_user_homes}; use tracedecay::sessions::lcm::LcmPreflightRequest; use crate::support::{assert_metadata_path_eq, create_git_repo_with_linked_worktree}; @@ -18,8 +19,14 @@ use crate::support::{assert_metadata_path_eq, create_git_repo_with_linked_worktr const SESSION_ID: &str = "20260101_000000_abc123"; #[tokio::test] +// Intentional: this test changes HOME/USERPROFILE/HERMES_HOME while storage +// discovery is running, so it must share the profile-environment lock used by +// Cursor's transcript tests. +#[allow(clippy::await_holding_lock)] async fn hermes_home_env_cannot_redirect_runtime_session_discovery() { - let _lock = crate::common::PROCESS_ENV_LOCK.lock().await; + let _lock = crate::common::GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()); let tmp = TempDir::new().unwrap(); let (standard_hermes_home, project) = setup(&tmp); let user_home = standard_hermes_home.parent().unwrap(); @@ -611,3 +618,173 @@ async fn unpinned_profile_uses_session_cwd_as_project_provenance() { ); assert_metadata_path_eq(&metadata["hermes_session_cwd"], &project); } + +#[tokio::test] +async fn unpinned_projectless_session_routes_only_a_tool_proven_turn() { + let tmp = TempDir::new().unwrap(); + let (hermes_home, project) = setup(&tmp); + let state_db = write_hermes_profile(&hermes_home, "test", None).await; + let conn = open_state_db(&state_db).await; + let tool_calls = json!([{ + "id": "call_project_context", + "type": "function", + "function": { + "name": "tracedecay_context", + "arguments": json!({ + "project_path": project.to_string_lossy(), + "query": "billing pipeline" + }).to_string() + } + }]) + .to_string(); + conn.execute( + "UPDATE messages SET tool_calls = ?1 + WHERE session_id = ?2 AND tool_calls IS NOT NULL", + libsql::params![tool_calls, SESSION_ID], + ) + .await + .unwrap(); + + let db = open_project_session_db(&project).await.unwrap(); + let stats = ingest_homes(&db, std::slice::from_ref(&hermes_home), &project).await; + assert_eq!(stats.messages_upserted, 4); + let session = db + .get_session("hermes", SESSION_ID) + .await + .expect("structured tool project path should prove turn association"); + let metadata: serde_json::Value = + serde_json::from_str(session.metadata_json.as_deref().unwrap()).unwrap(); + assert_eq!( + metadata["hermes_session_location_provenance"], + "tool_project_path" + ); + assert_metadata_path_eq(&metadata["hermes_session_cwd"], &project); +} + +#[tokio::test] +async fn explicit_tool_route_overrides_session_cwd_without_cross_project_duplication() { + let tmp = TempDir::new().unwrap(); + let (hermes_home, session_project) = setup(&tmp); + let tool_project = tmp.path().join("tool-project"); + crate::support::init_project_at(&tool_project); + let state_db = write_hermes_profile(&hermes_home, "test", None).await; + let conn = open_state_db(&state_db).await; + conn.execute( + "UPDATE sessions SET cwd = ?1 WHERE id = ?2", + libsql::params![session_project.to_string_lossy().as_ref(), SESSION_ID], + ) + .await + .unwrap(); + let tool_calls = json!([{ + "id": "call_other_project", + "type": "function", + "function": { + "name": "tracedecay_context", + "arguments": json!({ + "project_path": tool_project.to_string_lossy(), + "query": "other project" + }).to_string() + } + }]) + .to_string(); + conn.execute( + "UPDATE messages SET tool_calls = ?1 + WHERE session_id = ?2 AND tool_calls IS NOT NULL", + libsql::params![tool_calls, SESSION_ID], + ) + .await + .unwrap(); + + let session_db = open_project_session_db(&session_project).await.unwrap(); + let session_stats = ingest_homes( + &session_db, + std::slice::from_ref(&hermes_home), + &session_project, + ) + .await; + assert_eq!(session_stats.messages_upserted, 0); + assert!(session_db.get_session("hermes", SESSION_ID).await.is_none()); + + let tool_db = open_project_session_db(&tool_project).await.unwrap(); + let tool_stats = + ingest_homes(&tool_db, std::slice::from_ref(&hermes_home), &tool_project).await; + assert_eq!(tool_stats.messages_upserted, 4); + let session = tool_db + .get_session("hermes", SESSION_ID) + .await + .expect("explicit tool route should associate the turn with its project"); + let metadata: serde_json::Value = + serde_json::from_str(session.metadata_json.as_deref().unwrap()).unwrap(); + assert_eq!( + metadata["hermes_session_location_provenance"], + "tool_project_path" + ); + assert_metadata_path_eq(&metadata["hermes_session_cwd"], &tool_project); +} + +#[tokio::test] +async fn user_sweep_excludes_turns_explicitly_routed_to_registered_projects() { + let tmp = TempDir::new().unwrap(); + let (hermes_home, registered) = setup(&tmp); + let state_db = write_hermes_profile(&hermes_home, "test", None).await; + let conn = open_state_db(&state_db).await; + let tool_calls = json!([{ + "function": { + "name": "tracedecay_context", + "arguments": json!({"project_path": registered.to_string_lossy()}).to_string() + } + }]) + .to_string(); + conn.execute( + "UPDATE messages SET tool_calls = ?1 WHERE session_id = ?2 AND tool_calls IS NOT NULL", + libsql::params![tool_calls, SESSION_ID], + ) + .await + .unwrap(); + let user_db = GlobalDb::open_at(&tmp.path().join("user-sessions.db")) + .await + .unwrap(); + + let stats = ingest_user_homes( + &user_db, + std::slice::from_ref(&hermes_home), + std::slice::from_ref(®istered), + ) + .await; + + assert_eq!(stats.messages_upserted, 0); + assert!(user_db.get_session("hermes", SESSION_ID).await.is_none()); +} + +#[tokio::test] +async fn user_sweep_excludes_registered_session_cwd_without_tool_routes() { + let tmp = TempDir::new().unwrap(); + let (hermes_home, registered) = setup(&tmp); + let state_db = write_hermes_profile(&hermes_home, "test", None).await; + let conn = open_state_db(&state_db).await; + conn.execute( + "UPDATE sessions SET cwd = ?1 WHERE id = ?2", + libsql::params![registered.to_string_lossy().as_ref(), SESSION_ID], + ) + .await + .unwrap(); + conn.execute( + "UPDATE messages SET tool_calls = NULL WHERE session_id = ?1", + [SESSION_ID], + ) + .await + .unwrap(); + let user_db = GlobalDb::open_at(&tmp.path().join("user-sessions.db")) + .await + .unwrap(); + + let stats = ingest_user_homes( + &user_db, + std::slice::from_ref(&hermes_home), + std::slice::from_ref(®istered), + ) + .await; + + assert_eq!(stats.messages_upserted, 0); + assert!(user_db.get_session("hermes", SESSION_ID).await.is_none()); +} diff --git a/tests/transcript_ingest_suite/kiro.rs b/tests/transcript_ingest_suite/kiro.rs index eddf0eb50..420a8c6d6 100644 --- a/tests/transcript_ingest_suite/kiro.rs +++ b/tests/transcript_ingest_suite/kiro.rs @@ -100,6 +100,40 @@ fn write_workspace_session_json( path } +fn write_extensionless_execution( + home: &std::path::Path, + project: &std::path::Path, + workspace_hash: &str, + session_id: &str, +) -> std::path::PathBuf { + let data_dir = tracedecay::agents::kiro_data_dir(home); + let ws_storage = data_dir.join("User/workspaceStorage").join(workspace_hash); + std::fs::create_dir_all(&ws_storage).unwrap(); + std::fs::write( + ws_storage.join("workspace.json"), + serde_json::json!({"folder": format!("file://{}", project.display())}).to_string(), + ) + .unwrap(); + let path = data_dir + .join("User/globalStorage/kiro.kiroagent") + .join(workspace_hash) + .join(session_id); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + serde_json::json!({ + "sessionId": session_id, + "messages": [ + {"role": "user", "content": "Remember my preferred review style"}, + {"role": "assistant", "content": "I will remember that preference."} + ] + }) + .to_string(), + ) + .unwrap(); + path +} + #[tokio::test] async fn kiro_legacy_chat_populates_searchable_messages() { let tmp = TempDir::new().unwrap(); @@ -250,3 +284,35 @@ async fn kiro_transcript_for_other_project_is_skipped() { 0 ); } + +#[tokio::test] +async fn kiro_user_scope_includes_only_unregistered_sessions() { + let tmp = TempDir::new().unwrap(); + let (home, project) = setup(&tmp); + let projectless = tmp.path().join("general-chat"); + std::fs::create_dir_all(&projectless).unwrap(); + write_legacy_chat( + &home, + &project, + "cccccccccccccccccccccccccccccccc", + "registered-exec", + ); + write_workspace_session_json(&home, &projectless, "user-kiro"); + write_extensionless_execution( + &home, + &projectless, + "dddddddddddddddddddddddddddddddd", + "user-extensionless", + ); + + let db = open_project_session_db(&project).await.unwrap(); + let source = KiroSource::with_home(&home).for_user_scope(vec![project.clone()]); + let stats = ingest_source(&db, &source, tmp.path(), None).await; + assert_eq!(stats.messages_upserted, 4); + assert!(db.get_session("kiro", "kiro-workflow-1").await.is_none()); + let session = db.get_session("kiro", "user-kiro").await.unwrap(); + assert_eq!(session.project_key, "user"); + assert_eq!(session.project_path, "user"); + let extensionless = db.get_session("kiro", "user-extensionless").await.unwrap(); + assert_eq!(extensionless.project_key, "user"); +} diff --git a/tests/transcript_ingest_suite/vibe.rs b/tests/transcript_ingest_suite/vibe.rs index 5206b2847..cf0c0c2b4 100644 --- a/tests/transcript_ingest_suite/vibe.rs +++ b/tests/transcript_ingest_suite/vibe.rs @@ -2,7 +2,7 @@ use std::io::Write; use tempfile::TempDir; use tracedecay::sessions::cursor::open_project_session_db; -use tracedecay::sessions::source::ingest_source; +use tracedecay::sessions::source::{TranscriptSource, ingest_source}; use tracedecay::sessions::vibe::VibeSource; use crate::support::{assert_metadata_path_eq, create_git_repo_with_linked_worktree, setup}; @@ -180,3 +180,35 @@ async fn vibe_session_for_other_project_is_skipped() { 0 ); } + +#[tokio::test] +async fn vibe_user_scope_includes_only_unregistered_sessions() { + let tmp = TempDir::new().unwrap(); + let (home, project) = setup(&tmp); + let projectless = tmp.path().join("general-chat"); + std::fs::create_dir_all(&projectless).unwrap(); + write_vibe_session(&home, &project, "registered-vibe"); + write_vibe_session(&home, &projectless, "user-vibe"); + + let db = open_project_session_db(&project).await.unwrap(); + let source = VibeSource::with_home(&home).for_user_scope(vec![project.clone()]); + let stats = ingest_source(&db, &source, tmp.path(), None).await; + assert_eq!(stats.messages_upserted, 2); + assert!(db.get_session("vibe", "registered-vibe").await.is_none()); + let session = db.get_session("vibe", "user-vibe").await.unwrap(); + assert_eq!(session.project_key, "user"); + assert_eq!(session.project_path, "user"); +} + +#[test] +fn vibe_history_enumeration_is_bounded() { + let tmp = TempDir::new().unwrap(); + let home = tmp.path(); + for index in 0..513 { + let dir = home.join(format!(".vibe/logs/session/session-{index:04}")); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("messages.jsonl"), "").unwrap(); + } + let source = VibeSource::with_home(home); + assert_eq!(source.transcript_paths(home).len(), 512); +}