diff --git a/tests/test-native-hook-output-763.py b/tests/test-native-hook-output-763.py new file mode 100644 index 00000000..e810c8a1 --- /dev/null +++ b/tests/test-native-hook-output-763.py @@ -0,0 +1,498 @@ +"""Synthetic stdlib controls for native recall output, without package imports. + +The decoders model documented schemas. They do not execute native host parsers +or prove that a native model received the context. Retrieval and capture are +inert collaborators; the actual hook main/parser and serializer execute. +""" +from __future__ import annotations + +import argparse +import ast +import importlib.util +import io +import json +import os +import shlex +import subprocess +import sys +import types +import unittest +from pathlib import Path +from typing import Callable, cast +from unittest.mock import patch + +TEST_ROOT = Path(__file__).resolve().parent.parent +SOURCE_ROOT = Path(os.environ.get("TRUEMEMORY_HOOK_CONTRACT_SOURCE_ROOT", str(TEST_ROOT))) +HOOK_DIR = SOURCE_ROOT / "truememory" / "ingest" / "hooks" +HELPER_ROOT = Path(os.environ.get("TRUEMEMORY_HOOK_CONTRACT_HELPER_ROOT", str(TEST_ROOT))) +_OUTPUT_PATH = TEST_ROOT / "truememory" / "ingest" / "hooks" / "output.py" +_SPEC = importlib.util.spec_from_file_location("synthetic_hook_output_763", _OUTPUT_PATH) +assert _SPEC is not None and _SPEC.loader is not None +output = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(output) + +SYNTHETIC_CONTEXT = "synthetic blue lantern" +NATIVE_EVENTS = ( + ("cursor", "sessionStart"), + ("gemini", "SessionStart"), + ("gemini", "BeforeAgent"), + ("codex", "SessionStart"), + ("codex", "UserPromptSubmit"), +) + + +def read_source(source: Path) -> str: + commit = os.environ.get("TRUEMEMORY_HOOK_CONTRACT_SOURCE_COMMIT", "") + if not commit: + return source.read_text(encoding="utf-8") + relative = source.relative_to(SOURCE_ROOT) + return subprocess.run( + ["git", "show", f"{commit}:{relative}"], cwd=SOURCE_ROOT, + capture_output=True, text=True, check=True, + ).stdout + + +def load_shared_helpers() -> dict[str, object]: + namespace: dict[str, object] = {"Path": Path, "re": __import__("re"), "sys": sys, "shlex": shlex} + for path, functions in ( + ("truememory/hooks/adapters/base.py", {"is_owned_hook_command"}), + ("truememory/ingest/hooks/_shared.py", {"normalize_transcript_host", "hook_session_id"}), + ): + source = HELPER_ROOT / path + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + selected = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in functions] + if {node.name for node in selected} != functions: + raise AssertionError("Required peer helper is absent; compose #761 before package execution") + exec(compile(ast.Module(body=selected, type_ignores=[]), str(source), "exec"), namespace) + return namespace + + +SHARED_HELPERS = load_shared_helpers() + + +def decode_fixture(host: str, event: str, response: str) -> str | None: + """Strict synthetic protocol fixture, independently spelled field paths.""" + data = json.loads(response) + if host == "cursor": + if event != "sessionStart" or set(data) != {"additional_context"}: + return None + context = data["additional_context"] + elif host in {"gemini", "codex"}: + events = {"SessionStart", "BeforeAgent"} if host == "gemini" else {"SessionStart", "UserPromptSubmit"} + if event not in events or set(data) != {"hookSpecificOutput"}: + return None + specific = data["hookSpecificOutput"] + if not isinstance(specific, dict) or set(specific) != {"hookEventName", "additionalContext"}: + return None + if specific["hookEventName"] != event: + return None + context = specific["additionalContext"] + else: + if event not in {"SessionStart", "UserPromptSubmit"} or set(data) != {"additionalContext"}: + return None + context = data["additionalContext"] + return context if isinstance(context, str) else None + + +class RecordingStream(io.StringIO): + def __init__(self, failure: str = "") -> None: + super().__init__() + self.failure = failure + self.flushed = False + + def write(self, text: str) -> int: + if self.failure == "write": + raise BrokenPipeError("synthetic write refusal") + if self.failure == "short": + return super().write(text[:len(text) // 2]) + return super().write(text) + + def flush(self) -> None: + if self.failure == "flush": + raise BrokenPipeError("synthetic flush refusal") + self.flushed = True + + +def load_functions(script: str, names: set[str]) -> dict[str, object]: + source = HOOK_DIR / script + tree = ast.parse(read_source(source), filename=str(source)) + selected = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in names] + if {node.name for node in selected} != names: + raise AssertionError("Missing production function requested by fixture") + namespace: dict[str, object] = { + "argparse": argparse, "os": os, "json": json, "sys": sys, "Path": Path, + "HOOK_HOSTS": output.HOOK_HOSTS, "emit_context": output.emit_context, + "supports_context": output.supports_context, + } + exec(compile(ast.Module(body=selected, type_ignores=[]), str(source), "exec"), namespace) + return namespace + + +class HookHarness: + def __init__(self) -> None: + self.markers: set[str] = set() + self.flush_at_mark: list[bool] = [] + self.calls: list[tuple[str, object]] = [] + self.buffer_sessions: list[str] = [] + self.counter_sessions: list[str] = [] + self.store_sessions: list[str] = [] + self.first_run = False + self.recall_context = SYNTHETIC_CONTEXT + self.notice = "" + self.intensity = "standard" + self.admitted = False + self.stream = RecordingStream() + self.shared = types.ModuleType("truememory.ingest.hooks._shared") + self.shared.mark_recall_injected = self.mark # type: ignore[attr-defined] + self.shared.hook_session_id = SHARED_HELPERS["hook_session_id"] # type: ignore[attr-defined] + self.shared.consume_recall_injected = self.consume # type: ignore[attr-defined] + self.shared.is_allowed_transcript = self.admit # type: ignore[attr-defined] + self.shared.should_extract_session = lambda *_a: True # type: ignore[attr-defined] + self.shared.mark_session_extracted = lambda *_a, **_k: self.calls.append(("extracted", None)) # type: ignore[attr-defined] + self.stop = types.ModuleType("truememory.ingest.hooks.stop") + self.stop.TRACE_DIR = types.SimpleNamespace(mkdir=lambda **_k: None) # type: ignore[attr-defined] + self.stop.LOG_DIR = types.SimpleNamespace(mkdir=lambda **_k: None) # type: ignore[attr-defined] + self.stop._has_enough_messages = lambda *_a: self.calls.append(("read-transcript", _a)) or True # type: ignore[attr-defined] + self.stop._run_background_ingestion = self.background # type: ignore[attr-defined] + + def mark(self, session: str) -> None: + self.calls.append(("mark", session)) + self.flush_at_mark.append(self.stream.flushed) + self.markers.add(session) + + def consume(self, session: str) -> bool: + self.calls.append(("consume", session)) + found = session in self.markers + self.markers.discard(session) + return found + + def admit(self, transcript: str, *, host: str) -> bool: + self.calls.append(("admit", (transcript, host))) + return self.admitted + + def background(self, *args: object, transcript_host: str) -> int: + self.calls.append(("spawn", (args, transcript_host))) + return 21 + + def buffer(self, session: str, _prompt: str) -> None: + self.buffer_sessions.append(session) + self.calls.append(("buffer", None)) + + def count(self, session: str) -> int: + self.counter_sessions.append(session) + return 5 + + def store(self, _prompt: str, session: str, *_args: object) -> None: + self.store_sessions.append(session) + self.calls.append(("store", None)) + + def run( + self, script: str, host: str, event: str, *, failure: str = "", + payload: dict[str, object] | None = None, + ) -> str: + namespace = load_functions(script, {"main"}) + self.stream = RecordingStream(failure) + namespace.update({ + "_parse_args": lambda: argparse.Namespace(host=host, event=event, user="synthetic-user", db="", scan_stale=False), + "_run_maintenance_background": lambda *_a: None, + "_get_search_intensity": lambda: "standard", + "_INTENSITY_MEMORY_LIMITS": {"standard": 25}, "_BASE_MEMORY_LIMIT": 25, + "_intensity_budget": lambda *_a: 8192, "_is_first_run": lambda: self.first_run, + "_first_run_context": lambda: "synthetic onboarding", + "recall_memories": lambda *_a, **_k: self.recall_context, + "_check_for_update": lambda: self.notice, "_check_email_needed": lambda: "", + "log": types.SimpleNamespace(error=lambda *_a: self.calls.append(("hook-error", None))), + "buffer_message": self.buffer, + "_prune_old_buffers": lambda: None, "_try_capture_email": lambda *_a: None, + "_get_intensity_config": lambda: (self.intensity, "standard"), + "_increment_prompt_count": self.count, + "_try_per_exchange_store": self.store, + "_try_auto_recall": self.auto_recall, "_try_proactive_recall": self.proactive_recall, + }) + data = payload if payload is not None else {"session_id": "synthetic-session", "prompt": "Recall synthetic color"} + with patch.dict(sys.modules, {self.shared.__name__: self.shared, self.stop.__name__: self.stop}), patch.object(sys, "stdin", io.StringIO(json.dumps(data))), patch.object(sys, "stdout", self.stream), patch.dict(os.environ, {"TRUEMEMORY_EXTRACTION": ""}): + cast(Callable[[], None], namespace["main"])() + return self.stream.getvalue() + + def auto_recall(self, *_args: object, debounced: bool) -> str | None: + self.calls.append(("auto", debounced)) + return None if debounced else SYNTHETIC_CONTEXT + + def proactive_recall(self, *_args: object, debounced: bool) -> tuple[str | None, bool]: + self.calls.append(("proactive", debounced)) + return (None if debounced else SYNTHETIC_CONTEXT), True + + +class TestNativeOutput(unittest.TestCase): + def test_official_event_response_fixtures(self) -> None: + for host, event in NATIVE_EVENTS + (("claude", "SessionStart"), ("claude", "UserPromptSubmit")): + with self.subTest(host=host, event=event): + stream = RecordingStream() + self.assertTrue(output.emit_context(SYNTHETIC_CONTEXT, host=host, event=event, stream=stream)) + self.assertTrue(stream.flushed) + self.assertEqual(decode_fixture(host, event, stream.getvalue()), SYNTHETIC_CONTEXT) + self.assertEqual(stream.getvalue().count("\n"), 1) + + def test_legacy_payload_rejected_by_native_fixtures(self) -> None: + legacy = json.dumps({"additionalContext": SYNTHETIC_CONTEXT}) + for host, event in NATIVE_EVENTS: + with self.subTest(host=host, event=event): + self.assertIsNone(decode_fixture(host, event, legacy)) + + def test_wrong_events_and_hosts_emit_nothing(self) -> None: + for host, event in (("cursor", "beforeSubmitPrompt"), ("cursor", "SessionStart"), ("gemini", "UserPromptSubmit"), ("codex", "BeforeAgent"), ("codex", "Stop"), ("claude", "BeforeAgent"), ("unknown", "SessionStart"), ("codex ", "SessionStart")): + with self.subTest(host=host, event=event): + stream = RecordingStream() + self.assertFalse(output.emit_context(SYNTHETIC_CONTEXT, host=host, event=event, stream=stream)) + self.assertEqual(stream.getvalue(), "") + self.assertFalse(stream.flushed) + + def test_io_failures_do_not_report_emitted(self) -> None: + for failure in ("write", "flush", "short"): + with self.subTest(failure=failure): + self.assertFalse(output.emit_context(SYNTHETIC_CONTEXT, host="codex", event="SessionStart", stream=RecordingStream(failure))) + + def test_empty_context_emits_nothing(self) -> None: + stream = RecordingStream() + self.assertFalse(output.emit_context("", host="codex", event="SessionStart", stream=stream)) + self.assertEqual(stream.getvalue(), "") + + def test_session_main_uses_native_contract_and_marks_after_flush(self) -> None: + for host, event in (("cursor", "sessionStart"), ("gemini", "SessionStart"), ("codex", "SessionStart"), ("claude", "SessionStart")): + with self.subTest(host=host): + harness = HookHarness() + response = harness.run("session_start.py", host, event) + self.assertEqual(decode_fixture(host, event, response), SYNTHETIC_CONTEXT) + self.assertEqual(harness.markers, {"synthetic-session"}) + self.assertEqual(harness.flush_at_mark, [True]) + self.assertEqual([call for call in harness.calls if call[0] == "mark"], [("mark", "synthetic-session")]) + + def test_session_failed_emission_never_marks(self) -> None: + for failure in ("write", "flush", "short"): + with self.subTest(failure=failure): + harness = HookHarness() + harness.run("session_start.py", "codex", "SessionStart", failure=failure) + self.assertEqual(harness.markers, set()) + self.assertFalse([call for call in harness.calls if call[0] == "mark"]) + response = harness.run("user_prompt_submit.py", "codex", "UserPromptSubmit") + self.assertEqual(decode_fixture("codex", "UserPromptSubmit", response), SYNTHETIC_CONTEXT) + + def test_session_unsupported_event_never_marks(self) -> None: + harness = HookHarness() + self.assertEqual(harness.run("session_start.py", "cursor", "beforeSubmitPrompt"), "") + self.assertEqual(harness.markers, set()) + + def test_notices_and_onboarding_do_not_suppress_prompt_recall(self) -> None: + for first_run in (False, True): + with self.subTest(first_run=first_run): + harness = HookHarness() + harness.first_run = first_run + harness.recall_context = "" + harness.notice = "synthetic update" + response = harness.run("session_start.py", "gemini", "SessionStart") + context = decode_fixture("gemini", "SessionStart", response) + self.assertIsInstance(context, str) + self.assertIn("synthetic update", cast(str, context)) + self.assertEqual(harness.markers, set()) + response = harness.run("user_prompt_submit.py", "gemini", "BeforeAgent") + self.assertEqual(decode_fixture("gemini", "BeforeAgent", response), SYNTHETIC_CONTEXT) + + def test_native_prompt_main_preserves_once_only_debounce(self) -> None: + for host, event in (("gemini", "BeforeAgent"), ("codex", "UserPromptSubmit"), ("claude", "UserPromptSubmit")): + for intensity in ("standard", "max"): + with self.subTest(host=host, intensity=intensity): + harness = HookHarness() + harness.intensity = intensity + harness.run("session_start.py", host, "SessionStart") + self.assertEqual(harness.run("user_prompt_submit.py", host, event), "") + self.assertEqual(harness.markers, set()) + response = harness.run("user_prompt_submit.py", host, event) + self.assertEqual(decode_fixture(host, event, response), SYNTHETIC_CONTEXT) + self.assertEqual(len([call for call in harness.calls if call[0] == "consume"]), 2) + if intensity == "max": + self.assertFalse([call for call in harness.calls if call[0] == "auto"]) + + def test_cursor_prompt_capture_without_unsupported_recall(self) -> None: + harness = HookHarness() + harness.intensity = "max" + response = harness.run("user_prompt_submit.py", "cursor", "beforeSubmitPrompt") + self.assertEqual(response, "") + self.assertIn(("buffer", None), harness.calls) + self.assertIn(("store", None), harness.calls) + self.assertFalse([call for call in harness.calls if call[0] in {"auto", "proactive", "consume"}]) + + def test_prompt_emission_failure_does_not_escape_or_set_markers(self) -> None: + for host, event in (("gemini", "BeforeAgent"), ("codex", "UserPromptSubmit"), ("claude", "UserPromptSubmit")): + for failure in ("write", "flush", "short"): + with self.subTest(host=host, failure=failure): + harness = HookHarness() + harness.run("user_prompt_submit.py", host, event, failure=failure) + self.assertEqual(harness.markers, set()) + self.assertFalse([call for call in harness.calls if call[0] == "mark"]) + + def test_short_first_prompt_consumes_only_its_marker(self) -> None: + harness = HookHarness() + harness.run("session_start.py", "codex", "SessionStart") + data = {"session_id": "synthetic-session", "prompt": "hi"} + self.assertEqual(harness.run("user_prompt_submit.py", "codex", "UserPromptSubmit", payload=data), "") + self.assertEqual(harness.markers, set()) + self.assertEqual(len([call for call in harness.calls if call[0] == "consume"]), 1) + response = harness.run("user_prompt_submit.py", "codex", "UserPromptSubmit") + self.assertEqual(decode_fixture("codex", "UserPromptSubmit", response), SYNTHETIC_CONTEXT) + + def test_untrusted_stdin_cannot_select_host_or_event(self) -> None: + harness = HookHarness() + data = {"session_id": "synthetic-session", "host": "cursor", "hook_event_name": "beforeSubmitPrompt", "event": "BeforeAgent"} + response = harness.run("session_start.py", "codex", "SessionStart", payload=data) + self.assertEqual(decode_fixture("codex", "SessionStart", response), SYNTHETIC_CONTEXT) + + def test_cursor_native_conversations_have_distinct_capture_and_markers(self) -> None: + harness = HookHarness() + conversations = ["synthetic-conversation-a", "synthetic-conversation-b"] + for conversation in conversations: + data = {"conversation_id": conversation, "prompt": "synthetic capture prompt"} + response = harness.run("session_start.py", "cursor", "sessionStart", payload=data) + self.assertEqual(decode_fixture("cursor", "sessionStart", response), SYNTHETIC_CONTEXT) + self.assertEqual(harness.run("user_prompt_submit.py", "cursor", "beforeSubmitPrompt", payload=data), "") + self.assertEqual(harness.buffer_sessions, conversations) + self.assertEqual(harness.counter_sessions, conversations) + self.assertEqual(harness.store_sessions, conversations) + self.assertEqual(harness.markers, set(conversations)) + self.assertEqual(harness.flush_at_mark, [True, True]) + + def test_cursor_identity_precedence_and_other_hosts_session_id(self) -> None: + for host, event in (("cursor", "beforeSubmitPrompt"), ("claude", "UserPromptSubmit"), ("codex", "UserPromptSubmit"), ("gemini", "BeforeAgent")): + with self.subTest(host=host): + harness = HookHarness() + data = {"session_id": "synthetic-session", "conversation_id": "synthetic-conversation", "host": "cursor", "prompt": "synthetic capture prompt"} + harness.run("user_prompt_submit.py", host, event, payload=data) + expected = "synthetic-conversation" if host == "cursor" else "synthetic-session" + self.assertEqual(harness.buffer_sessions, [expected]) + self.assertEqual(harness.counter_sessions, [expected]) + self.assertEqual(harness.store_sessions, [expected]) + + def test_legacy_missing_session_fallback_and_no_start_marker(self) -> None: + harness = HookHarness() + data = {"prompt": "synthetic capture prompt"} + response = harness.run("session_start.py", "claude", "SessionStart", payload=data) + self.assertEqual(decode_fixture("claude", "SessionStart", response), SYNTHETIC_CONTEXT) + self.assertEqual(harness.markers, set()) + harness.run("user_prompt_submit.py", "claude", "UserPromptSubmit", payload=data) + self.assertEqual(harness.buffer_sessions, ["unknown"]) + + def test_cli_trusted_identity_defaults_and_overrides(self) -> None: + for script, default in (("session_start.py", "SessionStart"), ("user_prompt_submit.py", "UserPromptSubmit")): + parser = cast(Callable[[], argparse.Namespace], load_functions(script, {"_parse_args"})["_parse_args"]) + with patch.object(sys, "argv", [script]): + args = parser() + self.assertTrue(hasattr(args, "host")) + self.assertTrue(hasattr(args, "event")) + self.assertEqual((args.host, args.event), ("claude", default)) + with patch.object(sys, "argv", [script, "--host", "gemini", "--event", "BeforeAgent"]): + args = parser() + self.assertEqual((args.host, args.event), ("gemini", "BeforeAgent")) + with patch.object(sys, "argv", [script, "--host", "invalid"]), patch.object(sys, "stderr", io.StringIO()): + with self.assertRaises(SystemExit): + parser() + + def test_transcript_rejection_precedes_path_read(self) -> None: + harness = HookHarness() + data = {"session_id": "synthetic-session", "prompt": "Recall synthetic color", "transcript_path": "/synthetic/untrusted.jsonl"} + with patch.object(Path, "exists", side_effect=AssertionError("Unadmitted transcript stat")), patch.object(Path, "is_file", side_effect=AssertionError("Unadmitted transcript stat")): + response = harness.run("user_prompt_submit.py", "codex", "UserPromptSubmit", payload=data) + self.assertEqual(decode_fixture("codex", "UserPromptSubmit", response), SYNTHETIC_CONTEXT) + self.assertIn(("admit", ("/synthetic/untrusted.jsonl", "codex")), harness.calls) + + def test_admitted_transcript_threads_trusted_identity_to_spawn(self) -> None: + for host, event in (("cursor", "beforeSubmitPrompt"), ("gemini", "BeforeAgent"), ("codex", "UserPromptSubmit"), ("claude", "UserPromptSubmit")): + with self.subTest(host=host): + harness = HookHarness() + harness.admitted = True + data = {"session_id": "synthetic-session", "prompt": "Recall synthetic color", "transcript_path": "/synthetic/native.jsonl"} + with patch.object(Path, "is_file", return_value=True): + harness.run("user_prompt_submit.py", host, event, payload=data) + self.assertIn(("admit", ("/synthetic/native.jsonl", host)), harness.calls) + self.assertIn(("spawn", (("/synthetic/native.jsonl", "synthetic-session", "synthetic-user", ""), host)), harness.calls) + self.assertIn(("extracted", None), harness.calls) + phases = [call[0] for call in harness.calls] + self.assertLess(phases.index("admit"), phases.index("read-transcript")) + + +class VirtualPath: + files: dict[str, str] = {} + + def __init__(self, path: object) -> None: + self.path = str(path) + + @classmethod + def home(cls) -> VirtualPath: + return cls("/synthetic") + + @property + def parent(self) -> VirtualPath: + return VirtualPath(str(Path(self.path).parent)) + + def __truediv__(self, part: str) -> VirtualPath: + return VirtualPath(self.path + "/" + part) + + def __str__(self) -> str: + return self.path + + def exists(self) -> bool: + return self.path in self.files + + def mkdir(self, **_kwargs: object) -> None: + return None + + def read_text(self, **_kwargs: object) -> str: + return self.files[self.path] + + +class TestCursorInstallation(unittest.TestCase): + def test_fresh_and_upgrade_install_native_identity_preserve_other_hooks(self) -> None: + source = SOURCE_ROOT / "truememory" / "hooks" / "adapters" / "cursor.py" + tree = ast.parse(read_source(source), filename=str(source)) + owned = {"_HOOK_EVENTS", "_STALE_EVENTS", "_TRUEMEMORY_MARKER", "_CURSOR_DIR", "_MCP_CONFIG", "_HOOK_CONFIG"} + selected = [node for node in tree.body if (isinstance(node, ast.Assign) and all(isinstance(target, ast.Name) and target.id in owned for target in node.targets)) or (isinstance(node, ast.ClassDef) and node.name == "CursorAdapter")] + namespace: dict[str, object] = { + "CLIAdapter": type("SyntheticAdapter", (), {}), "Path": VirtualPath, + "json": json, "shlex": shlex, "sys": sys, "shutil": types.SimpleNamespace(), + "__file__": "/synthetic/truememory/hooks/adapters/cursor.py", + "atomic_write_text": lambda path, text: VirtualPath.files.__setitem__(str(path), text), + "is_owned_hook_command": SHARED_HELPERS["is_owned_hook_command"], + } + exec(compile(ast.Module(body=selected, type_ignores=[]), str(source), "exec"), namespace) + adapter_type = cast(type, namespace["CursorAdapter"]) + config_path = str(namespace["_HOOK_CONFIG"]) + third_party = {"command": "/synthetic/foreign-policy --target truememory", "timeout": 17, "custom": "preserve"} + for upgraded in (False, True): + with self.subTest(upgraded=upgraded): + VirtualPath.files = {} + existing = {"version": 1, "custom": "preserve", "hooks": {"sessionStart": [third_party.copy()]}} + if upgraded: + existing["hooks"]["sessionStart"].append({"command": "python /synthetic/truememory/ingest/hooks/session_start.py", "timeout": 1, "custom": "owned metadata"}) + VirtualPath.files[config_path] = json.dumps(existing) + adapter = adapter_type() + adapter.install_hooks(python_path="/synthetic/python path/python3", user_id="synthetic user", db_path="/synthetic/db path") + data = json.loads(VirtualPath.files[config_path]) + self.assertEqual(data["hooks"]["sessionStart"][0], third_party) + self.assertEqual(data["custom"], "preserve") + for event in ("sessionStart", "stop", "beforeSubmitPrompt", "preCompact"): + commands = [entry for entry in data["hooks"][event] if cast(Callable[[object], bool], SHARED_HELPERS["is_owned_hook_command"])(entry["command"])] + self.assertEqual(len(commands), 1) + parts = shlex.split(commands[0]["command"]) + self.assertIn("--host", parts) + self.assertIn("--event", parts) + self.assertEqual(parts[parts.index("--host") + 1], "cursor") + self.assertEqual(parts[parts.index("--event") + 1], event) + self.assertEqual(parts[parts.index("--user") + 1], "synthetic user") + self.assertEqual(parts[parts.index("--db") + 1], "/synthetic/db path") + if upgraded: + self.assertEqual(data["hooks"]["sessionStart"][1]["custom"], "owned metadata") + first = VirtualPath.files[config_path] + adapter.install_hooks(python_path="/synthetic/python path/python3", user_id="synthetic user", db_path="/synthetic/db path") + self.assertEqual(VirtualPath.files[config_path], first) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cursor_adapter.py b/tests/test_cursor_adapter.py index f2417737..aba07a8a 100644 --- a/tests/test_cursor_adapter.py +++ b/tests/test_cursor_adapter.py @@ -244,7 +244,7 @@ def test_uninstall_preserves_other_entries(tmp_path, monkeypatch): "hooks": { "sessionStart": [ {"command": "my-hook", "timeout": 5000}, - {"command": "/path/to/truememory/hooks/session_start.py", "timeout": 10000}, + {"command": "/usr/bin/python3 /path/to/truememory/ingest/hooks/session_start.py", "timeout": 10000}, ] }, }), encoding="utf-8") @@ -416,16 +416,16 @@ def test_install_migrates_stale_user_prompt_submit(tmp_path, monkeypatch): "version": 1, "hooks": { "sessionStart": [ - {"command": "/usr/bin/python3 /path/to/truememory/session_start.py", "timeout": 10000} + {"command": "/usr/bin/python3 /path/to/truememory/ingest/hooks/session_start.py", "timeout": 10000} ], "stop": [ - {"command": "/usr/bin/python3 /path/to/truememory/stop.py", "timeout": 5000} + {"command": "/usr/bin/python3 /path/to/truememory/ingest/hooks/stop.py", "timeout": 5000} ], "userPromptSubmit": [ - {"command": "/usr/bin/python3 /path/to/truememory/user_prompt_submit.py", "timeout": 5000} + {"command": "/usr/bin/python3 /path/to/truememory/ingest/hooks/user_prompt_submit.py", "timeout": 5000} ], "preCompact": [ - {"command": "/usr/bin/python3 /path/to/truememory/compact.py", "timeout": 5000} + {"command": "/usr/bin/python3 /path/to/truememory/ingest/hooks/compact.py", "timeout": 5000} ], }, } @@ -460,13 +460,13 @@ def test_install_updates_stale_millisecond_timeouts(tmp_path, monkeypatch): "version": 1, "hooks": { "sessionStart": [ - {"command": "/usr/bin/python3 /path/to/truememory/session_start.py", "timeout": 10000} + {"command": "/usr/bin/python3 /path/to/truememory/ingest/hooks/session_start.py", "timeout": 10000} ], "stop": [ - {"command": "/usr/bin/python3 /path/to/truememory/stop.py", "timeout": 5000} + {"command": "/usr/bin/python3 /path/to/truememory/ingest/hooks/stop.py", "timeout": 5000} ], "preCompact": [ - {"command": "/usr/bin/python3 /path/to/truememory/compact.py", "timeout": 5000} + {"command": "/usr/bin/python3 /path/to/truememory/ingest/hooks/compact.py", "timeout": 5000} ], }, } @@ -496,11 +496,11 @@ def test_install_preserves_non_truememory_hooks(tmp_path, monkeypatch): "version": 1, "hooks": { "userPromptSubmit": [ - {"command": "/usr/bin/python3 /path/to/truememory/user_prompt_submit.py", "timeout": 5000}, + {"command": "/usr/bin/python3 /path/to/truememory/ingest/hooks/user_prompt_submit.py", "timeout": 5000}, {"command": "/usr/local/bin/my-custom-linter", "timeout": 10}, ], "sessionStart": [ - {"command": "/usr/bin/python3 /path/to/truememory/session_start.py", "timeout": 10000} + {"command": "/usr/bin/python3 /path/to/truememory/ingest/hooks/session_start.py", "timeout": 10000} ], }, } diff --git a/tests/test_issue_630_search_supplement_merge.py b/tests/test_issue_630_search_supplement_merge.py index a6938df4..e5862f8b 100644 --- a/tests/test_issue_630_search_supplement_merge.py +++ b/tests/test_issue_630_search_supplement_merge.py @@ -39,6 +39,7 @@ def fts_engine(tmp_path, monkeypatch): monkeypatch.delenv("TRUEMEMORY_ALPHA_SURPRISE", raising=False) from truememory.engine import TrueMemoryEngine + from truememory.maintenance import canonical_database_path from truememory.storage import create_db db_path = tmp_path / "i630.db" @@ -54,11 +55,27 @@ def fts_engine(tmp_path, monkeypatch): conn.close() eng = TrueMemoryEngine(db_path) - eng.open(rebuild_vectors=False) - # Force the bare path: no cross-encoder rerank, no salience filtering. - eng._has_reranker = False - eng._has_salience = False - return eng + coordinator = None + try: + eng.open(rebuild_vectors=False) + coordinator = eng._maintenance_coordinator + # Force the bare path: no cross-encoder rerank, no salience filtering. + eng._has_reranker = False + eng._has_salience = False + yield eng + finally: + try: + if coordinator is None: + coordinator = eng._maintenance_coordinator + if coordinator is not None: + fixture_path = canonical_database_path(db_path) + if fixture_path is None or canonical_database_path(coordinator.path) != fixture_path: + raise RuntimeError("Fixture maintenance coordinator belongs to a different database") + coordinator.cancel() + if not coordinator.wait(10): + raise RuntimeError("Fixture-owned maintenance worker did not finish") + finally: + eng.close() # Query that _has_personality_intent() recognizes (matches "favorite food"). diff --git a/truememory/hooks/adapters/cursor.py b/truememory/hooks/adapters/cursor.py index 06ba89d5..a61b1d29 100644 --- a/truememory/hooks/adapters/cursor.py +++ b/truememory/hooks/adapters/cursor.py @@ -13,7 +13,7 @@ import sys from pathlib import Path -from truememory.hooks.adapters.base import CLIAdapter, atomic_write_text +from truememory.hooks.adapters.base import CLIAdapter, atomic_write_text, is_owned_hook_command _CURSOR_DIR = Path.home() / ".cursor" _MCP_CONFIG = _CURSOR_DIR / "mcp.json" @@ -172,7 +172,7 @@ def install_hooks( h for h in stale_list if not ( isinstance(h, dict) - and _TRUEMEMORY_MARKER in h.get("command", "").lower() + and is_owned_hook_command(h.get("command")) ) ] if cleaned: @@ -180,7 +180,8 @@ def install_hooks( else: hooks.pop(stale, None) - # --- Migration: update existing TrueMemory hook timeouts --- + # Upgrade owned commands so existing installations select the native + # event contract instead of retaining the legacy Claude response. for event, info in _HOOK_EVENTS.items(): event_list = hooks.get(event) if not isinstance(event_list, list): @@ -188,10 +189,12 @@ def install_hooks( for entry in event_list: if ( isinstance(entry, dict) - and _TRUEMEMORY_MARKER in entry.get("command", "").lower() - and entry.get("timeout") != info["timeout"] + and is_owned_hook_command(entry.get("command")) ): entry["timeout"] = info["timeout"] + entry["command"] = self._build_command( + py, hooks_dir / info["script"], user_id, db_path, event=event, + ) for event, info in _HOOK_EVENTS.items(): event_list = hooks.setdefault(event, []) @@ -203,7 +206,7 @@ def install_hooks( continue script_path = hooks_dir / info["script"] - cmd = self._build_command(py, script_path, user_id, db_path) + cmd = self._build_command(py, script_path, user_id, db_path, event=event) event_list.append({ "command": cmd, "timeout": info["timeout"], @@ -232,12 +235,16 @@ def _build_command( script_path: Path, user_id: str = "", db_path: str = "", + event: str = "", ) -> str: parts: list[str] = [python_path, str(script_path)] if user_id: parts.extend(["--user", user_id]) if db_path: parts.extend(["--db", db_path]) + parts.extend(["--host", "cursor"]) + if event: + parts.extend(["--event", event]) if sys.platform == "win32": import subprocess as _sp return _sp.list2cmdline(parts) @@ -273,7 +280,7 @@ def _has_hook_entries(self) -> bool: def _event_has_truememory(entries: list) -> bool: return any( isinstance(h, dict) - and _TRUEMEMORY_MARKER in h.get("command", "").lower() + and is_owned_hook_command(h.get("command")) for h in entries ) @@ -309,7 +316,7 @@ def _remove_hook_entries(self) -> None: h for h in entries if not ( isinstance(h, dict) - and _TRUEMEMORY_MARKER in h.get("command", "").lower() + and is_owned_hook_command(h.get("command")) ) ] if cleaned: diff --git a/truememory/ingest/hooks/output.py b/truememory/ingest/hooks/output.py new file mode 100644 index 00000000..f924c235 --- /dev/null +++ b/truememory/ingest/hooks/output.py @@ -0,0 +1,58 @@ +"""Event-specific context output for installed recall hooks.""" +from __future__ import annotations + +import json +import logging +import sys +from typing import TextIO + +log = logging.getLogger(__name__) + +HOOK_HOSTS = ("claude", "cursor", "gemini", "codex") +_CONTEXT_EVENTS = { + "claude": frozenset({"SessionStart", "UserPromptSubmit"}), + "cursor": frozenset({"sessionStart"}), + "gemini": frozenset({"SessionStart", "BeforeAgent"}), + "codex": frozenset({"SessionStart", "UserPromptSubmit"}), +} + + +def supports_context(host: str, event: str) -> bool: + """Use trusted installer flags, never host identity supplied on stdin.""" + return event in _CONTEXT_EVENTS.get(host, ()) + + +def emit_context( + context: str, *, host: str, event: str, stream: TextIO | None = None, +) -> bool: + """Return success only after the supported JSON response is flushed. + + This confirms transport emission, not native host/model acceptance. + Cursor beforeSubmitPrompt has no documented context output contract. + """ + if not isinstance(context, str) or not context or not supports_context(host, event): + return False + + output: dict[str, object] + if host == "claude": + output = {"additionalContext": context} + elif host == "cursor": + output = {"additional_context": context} + else: + output = { + "hookSpecificOutput": { + "hookEventName": event, + "additionalContext": context, + }, + } + + target = stream if stream is not None else sys.stdout + try: + response = json.dumps(output) + "\n" + if target.write(response) != len(response): + return False + target.flush() + except (OSError, ValueError, TypeError): + log.warning("Recall context emission failed for host=%s event=%s", host, event) + return False + return True diff --git a/truememory/ingest/hooks/session_start.py b/truememory/ingest/hooks/session_start.py index 81a20431..b2baea01 100755 --- a/truememory/ingest/hooks/session_start.py +++ b/truememory/ingest/hooks/session_start.py @@ -30,6 +30,7 @@ from truememory import _platform from truememory._platform import _env_int from truememory._sanitize import sanitize_injection_content +from truememory.ingest.hooks.output import HOOK_HOSTS, emit_context try: import fcntl @@ -170,7 +171,7 @@ def _parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(add_help=False) p.add_argument("--user", default=os.environ.get("TRUEMEMORY_USER_ID", "")) p.add_argument("--db", default=os.environ.get("TRUEMEMORY_DB_PATH", "")) - p.add_argument("--host", choices=("claude", "codex", "gemini", "cursor"), default="claude") + p.add_argument("--host", choices=HOOK_HOSTS, default="claude") p.add_argument("--event", default="SessionStart") p.add_argument("--scan-stale", action="store_true", help=argparse.SUPPRESS) args, _ = p.parse_known_args() @@ -771,16 +772,15 @@ def main(): if email_notice: context = (context or "") + "\n\n" + email_notice - if context: - output = {"additionalContext": context} - print(json.dumps(output)) + if context and emit_context(context, host=args.host, event=args.event): # Mark recall as injected so the first UserPromptSubmit can skip # its redundant per-message auto-recall (issue #561). Written only - # after the context has actually been emitted, and only when the + # after the supported response has been flushed, and only when the # recall portion was non-empty — an empty/failed recall must not # suppress the first prompt's targeted recall. if recall_injected: - session_id = input_data.get("session_id", "") + from truememory.ingest.hooks._shared import hook_session_id + session_id = hook_session_id(input_data, args.host, default="") if session_id: from truememory.ingest.hooks._shared import mark_recall_injected mark_recall_injected(session_id) diff --git a/truememory/ingest/hooks/user_prompt_submit.py b/truememory/ingest/hooks/user_prompt_submit.py index 9a22ee9c..840ef08d 100755 --- a/truememory/ingest/hooks/user_prompt_submit.py +++ b/truememory/ingest/hooks/user_prompt_submit.py @@ -32,6 +32,7 @@ from truememory._platform import _env_int from truememory._sanitize import sanitize_injection_content +from truememory.ingest.hooks.output import HOOK_HOSTS, emit_context, supports_context # Optional: fcntl isn't available on Windows, so we gracefully degrade try: @@ -83,13 +84,13 @@ def _word_overlap(a: str, b: str) -> float: def _parse_args() -> argparse.Namespace: """Parse command-line overrides the installer threads through. - The trusted host selects transcript admission; event retains installer context. + The trusted host/event select recall output and transcript admission. ``parse_known_args`` keeps the existing forward-compatible flags. """ p = argparse.ArgumentParser(add_help=False) p.add_argument("--user", default=os.environ.get("TRUEMEMORY_USER_ID", "")) p.add_argument("--db", default=os.environ.get("TRUEMEMORY_DB_PATH", "")) - p.add_argument("--host", choices=("claude", "codex", "gemini", "cursor"), default="claude") + p.add_argument("--host", choices=HOOK_HOSTS, default="claude") p.add_argument("--event", default="UserPromptSubmit") args, _ = p.parse_known_args() return args @@ -800,6 +801,11 @@ def main(): except Exception: pass # Never crash the hook + # Capture still runs on Cursor, whose beforeSubmitPrompt response cannot + # inject context. Avoid searching for recall that the host cannot receive. + if not supports_context(args.host, args.event): + return + # Issue #561 / #636 (M-41): consume the one-shot SessionStart recall marker # exactly ONCE per prompt. Both the proactive and auto-recall paths used to # call consume_recall_injected() independently — the proactive path consumed @@ -835,7 +841,7 @@ def main(): ) if recall_context: - print(json.dumps({"additionalContext": recall_context})) + emit_context(recall_context, host=args.host, event=args.event) def buffer_message(session_id: str, prompt: str):