Skip to content
Merged
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
10 changes: 10 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@
}
]
}
],
"PostCompact": [
{
"hooks": [
{
"type": "command",
"command": "python -m komi.adapters.claude_code.hook_compact"
}
]
}
]
}
}
17 changes: 17 additions & 0 deletions komi/adapters/claude_code/hook_compact.py
Original file line number Diff line number Diff line change
@@ -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())
129 changes: 97 additions & 32 deletions komi/adapters/claude_code/hook_recall.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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}")
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion komi/adapters/claude_code/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
174 changes: 174 additions & 0 deletions tests/test_compact_reinjection.py
Original file line number Diff line number Diff line change
@@ -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 = "<komi-recall>SAMPLE LEARNING</komi-recall>"


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)
Loading