diff --git a/hooks/hooks.json b/hooks/hooks.json index 51426fb..94f743d 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -29,6 +29,16 @@ } ] } + ], + "PostCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "python -m komi.adapters.claude_code.hook_compact" + } + ] + } ] } } diff --git a/komi/adapters/claude_code/hook_compact.py b/komi/adapters/claude_code/hook_compact.py new file mode 100644 index 0000000..eef949d --- /dev/null +++ b/komi/adapters/claude_code/hook_compact.py @@ -0,0 +1,17 @@ +"""PostCompact hook — re-inject recalled learnings after a /compact. + +Thin entry point so the installed hooks can register a clear ``hook_compact`` +command for PostCompact. The actual logic lives in :mod:`hook_recall` (it routes by +the ``hook_event_name`` on stdin), so this just delegates to keep one source of +truth for the recall build + emit. See hook_recall's module docstring for the +compaction-injection caveats. + +Entry point: ``python -m komi.adapters.claude_code.hook_compact`` +""" + +from __future__ import annotations + +from .hook_recall import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/komi/adapters/claude_code/hook_recall.py b/komi/adapters/claude_code/hook_recall.py index 745a9dc..f80fccc 100644 --- a/komi/adapters/claude_code/hook_recall.py +++ b/komi/adapters/claude_code/hook_recall.py @@ -1,12 +1,27 @@ -"""SessionStart hook — inject recalled learnings as additionalContext. - -Claude Code invokes this with the SessionStart hook JSON on stdin. We build the -recall block from the personal + project stores and emit it via -``hookSpecificOutput.additionalContext`` so it lands in the model's context with -zero user action. Runs once at session start to keep the prompt prefix stable -(the frozen-snapshot discipline that preserves the host's prompt cache). - -Entry point: ``python -m komi.adapters.claude_code.hook_recall`` +"""Recall hook — inject recalled learnings into the agent's context. + +Claude Code invokes this with a hook JSON on stdin. We build the recall block from +the personal + project stores and emit it so it lands in the model's context with +zero user action. It serves THREE events: + + • SessionStart (source = startup | resume | clear) — the primary path; emits + ``hookSpecificOutput.additionalContext``. Runs once at session start to keep the + prompt prefix stable (the frozen-snapshot discipline that preserves the cache). + + • SessionStart (source = compact) AND PostCompact — re-inject after a /compact (or + auto-compact), because compaction can drop the originally-injected learnings and + the agent would otherwise stop applying them mid-session. Research caveat: on + current Claude Code, SessionStart(compact) additionalContext is known to be + dropped (issue #15174) and PostCompact context-injection is under-documented — so + we register on BOTH and additionally print the block as plain stdout (the + documented "stdout is added to context" mechanism), to maximize the chance the + re-injection actually lands on whatever the host version honors. Best-effort by + design; if none inject, it degrades to a harmless no-op (the learnings are still + on disk and reload fully next session). + +Entry points: + ``python -m komi.adapters.claude_code.hook_recall`` (SessionStart) + ``python -m komi.adapters.claude_code.hook_compact`` (PostCompact; thin shim → main) """ from __future__ import annotations @@ -22,25 +37,24 @@ def main() -> int: payload = _read_stdin_json() cwd = payload.get("cwd", "") or "" - - # Kick off background maintenance if due (detached; never blocks this hook): - # pool sync (~12h cadence) and the slow curator (~7d cadence). - _maybe_sync_pool() - try: - from .curate import maybe_curate_in_background - maybe_curate_in_background() - except Exception: - pass + event, source = _classify_event(payload) + is_compaction = (event == "PostCompact") or (event == "SessionStart" and source == "compact") + + # Background maintenance (pool sync ~12h, curator ~7d) belongs to a genuine + # session START only — NOT to a compaction re-inject (which happens mid-session + # and shouldn't kick off cadenced jobs or disturb the running session). + if not is_compaction: + _maybe_sync_pool() + try: + from .curate import maybe_curate_in_background + maybe_curate_in_background() + except Exception: + pass try: - store = _merged_store(cwd) - block = recall( - store, - cwd=cwd, - recent_files=_recent_files(payload), - prompt_hint="", - config=RecallConfig(k=8, include_global=True), - ) + # Recompute the block FRESH every time — at compaction this picks up anything + # learned earlier this session. Cheap: a local store read. + block = build_block(cwd, payload) except Exception as e: # Never break the session because recall failed — emit nothing. _emit({}, note=f"komi recall skipped: {e}") @@ -50,15 +64,66 @@ def main() -> int: _emit({}) return 0 - _emit({ - "hookSpecificOutput": { - "hookEventName": "SessionStart", - "additionalContext": block, - } - }) + _emit_block(block, event, is_compaction) return 0 +def build_block(cwd: str, payload: dict) -> str: + """Build the recall context block from the merged store. Reusable across events.""" + store = _merged_store(cwd) + return recall( + store, + cwd=cwd, + recent_files=_recent_files(payload), + prompt_hint="", + config=RecallConfig(k=8, include_global=True), + ) + + +def _classify_event(payload: dict) -> tuple[str, str]: + """Return (event, source). ``event`` is the hook event name (SessionStart / + PostCompact / …); ``source`` is the SessionStart trigger (startup/resume/clear/ + compact) or the compaction trigger (manual/auto), empty if absent. Defaults to + SessionStart so a bare/legacy payload behaves exactly as before.""" + event = payload.get("hook_event_name") or "SessionStart" + source = payload.get("source") or payload.get("trigger") or "" + return event, source + + +def _emit_block(block: str, event: str, is_compaction: bool) -> None: + """Emit the recall block in the form the given event supports. + + The two injection mechanisms are mutually exclusive on one stdout stream (a + JSON object plus trailing plain text is neither valid JSON nor clean text), so + we choose by EVENT: + + • SessionStart (incl. source=compact): structured + ``hookSpecificOutput.additionalContext`` — the documented SessionStart path. + • PostCompact: plain stdout text — the documented "stdout is added to context" + path for PostCompact (its JSON additionalContext support is unconfirmed). + + Registering komi on BOTH SessionStart(compact) and PostCompact is the + belt-and-suspenders part (see module docstring): each speaks its own correct + format, so whichever event the host version actually honors, the learnings land. + At compaction we frame the block so the model knows it's a re-application.""" + if event == "PostCompact": + framed = ( + "Recalled learnings (re-applied after this conversation was compacted — " + "keep using them):\n" + block + ) + sys.stdout.write(framed) # plain stdout: PostCompact's add-to-context path + sys.stdout.flush() + return + + # SessionStart (startup/resume/clear/compact): structured additionalContext. + ctx = block + if is_compaction: + ctx = ("Recalled learnings (re-applied after this conversation was " + "compacted — keep using them):\n" + block) + _emit({"hookSpecificOutput": {"hookEventName": "SessionStart", + "additionalContext": ctx}}) + + def _merged_store(cwd: str) -> Store: """Personal store is the base; if in a project, its learnings share the same index so a single recall query sees both. We open the personal store (which diff --git a/komi/adapters/claude_code/setup.py b/komi/adapters/claude_code/setup.py index f0ceeab..f33e232 100644 --- a/komi/adapters/claude_code/setup.py +++ b/komi/adapters/claude_code/setup.py @@ -27,11 +27,16 @@ from . import paths -HOOK_EVENTS = ("SessionStart", "Stop", "SubagentStop") +HOOK_EVENTS = ("SessionStart", "Stop", "SubagentStop", "PostCompact") _HOOK_MODULES = { "SessionStart": "komi.adapters.claude_code.hook_recall", "Stop": "komi.adapters.claude_code.hook_distill", "SubagentStop": "komi.adapters.claude_code.hook_distill", + # PostCompact re-injects recalled learnings after a /compact (the SessionStart + # hook also fires with source=compact, but that path's injection is unreliable on + # current Claude Code — issue #15174 — so we register both). hook_compact routes + # through hook_recall.main(), which emits the format each event supports. + "PostCompact": "komi.adapters.claude_code.hook_compact", } _HOOK_MARKER = "komi.adapters.claude_code" diff --git a/tests/test_compact_reinjection.py b/tests/test_compact_reinjection.py new file mode 100644 index 0000000..095a484 --- /dev/null +++ b/tests/test_compact_reinjection.py @@ -0,0 +1,174 @@ +"""Compact-aware re-injection: the recall hook must re-emit learnings after a +/compact, in the format each event supports. + +Background (see hook_recall module docstring): compaction can drop the learnings +injected at SessionStart, so the agent stops applying them mid-session. We re-inject +on two events — SessionStart(source=compact) via JSON additionalContext, and +PostCompact via plain stdout — because neither is fully reliable alone on current +Claude Code. These tests pin the routing and the installer registration. +""" + +import io +import json +import os +import importlib +import tempfile +from unittest import mock + +import pytest + +from komi.adapters.claude_code import hook_recall as hr + + +_BLOCK = "SAMPLE LEARNING" + + +def _run_main(payload: dict) -> str: + """Drive hook_recall.main() with a given stdin payload + a stub recall block. + Returns whatever it wrote to stdout.""" + out = io.StringIO() + with mock.patch.object(hr, "build_block", lambda cwd, p: _BLOCK), \ + mock.patch.object(hr, "_maybe_sync_pool", lambda: None), \ + mock.patch.object(hr, "_read_stdin_json", lambda: payload), \ + mock.patch("sys.stdout", out): + rc = hr.main() + assert rc == 0 + return out.getvalue() + + +# ── event routing ──────────────────────────────────────────────────────────── + +def test_startup_emits_json_additionalcontext_unframed(): + out = _run_main({"hook_event_name": "SessionStart", "source": "startup", "cwd": "."}) + obj = json.loads(out) + assert obj["hookSpecificOutput"]["hookEventName"] == "SessionStart" + ctx = obj["hookSpecificOutput"]["additionalContext"] + assert _BLOCK in ctx + assert "compacted" not in ctx # normal start: no re-application framing + + +def test_sessionstart_compact_emits_json_with_framing(): + out = _run_main({"hook_event_name": "SessionStart", "source": "compact", "cwd": "."}) + obj = json.loads(out) # still JSON additionalContext + ctx = obj["hookSpecificOutput"]["additionalContext"] + assert _BLOCK in ctx + assert "compacted" in ctx # tells the model these are re-applied + + +def test_postcompact_emits_plain_stdout_not_json(): + out = _run_main({"hook_event_name": "PostCompact", "trigger": "manual", "cwd": "."}) + # PostCompact uses the plain-stdout add-to-context path, so it must NOT be JSON + with pytest.raises(json.JSONDecodeError): + json.loads(out) + assert _BLOCK in out + assert "compacted" in out + + +def test_legacy_payload_behaves_as_session_start(): + # a bare/old payload (no hook_event_name) must still inject as SessionStart JSON + out = _run_main({"cwd": "."}) + obj = json.loads(out) + assert obj["hookSpecificOutput"]["hookEventName"] == "SessionStart" + assert _BLOCK in obj["hookSpecificOutput"]["additionalContext"] + + +def test_empty_block_emits_nothing_actionable(): + out = io.StringIO() + with mock.patch.object(hr, "build_block", lambda cwd, p: ""), \ + mock.patch.object(hr, "_maybe_sync_pool", lambda: None), \ + mock.patch.object(hr, "_read_stdin_json", + lambda: {"hook_event_name": "PostCompact", "trigger": "auto"}), \ + mock.patch("sys.stdout", out): + hr.main() + # nothing to inject → emit an empty JSON object, never a stray block + assert out.getvalue() == "{}" + + +def test_recall_failure_never_breaks_session(): + def boom(cwd, p): + raise RuntimeError("store exploded") + out = io.StringIO() + with mock.patch.object(hr, "build_block", boom), \ + mock.patch.object(hr, "_maybe_sync_pool", lambda: None), \ + mock.patch.object(hr, "_read_stdin_json", + lambda: {"hook_event_name": "PostCompact"}), \ + mock.patch("sys.stdout", out): + rc = hr.main() + assert rc == 0 # graceful, non-fatal + assert "_note" in json.loads(out.getvalue()) # records why it skipped + + +def test_compaction_skips_background_maintenance(): + """A compaction re-inject must NOT kick off pool sync / curator (those belong to + a genuine session start; firing them mid-session is wrong).""" + called = {"sync": False, "curate": False} + with mock.patch.object(hr, "build_block", lambda cwd, p: _BLOCK), \ + mock.patch.object(hr, "_maybe_sync_pool", + lambda: called.__setitem__("sync", True)), \ + mock.patch.object(hr, "_read_stdin_json", + lambda: {"hook_event_name": "PostCompact", "trigger": "manual"}), \ + mock.patch("sys.stdout", io.StringIO()): + hr.main() + assert called["sync"] is False # not synced on a compaction event + + +def test_session_start_does_run_background_maintenance(): + called = {"sync": False} + with mock.patch.object(hr, "build_block", lambda cwd, p: _BLOCK), \ + mock.patch.object(hr, "_maybe_sync_pool", + lambda: called.__setitem__("sync", True)), \ + mock.patch.object(hr, "_read_stdin_json", + lambda: {"hook_event_name": "SessionStart", "source": "startup"}), \ + mock.patch("sys.stdout", io.StringIO()): + hr.main() + assert called["sync"] is True # genuine start: maintenance runs + + +# ── installer registers PostCompact ─────────────────────────────────────────── + +@pytest.fixture +def home(tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path)) + from komi.adapters.claude_code import paths, setup + importlib.reload(paths) + importlib.reload(setup) + return setup + + +def _cmds(setup_mod, event): + data = json.loads(setup_mod.settings_path().read_text(encoding="utf-8")) + return [h["command"] for e in data.get("hooks", {}).get(event, []) + for h in e.get("hooks", [])] + + +def test_install_registers_postcompact(home): + setup = home + setup._install_hooks() + pc = _cmds(setup, "PostCompact") + assert len(pc) == 1 + assert "hook_compact" in pc[0] + assert pc[0].split(" -m ")[0].strip().strip('"') not in ("python", "python3") # absolute + + +def test_install_postcompact_idempotent(home): + setup = home + setup._install_hooks(); setup._install_hooks(); setup._install_hooks() + assert len(_cmds(setup, "PostCompact")) == 1 + + +def test_uninstall_removes_postcompact(home): + setup = home + setup._install_hooks() + setup.uninstall(keep_data=True) + komi_pc = [c for c in _cmds(setup, "PostCompact") if "komi" in c] + assert komi_pc == [] + + +def test_plugin_manifest_has_postcompact(): + """The plugin install path uses hooks/hooks.json; it must declare PostCompact too.""" + from pathlib import Path + manifest = Path(__file__).resolve().parents[1] / "hooks" / "hooks.json" + data = json.loads(manifest.read_text(encoding="utf-8")) + pc = data["hooks"].get("PostCompact", []) + cmds = [h["command"] for e in pc for h in e.get("hooks", [])] + assert any("hook_compact" in c for c in cmds)