Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
498 changes: 498 additions & 0 deletions tests/test-native-hook-output-763.py

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions tests/test_cursor_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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}
],
},
}
Expand Down Expand Up @@ -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}
],
},
}
Expand Down Expand Up @@ -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}
],
},
}
Expand Down
27 changes: 22 additions & 5 deletions tests/test_issue_630_search_supplement_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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").
Expand Down
23 changes: 15 additions & 8 deletions truememory/hooks/adapters/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -172,26 +172,29 @@ 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:
hooks[stale] = cleaned
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):
continue
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, [])
Expand All @@ -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"],
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
)

Expand Down Expand Up @@ -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:
Expand Down
58 changes: 58 additions & 0 deletions truememory/ingest/hooks/output.py
Original file line number Diff line number Diff line change
@@ -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
12 changes: 6 additions & 6 deletions truememory/ingest/hooks/session_start.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 9 additions & 3 deletions truememory/ingest/hooks/user_prompt_submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
Loading