From 1b0736cad6edfcbd7a82327d775927cbcbc76a04 Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:33:02 +0400 Subject: [PATCH 1/2] memory: restore basic_memory fallback for Claude hooks; add Codex/OMP capture and bounded reindex --- memory/hooks/README-codex-omp.md | 42 ++++ memory/hooks/common.sh | 82 ++++++++ memory/hooks/omp-memory.ts | 65 +++++++ memory/hooks/session-end.sh | 34 +--- memory/hooks/session-start.sh | 5 +- memory/hooks/stop.sh | 19 +- memory/lib/basic_memory.py | 8 +- memory/tests/test_basic_memory_hooks.py | 242 ++++++++++++++++++++++++ 8 files changed, 469 insertions(+), 28 deletions(-) create mode 100644 memory/hooks/README-codex-omp.md create mode 100644 memory/hooks/omp-memory.ts diff --git a/memory/hooks/README-codex-omp.md b/memory/hooks/README-codex-omp.md new file mode 100644 index 0000000..3a94719 --- /dev/null +++ b/memory/hooks/README-codex-omp.md @@ -0,0 +1,42 @@ +# Codex and OMP/Pi memory capture + +Claude Code capture is wired automatically by `dotagents setup`. Codex and +OMP/Pi have no session-end hook that dotagents installs for them, so their +automatic capture is opt-in and wired manually. Both route through the same +`session-end.sh` dispatcher, which classifies a Codex/OMP payload and writes the +local `basic_memory` digest — the same digest Claude produces. + +Reminder for both harnesses: automatic digests are a safety net, not a +substitute for deliberate capture. Record durable cross-harness facts explicitly +with `rem add -src codex ""` / `rem add -src omp ""`. + +## Codex + +Codex emits Claude-compatible Stop/SessionEnd payloads (with `transcript_path` +and `model`). Add a hook to `~/.codex/hooks.json` on `SessionEnd` (preferred) or +`Stop`, setting `DOTAGENTS_MEMORY_SOURCE=codex` so the digest is labelled and so +the `Stop`-wired path captures rather than passing through: + +```json +{ + "type": "command", + "command": "DOTAGENTS_MEMORY_SOURCE=codex ~/.agents/memory/hooks/session-end.sh", + "timeout": 30 +} +``` + +Reminder: also `rem add -src codex ""` for facts worth promoting. + +## OMP / Pi + +OMP/Pi has no session-end hook but supports extensions that fire on +`agent_end`. Install the shipped extension: + +```sh +cp ~/.agents/memory/hooks/omp-memory.ts ~/.omp/agent/extensions/ +``` + +It pipes an `agent: "omp"` payload into `session-end.sh` on every `agent_end`. +Set `DOTAGENTS_MEMORY_HOOKS` if your dotagents checkout is not at `~/.agents`. + +Reminder: also `rem add -src omp ""` for facts worth promoting. diff --git a/memory/hooks/common.sh b/memory/hooks/common.sh index 860b64e..6f1f3ec 100755 --- a/memory/hooks/common.sh +++ b/memory/hooks/common.sh @@ -52,3 +52,85 @@ index_memory_top_level() { done memsearch index "$@" --collection "$MEMSEARCH_COLLECTION" >/dev/null 2>&1 } + +# Best-effort vault reindex fired after a session digest is written. It never +# blocks the hook (all work is backgrounded), is bounded by a watchdog so a +# hung memsearch can't run forever, and refuses to overlap a refresh that is +# already running via an atomic mkdir lock. No-op when memsearch is absent. +refresh_index_async() { + command -v memsearch >/dev/null 2>&1 || return 0 + prepare_memory_index_env + + reindex_lock="${MEMSEARCH_STATE_DIR%/}/reindex.lock" + # mkdir is atomic: it fails when a refresh already holds the lock, so we never + # spawn overlapping reindexers. + if ! mkdir "$reindex_lock" 2>/dev/null; then + return 0 + fi + + ( + trap 'rmdir "$reindex_lock" 2>/dev/null || true' EXIT + set -- "$NOTES_DIR" "$PROFILE_DIR" + for path in "$SESSIONS_DIR"/*.md "$SESSIONS_DIR"/*.markdown; do + [ -f "$path" ] && set -- "$@" "$path" + done + memsearch index "$@" --collection "$MEMSEARCH_COLLECTION" >/dev/null 2>&1 & + index_pid=$! + ( sleep "${MEMSEARCH_REINDEX_TIMEOUT:-120}"; kill "$index_pid" 2>/dev/null || true ) & + watchdog_pid=$! + wait "$index_pid" 2>/dev/null || true + kill "$watchdog_pid" 2>/dev/null || true + wait "$watchdog_pid" 2>/dev/null || true + ) >/dev/null 2>&1 & + + return 0 +} + +# Classify a hook payload file into a dispatch kind. Codex and OMP capture route +# through the local basic_memory digest (never the Claude plugin), detected from +# an explicit source hint or the payload's own agent/platform marker. +classify_payload() { + MEMORY_SOURCE_HINT="${DOTAGENTS_MEMORY_SOURCE:-}" python3 - "$1" <<'PY' +import os +import json +import sys +from pathlib import Path + +try: + data = json.load(open(sys.argv[1])) +except Exception: + print("unknown") + raise SystemExit + +hint = (os.environ.get("MEMORY_SOURCE_HINT") or "").strip().lower() +agent = str(data.get("agent") or data.get("platform") or "").strip().lower() +transcript = os.path.expanduser(str(data.get("transcript_path") or "")) + +if hint in {"codex", "omp"}: + print(hint) +elif agent in {"codex", "omp"}: + print(agent) +elif transcript and "/.factory/" in transcript and Path(transcript).suffix == ".jsonl": + print("factory-jsonl") +elif data.get("platform") == "amp" or data.get("amp_thread_id"): + print("amp-json") +elif data.get("session_id") and not transcript: + print("hermes-json") +else: + print("claude-plugin") +PY +} + +# Write a local basic_memory digest for the payload, then fire a bounded, +# non-overlapping reindex only when a new digest was actually appended. Used by +# the Claude fallback and by Codex/OMP capture so the logic lives in one place. +dispatch_basic_digest() { + if digest_output="$(python3 "$MEMORY_DIR/hooks/basic-session-end.py" <"$1")"; then + printf '%s\n' "$digest_output" + case "$digest_output" in + *'"systemMessage":"basic memory appended'*) refresh_index_async ;; + esac + else + printf '{"continue":true,"suppressOutput":true}\n' + fi +} diff --git a/memory/hooks/omp-memory.ts b/memory/hooks/omp-memory.ts new file mode 100644 index 0000000..038b017 --- /dev/null +++ b/memory/hooks/omp-memory.ts @@ -0,0 +1,65 @@ +// dotagents OMP/Pi memory extension. +// +// OMP/Pi exposes no session-end hook of its own, so this extension captures a +// basic_memory digest on `agent_end` by piping an OMP-tagged payload into the +// shared session-end dispatch (memory/hooks/session-end.sh), which classifies +// `agent: "omp"` and writes the same local digest Claude and Codex use. +// +// Manual wiring (dotagents does not install OMP extensions automatically): +// cp ~/.agents/memory/hooks/omp-memory.ts ~/.omp/agent/extensions/ +// Override the hook location with DOTAGENTS_MEMORY_HOOKS if your dotagents +// checkout is not at ~/.agents. +// +// Reminder: automatic digests are a net, not a replacement. Capture deliberate +// cross-harness facts explicitly with: rem add -src omp "" +import { spawn } from "node:child_process"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { AgentEndEvent, ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent"; + +function hooksDir(): string { + return process.env.DOTAGENTS_MEMORY_HOOKS || path.join(os.homedir(), ".agents", "memory", "hooks"); +} + +function digestMessages(event: AgentEndEvent): Array<{ role: string; content: unknown }> { + const messages: Array<{ role: string; content: unknown }> = []; + for (const message of event.messages || []) { + if (!message || typeof message !== "object") continue; + const typed = message as { role?: unknown; content?: unknown }; + if (typeof typed.role !== "string" || typed.content == null) continue; + messages.push({ role: typed.role, content: typed.content }); + } + return messages; +} + +function captureDigest(ctx: ExtensionContext, event: AgentEndEvent): void { + const sessionId = ctx.sessionManager.getSessionId(); + if (!sessionId) return; + const cwd = ctx.cwd || process.cwd(); + const payload = JSON.stringify({ + agent: "omp", + hook_event_name: "SessionEnd", + session_id: sessionId, + cwd, + messages: digestMessages(event), + }); + try { + const child = spawn(path.join(hooksDir(), "session-end.sh"), [], { + env: { ...process.env, DOTAGENTS_MEMORY_SOURCE: "omp" }, + stdio: ["pipe", "ignore", "ignore"], + detached: true, + }); + child.on("error", () => {}); + child.unref(); + child.stdin.on("error", () => {}); + child.stdin.end(payload); + } catch (_) { + // Best-effort: never let memory capture break the session. + } +} + +export default function dotagentsOmpMemoryExtension(api: ExtensionAPI) { + api.on("agent_end", async (event, ctx) => { + captureDigest(ctx, event); + }); +} diff --git a/memory/hooks/session-end.sh b/memory/hooks/session-end.sh index 7db6064..7631594 100755 --- a/memory/hooks/session-end.sh +++ b/memory/hooks/session-end.sh @@ -15,29 +15,7 @@ payload="$(mktemp)" trap 'rm -f "$payload"' EXIT cat >"$payload" -kind="$(python3 - "$payload" <<'PY' -import os -import json -import sys -from pathlib import Path - -try: - data = json.load(open(sys.argv[1])) -except Exception: - print("unknown") - raise SystemExit - -transcript = os.path.expanduser(str(data.get("transcript_path") or "")) -if transcript and "/.factory/" in transcript and Path(transcript).suffix == ".jsonl": - print("factory-jsonl") -elif data.get("platform") == "amp" or data.get("amp_thread_id"): - print("amp-json") -elif data.get("session_id") and not transcript: - print("hermes-json") -else: - print("claude-plugin") -PY -)" +kind="$(classify_payload "$payload")" case "$kind" in amp-json) @@ -49,12 +27,20 @@ case "$kind" in hermes-json) python3 "$MEMORY_DIR/lib/hermes_digest.py" <"$payload" ;; + codex|omp) + # Codex/OMP capture always uses the local basic_memory digest. + dispatch_basic_digest "$payload" + ;; *) plugin_dir="$(resolve_claude_memory_plugin || true)" if [ -n "$plugin_dir" ]; then bash "$plugin_dir/hooks/session-end.sh" <"$payload" >/dev/null 2>&1 || true index_memory_top_level || true + printf '{"continue":true,"suppressOutput":true}\n' + else + # No memsearch claude-code plugin (e.g. memsearch 0.2.x): fall back to the + # local basic_memory digest so Claude capture keeps working. + dispatch_basic_digest "$payload" fi - printf '{"continue":true,"suppressOutput":true}\n' ;; esac diff --git a/memory/hooks/session-start.sh b/memory/hooks/session-start.sh index 0431853..81aa7fe 100755 --- a/memory/hooks/session-start.sh +++ b/memory/hooks/session-start.sh @@ -11,8 +11,9 @@ fi load_memory_config plugin_dir="$(resolve_claude_memory_plugin || true)" if [ -z "$plugin_dir" ]; then - printf '{"continue":true,"suppressOutput":true}\n' - exit 0 + # No memsearch claude-code plugin: fall back to the local basic_memory + # implementation so recent session digests are injected as additionalContext. + exec python3 "$MEMORY_DIR/hooks/basic-session-start.py" fi prepare_memory_index_env diff --git a/memory/hooks/stop.sh b/memory/hooks/stop.sh index 388f349..3a86ce2 100755 --- a/memory/hooks/stop.sh +++ b/memory/hooks/stop.sh @@ -9,6 +9,23 @@ if [ "${MEMSEARCH_SKIP_CLAUDE_HOOKS:-}" = "1" ]; then fi load_memory_config + +payload="$(mktemp)" +trap 'rm -f "$payload"' EXIT +cat >"$payload" + +# Codex/OMP capture may be wired to their Stop event (they expose no reliable +# session-end hook); route those to the local basic_memory digest. Claude Stop +# fires once per response, so it stays a clean continuation and lets the Claude +# SessionEnd hook own the full-session digest. +kind="$(classify_payload "$payload")" +case "$kind" in + codex|omp) + dispatch_basic_digest "$payload" + exit 0 + ;; +esac + plugin_dir="$(resolve_claude_memory_plugin || true)" if [ -z "$plugin_dir" ]; then printf '{"continue":true,"suppressOutput":true}\n' @@ -17,4 +34,4 @@ fi prepare_memory_index_env export MEMSEARCH_SKIP_CLAUDE_HOOKS=1 -exec bash "$plugin_dir/hooks/stop.sh" +exec bash "$plugin_dir/hooks/stop.sh" <"$payload" diff --git a/memory/lib/basic_memory.py b/memory/lib/basic_memory.py index 4a922bc..4899ae1 100755 --- a/memory/lib/basic_memory.py +++ b/memory/lib/basic_memory.py @@ -303,7 +303,13 @@ def build_digest(payload: dict[str, Any], messages: list[dict[str, Any]], starte users = collect_user_turns(messages) assistant = last_assistant_text(messages) paths = extract_paths(messages, payload) - platform = payload.get("platform") or payload.get("agent") or payload.get("hook_event_name") or "basic" + platform = ( + payload.get("platform") + or payload.get("agent") + or os.environ.get("DOTAGENTS_MEMORY_SOURCE") + or payload.get("hook_event_name") + or "basic" + ) model = payload.get("model") lines = [] diff --git a/memory/tests/test_basic_memory_hooks.py b/memory/tests/test_basic_memory_hooks.py index 344042e..b935e57 100644 --- a/memory/tests/test_basic_memory_hooks.py +++ b/memory/tests/test_basic_memory_hooks.py @@ -19,6 +19,8 @@ FACTORY_DIGEST = MEMORY_DIR / "lib" / "factory_digest.py" HERMES_DIGEST = MEMORY_DIR / "lib" / "hermes_digest.py" SESSION_END_HOOK = MEMORY_DIR / "hooks" / "session-end.sh" +SESSION_START_HOOK = MEMORY_DIR / "hooks" / "session-start.sh" +STOP_HOOK = MEMORY_DIR / "hooks" / "stop.sh" class BasicMemoryHookTests(unittest.TestCase): @@ -591,5 +593,245 @@ def test_dream_truncates_legacy_evidence_beyond_twenty_occurrences(self): self.assertEqual(len(candidate["evidence"]), 20) self.assertEqual(candidate["evidence_omitted"], 1) +class ClaudeFallbackAndDispatchTests(unittest.TestCase): + """R1 fallback, R7 Codex/OMP capture, and R2 bounded reindex.""" + + def run_shell(self, hook: Path, payload: object, *, env: dict[str, str], raw: str | None = None): + stdin = raw if raw is not None else json.dumps(payload) + return subprocess.run( + ["/bin/bash", str(hook)], + input=stdin, + text=True, + capture_output=True, + env=env, + check=False, + ) + + def base_env(self, knowledge: Path, extra: dict[str, str] | None = None) -> dict[str, str]: + env = os.environ.copy() + env["KNOWLEDGE_DIR"] = str(knowledge) + env["MEMSEARCH_STATE_DIR"] = str(knowledge / "state") + # A scratch collection keeps any stray reindex away from the real index. + env["MEMSEARCH_COLLECTION"] = "test-scratch" + # Point the plugin resolver at a directory that does not exist so the + # Claude fallback is exercised (memsearch 0.2.x ships no claude-code plugin). + env["MEMSEARCH_PLUGIN_DIR"] = str(knowledge / "no-such-plugin") + if extra: + env.update(extra) + return env + + def fake_memsearch(self, fake_bin: Path, *, log: Path, sleep: float = 0.0, done_marker: Path | None = None) -> None: + fake_bin.mkdir(parents=True, exist_ok=True) + lines = ["#!/bin/sh", f'printf "%s\\n" "$*" >> "{log}"'] + if sleep: + lines.append(f"sleep {sleep}") + if done_marker is not None: + lines.append(f'printf "done\\n" >> "{done_marker}"') + lines.append("exit 0") + script = fake_bin / "memsearch" + script.write_text("\n".join(lines) + "\n", encoding="utf-8") + script.chmod(script.stat().st_mode | stat.S_IXUSR) + + def with_fake_memsearch_path(self, env: dict[str, str], fake_bin: Path) -> dict[str, str]: + env["PATH"] = str(fake_bin) + os.pathsep + env.get("PATH", "") + return env + + def claude_payload(self, tmp_path: Path, session_id: str, first: str = "remember ripgrep over grep") -> dict: + transcript = tmp_path / f"{session_id}.jsonl" + transcript.write_text( + "\n".join( + [ + json.dumps({"type": "session_start", "session_id": session_id, "timestamp": "2026-09-09T16:36:00Z"}), + json.dumps({"type": "message", "timestamp": "2026-09-09T16:36:01Z", "message": {"role": "user", "content": first}}), + json.dumps({"type": "message", "timestamp": "2026-09-09T16:36:05Z", "message": {"role": "assistant", "content": "Noted."}}), + ] + ) + + "\n", + encoding="utf-8", + ) + return { + "hook_event_name": "SessionEnd", + "session_id": session_id, + "transcript_path": str(transcript), + "model": "claude-opus-4-8", + } + + def wait_for(self, predicate, timeout: float = 6.0, interval: float = 0.1) -> bool: + import time + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(interval) + return predicate() + + def sole_digest(self, knowledge: Path) -> str: + files = sorted((knowledge / "sessions").glob("*.md")) + self.assertEqual(len(files), 1, files) + return files[0].read_text(encoding="utf-8") + + def test_plugin_present_path_delegates_and_skips_basic_digest(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + knowledge = tmp_path / "knowledge" + plugin_root = tmp_path / "plugin" + (plugin_root / "hooks").mkdir(parents=True) + plugin_marker = tmp_path / "plugin-ran" + plugin_end = plugin_root / "hooks" / "session-end.sh" + plugin_end.write_text(f'#!/bin/sh\nprintf "ran\\n" > "{plugin_marker}"\nexit 0\n', encoding="utf-8") + plugin_end.chmod(plugin_end.stat().st_mode | stat.S_IXUSR) + + fake_bin = tmp_path / "bin" + log = tmp_path / "memsearch.log" + self.fake_memsearch(fake_bin, log=log) + env = self.with_fake_memsearch_path( + self.base_env(knowledge, {"MEMSEARCH_PLUGIN_DIR": str(plugin_root)}), fake_bin + ) + + result = self.run_shell(SESSION_END_HOOK, self.claude_payload(tmp_path, "plugin-present"), env=env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), {"continue": True, "suppressOutput": True}) + self.assertTrue(plugin_marker.exists(), "plugin session-end.sh should run when the plugin resolves") + self.assertEqual(list((knowledge / "sessions").glob("*.md")), [], "basic digest must not be written on the plugin path") + + def test_plugin_missing_fallback_writes_claude_digest(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + knowledge = tmp_path / "knowledge" + env = self.base_env(knowledge) # no memsearch on PATH -> reindex is a no-op + + result = self.run_shell(SESSION_END_HOOK, self.claude_payload(tmp_path, "claude-fallback"), env=env) + self.assertEqual(result.returncode, 0, result.stderr) + output = json.loads(result.stdout) + self.assertTrue(output["continue"]) + self.assertIn("basic memory appended", output["systemMessage"]) + digest = self.sole_digest(knowledge) + self.assertIn("basic-memory-session:claude-fallback:start", digest) + self.assertIn("remember ripgrep over grep", digest) + + def test_plugin_missing_session_start_injects_context(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + knowledge = tmp_path / "knowledge" + sessions = knowledge / "sessions" + sessions.mkdir(parents=True) + (sessions / "2026-09-09.md").write_text("## Session digest\n- first request: prefer ripgrep\n", encoding="utf-8") + + result = self.run_shell(SESSION_START_HOOK, {"hook_event_name": "SessionStart"}, env=self.base_env(knowledge)) + self.assertEqual(result.returncode, 0, result.stderr) + output = json.loads(result.stdout) + self.assertEqual(output["hookSpecificOutput"]["hookEventName"], "SessionStart") + self.assertIn("prefer ripgrep", output["hookSpecificOutput"]["additionalContext"]) + + def test_codex_payload_classifies_to_basic_digest_labeled_codex(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + knowledge = tmp_path / "knowledge" + env = self.base_env(knowledge, {"DOTAGENTS_MEMORY_SOURCE": "codex"}) + payload = self.claude_payload(tmp_path, "codex-1", first="codex remember this") + payload["model"] = "gpt-5-codex" + + result = self.run_shell(SESSION_END_HOOK, payload, env=env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("basic memory appended", json.loads(result.stdout)["systemMessage"]) + digest = self.sole_digest(knowledge) + self.assertIn("- source: codex; model: gpt-5-codex", digest) + self.assertIn("codex remember this", digest) + + def test_omp_payload_via_stop_hook_writes_basic_digest_labeled_omp(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + knowledge = tmp_path / "knowledge" + payload = { + "hook_event_name": "Stop", + "agent": "omp", + "session_id": "omp-1", + "messages": [{"role": "user", "content": "omp remember this"}], + } + result = self.run_shell(STOP_HOOK, payload, env=self.base_env(knowledge)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("basic memory appended", json.loads(result.stdout)["systemMessage"]) + digest = self.sole_digest(knowledge) + self.assertIn("- source: omp", digest) + self.assertIn("omp remember this", digest) + + def test_stop_hook_claude_payload_does_not_capture(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + knowledge = tmp_path / "knowledge" + result = self.run_shell(STOP_HOOK, self.claude_payload(tmp_path, "claude-stop"), env=self.base_env(knowledge)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), {"continue": True, "suppressOutput": True}) + self.assertFalse((knowledge / "sessions").exists(), "Claude Stop must not capture; SessionEnd owns the digest") + + def test_reindex_fires_after_append_and_is_gated_on_replay(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + knowledge = tmp_path / "knowledge" + fake_bin = tmp_path / "bin" + log = tmp_path / "memsearch.log" + self.fake_memsearch(fake_bin, log=log) + env = self.with_fake_memsearch_path(self.base_env(knowledge), fake_bin) + payload = self.claude_payload(tmp_path, "reindex-1") + + first = self.run_shell(SESSION_END_HOOK, payload, env=env) + self.assertEqual(first.returncode, 0, first.stderr) + self.assertTrue(self.wait_for(lambda: log.exists() and log.read_text().count("index") == 1), "one reindex expected after append") + + # Re-running the same session id is a replay -> no digest, no reindex. + second = self.run_shell(SESSION_END_HOOK, payload, env=env) + self.assertEqual(second.returncode, 0, second.stderr) + self.assertIn("skipped replayed", json.loads(second.stdout)["systemMessage"]) + import time + + time.sleep(0.8) + self.assertEqual(log.read_text().count("index"), 1, "replay must not trigger a second reindex") + + def test_reindex_does_not_overlap_a_running_refresh(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + knowledge = tmp_path / "knowledge" + fake_bin = tmp_path / "bin" + log = tmp_path / "memsearch.log" + self.fake_memsearch(fake_bin, log=log) + env = self.with_fake_memsearch_path(self.base_env(knowledge), fake_bin) + # Simulate a refresh already in flight by pre-holding the lock. + lock = knowledge / "state" / "reindex.lock" + lock.mkdir(parents=True) + + result = self.run_shell(SESSION_END_HOOK, self.claude_payload(tmp_path, "no-overlap"), env=env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("basic memory appended", json.loads(result.stdout)["systemMessage"]) + import time + + time.sleep(0.8) + self.assertFalse(log.exists(), "held lock must prevent an overlapping reindex") + + def test_reindex_is_nonblocking_and_bounded_by_watchdog(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + knowledge = tmp_path / "knowledge" + fake_bin = tmp_path / "bin" + log = tmp_path / "memsearch.log" + done = tmp_path / "reindex-done" + self.fake_memsearch(fake_bin, log=log, sleep=5, done_marker=done) + env = self.with_fake_memsearch_path( + self.base_env(knowledge, {"MEMSEARCH_REINDEX_TIMEOUT": "1"}), fake_bin + ) + import time + + start = time.monotonic() + result = self.run_shell(SESSION_END_HOOK, self.claude_payload(tmp_path, "bounded"), env=env) + elapsed = time.monotonic() - start + self.assertEqual(result.returncode, 0, result.stderr) + self.assertLess(elapsed, 3.0, "hook must not block on the reindex") + # The reindex started but the watchdog kills it before the 5s sleep completes. + self.assertTrue(self.wait_for(lambda: log.exists()), "reindex should start") + time.sleep(2.5) + self.assertFalse(done.exists(), "watchdog must kill a reindex that exceeds the bound") + self.assertFalse((knowledge / "state" / "reindex.lock").exists(), "lock must be released after the bounded run") + + if __name__ == "__main__": unittest.main() From 1e55c1fcf039dec7e5dd4e457da0c3763075c057 Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:39:26 +0400 Subject: [PATCH 2/2] memory: source-hint precedence in digest label; recover stale reindex lock from a dead owner --- memory/hooks/common.sh | 15 +++++++++++-- memory/lib/basic_memory.py | 6 ++++-- memory/tests/test_basic_memory_hooks.py | 28 +++++++++++++++++++++++-- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/memory/hooks/common.sh b/memory/hooks/common.sh index 6f1f3ec..8ca2623 100755 --- a/memory/hooks/common.sh +++ b/memory/hooks/common.sh @@ -65,11 +65,19 @@ refresh_index_async() { # mkdir is atomic: it fails when a refresh already holds the lock, so we never # spawn overlapping reindexers. if ! mkdir "$reindex_lock" 2>/dev/null; then - return 0 + # The lock exists. Reclaim it only if its owner is gone (e.g. the refresher + # was SIGKILLed mid-run), so a dead process can't suppress reindex forever. + owner="$(cat "$reindex_lock/pid" 2>/dev/null || true)" + if [ -n "$owner" ] && kill -0 "$owner" 2>/dev/null; then + return 0 + fi + rm -f "$reindex_lock/pid" 2>/dev/null || true + rmdir "$reindex_lock" 2>/dev/null || true + mkdir "$reindex_lock" 2>/dev/null || return 0 fi ( - trap 'rmdir "$reindex_lock" 2>/dev/null || true' EXIT + trap 'rm -f "$reindex_lock/pid" 2>/dev/null || true; rmdir "$reindex_lock" 2>/dev/null || true' EXIT set -- "$NOTES_DIR" "$PROFILE_DIR" for path in "$SESSIONS_DIR"/*.md "$SESSIONS_DIR"/*.markdown; do [ -f "$path" ] && set -- "$@" "$path" @@ -82,6 +90,9 @@ refresh_index_async() { kill "$watchdog_pid" 2>/dev/null || true wait "$watchdog_pid" 2>/dev/null || true ) >/dev/null 2>&1 & + # Record the worker's PID (portable across bash 3.2, unlike $BASHPID) so a + # later call can tell a live refresh from a lock stranded by a killed one. + printf '%s\n' "$!" >"$reindex_lock/pid" 2>/dev/null || true return 0 } diff --git a/memory/lib/basic_memory.py b/memory/lib/basic_memory.py index 4899ae1..0bdc469 100755 --- a/memory/lib/basic_memory.py +++ b/memory/lib/basic_memory.py @@ -303,10 +303,12 @@ def build_digest(payload: dict[str, Any], messages: list[dict[str, Any]], starte users = collect_user_turns(messages) assistant = last_assistant_text(messages) paths = extract_paths(messages, payload) + # An explicit source hint wins over payload markers so a conflicting + # platform/agent field in the payload cannot mislabel the digest. platform = ( - payload.get("platform") + os.environ.get("DOTAGENTS_MEMORY_SOURCE") + or payload.get("platform") or payload.get("agent") - or os.environ.get("DOTAGENTS_MEMORY_SOURCE") or payload.get("hook_event_name") or "basic" ) diff --git a/memory/tests/test_basic_memory_hooks.py b/memory/tests/test_basic_memory_hooks.py index b935e57..d4c2d65 100644 --- a/memory/tests/test_basic_memory_hooks.py +++ b/memory/tests/test_basic_memory_hooks.py @@ -796,9 +796,10 @@ def test_reindex_does_not_overlap_a_running_refresh(self): log = tmp_path / "memsearch.log" self.fake_memsearch(fake_bin, log=log) env = self.with_fake_memsearch_path(self.base_env(knowledge), fake_bin) - # Simulate a refresh already in flight by pre-holding the lock. + # Simulate a refresh in flight by pre-holding the lock with a live owner. lock = knowledge / "state" / "reindex.lock" lock.mkdir(parents=True) + (lock / "pid").write_text(str(os.getpid()), encoding="utf-8") result = self.run_shell(SESSION_END_HOOK, self.claude_payload(tmp_path, "no-overlap"), env=env) self.assertEqual(result.returncode, 0, result.stderr) @@ -806,7 +807,30 @@ def test_reindex_does_not_overlap_a_running_refresh(self): import time time.sleep(0.8) - self.assertFalse(log.exists(), "held lock must prevent an overlapping reindex") + self.assertFalse(log.exists(), "a live owner's lock must prevent an overlapping reindex") + + def test_reindex_reclaims_a_stale_lock_from_a_dead_owner(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + knowledge = tmp_path / "knowledge" + fake_bin = tmp_path / "bin" + log = tmp_path / "memsearch.log" + self.fake_memsearch(fake_bin, log=log) + env = self.with_fake_memsearch_path(self.base_env(knowledge), fake_bin) + # A lock left behind by a SIGKILLed refresher: its recorded owner is dead. + dead = subprocess.Popen(["true"]) + dead.wait() + lock = knowledge / "state" / "reindex.lock" + lock.mkdir(parents=True) + (lock / "pid").write_text(str(dead.pid), encoding="utf-8") + + result = self.run_shell(SESSION_END_HOOK, self.claude_payload(tmp_path, "stale-lock"), env=env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("basic memory appended", json.loads(result.stdout)["systemMessage"]) + self.assertTrue( + self.wait_for(lambda: log.exists() and "index" in log.read_text()), + "a stale lock from a dead owner must be reclaimed so reindex is not suppressed forever", + ) def test_reindex_is_nonblocking_and_bounded_by_watchdog(self): with tempfile.TemporaryDirectory() as tmp: