Skip to content

Commit f088e43

Browse files
authored
Merge pull request #6 from kurikomi-labs/compact-reinjection
Re-inject recalled learnings after /compact
2 parents 21a1085 + 00b6524 commit f088e43

5 files changed

Lines changed: 304 additions & 33 deletions

File tree

hooks/hooks.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@
2929
}
3030
]
3131
}
32+
],
33+
"PostCompact": [
34+
{
35+
"hooks": [
36+
{
37+
"type": "command",
38+
"command": "python -m komi.adapters.claude_code.hook_compact"
39+
}
40+
]
41+
}
3242
]
3343
}
3444
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""PostCompact hook — re-inject recalled learnings after a /compact.
2+
3+
Thin entry point so the installed hooks can register a clear ``hook_compact``
4+
command for PostCompact. The actual logic lives in :mod:`hook_recall` (it routes by
5+
the ``hook_event_name`` on stdin), so this just delegates to keep one source of
6+
truth for the recall build + emit. See hook_recall's module docstring for the
7+
compaction-injection caveats.
8+
9+
Entry point: ``python -m komi.adapters.claude_code.hook_compact``
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from .hook_recall import main
15+
16+
if __name__ == "__main__":
17+
raise SystemExit(main())

komi/adapters/claude_code/hook_recall.py

Lines changed: 97 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,27 @@
1-
"""SessionStart hook — inject recalled learnings as additionalContext.
2-
3-
Claude Code invokes this with the SessionStart hook JSON on stdin. We build the
4-
recall block from the personal + project stores and emit it via
5-
``hookSpecificOutput.additionalContext`` so it lands in the model's context with
6-
zero user action. Runs once at session start to keep the prompt prefix stable
7-
(the frozen-snapshot discipline that preserves the host's prompt cache).
8-
9-
Entry point: ``python -m komi.adapters.claude_code.hook_recall``
1+
"""Recall hook — inject recalled learnings into the agent's context.
2+
3+
Claude Code invokes this with a hook JSON on stdin. We build the recall block from
4+
the personal + project stores and emit it so it lands in the model's context with
5+
zero user action. It serves THREE events:
6+
7+
• SessionStart (source = startup | resume | clear) — the primary path; emits
8+
``hookSpecificOutput.additionalContext``. Runs once at session start to keep the
9+
prompt prefix stable (the frozen-snapshot discipline that preserves the cache).
10+
11+
• SessionStart (source = compact) AND PostCompact — re-inject after a /compact (or
12+
auto-compact), because compaction can drop the originally-injected learnings and
13+
the agent would otherwise stop applying them mid-session. Research caveat: on
14+
current Claude Code, SessionStart(compact) additionalContext is known to be
15+
dropped (issue #15174) and PostCompact context-injection is under-documented — so
16+
we register on BOTH and additionally print the block as plain stdout (the
17+
documented "stdout is added to context" mechanism), to maximize the chance the
18+
re-injection actually lands on whatever the host version honors. Best-effort by
19+
design; if none inject, it degrades to a harmless no-op (the learnings are still
20+
on disk and reload fully next session).
21+
22+
Entry points:
23+
``python -m komi.adapters.claude_code.hook_recall`` (SessionStart)
24+
``python -m komi.adapters.claude_code.hook_compact`` (PostCompact; thin shim → main)
1025
"""
1126

1227
from __future__ import annotations
@@ -22,25 +37,24 @@
2237
def main() -> int:
2338
payload = _read_stdin_json()
2439
cwd = payload.get("cwd", "") or ""
25-
26-
# Kick off background maintenance if due (detached; never blocks this hook):
27-
# pool sync (~12h cadence) and the slow curator (~7d cadence).
28-
_maybe_sync_pool()
29-
try:
30-
from .curate import maybe_curate_in_background
31-
maybe_curate_in_background()
32-
except Exception:
33-
pass
40+
event, source = _classify_event(payload)
41+
is_compaction = (event == "PostCompact") or (event == "SessionStart" and source == "compact")
42+
43+
# Background maintenance (pool sync ~12h, curator ~7d) belongs to a genuine
44+
# session START only — NOT to a compaction re-inject (which happens mid-session
45+
# and shouldn't kick off cadenced jobs or disturb the running session).
46+
if not is_compaction:
47+
_maybe_sync_pool()
48+
try:
49+
from .curate import maybe_curate_in_background
50+
maybe_curate_in_background()
51+
except Exception:
52+
pass
3453

3554
try:
36-
store = _merged_store(cwd)
37-
block = recall(
38-
store,
39-
cwd=cwd,
40-
recent_files=_recent_files(payload),
41-
prompt_hint="",
42-
config=RecallConfig(k=8, include_global=True),
43-
)
55+
# Recompute the block FRESH every time — at compaction this picks up anything
56+
# learned earlier this session. Cheap: a local store read.
57+
block = build_block(cwd, payload)
4458
except Exception as e:
4559
# Never break the session because recall failed — emit nothing.
4660
_emit({}, note=f"komi recall skipped: {e}")
@@ -50,15 +64,66 @@ def main() -> int:
5064
_emit({})
5165
return 0
5266

53-
_emit({
54-
"hookSpecificOutput": {
55-
"hookEventName": "SessionStart",
56-
"additionalContext": block,
57-
}
58-
})
67+
_emit_block(block, event, is_compaction)
5968
return 0
6069

6170

71+
def build_block(cwd: str, payload: dict) -> str:
72+
"""Build the recall context block from the merged store. Reusable across events."""
73+
store = _merged_store(cwd)
74+
return recall(
75+
store,
76+
cwd=cwd,
77+
recent_files=_recent_files(payload),
78+
prompt_hint="",
79+
config=RecallConfig(k=8, include_global=True),
80+
)
81+
82+
83+
def _classify_event(payload: dict) -> tuple[str, str]:
84+
"""Return (event, source). ``event`` is the hook event name (SessionStart /
85+
PostCompact / …); ``source`` is the SessionStart trigger (startup/resume/clear/
86+
compact) or the compaction trigger (manual/auto), empty if absent. Defaults to
87+
SessionStart so a bare/legacy payload behaves exactly as before."""
88+
event = payload.get("hook_event_name") or "SessionStart"
89+
source = payload.get("source") or payload.get("trigger") or ""
90+
return event, source
91+
92+
93+
def _emit_block(block: str, event: str, is_compaction: bool) -> None:
94+
"""Emit the recall block in the form the given event supports.
95+
96+
The two injection mechanisms are mutually exclusive on one stdout stream (a
97+
JSON object plus trailing plain text is neither valid JSON nor clean text), so
98+
we choose by EVENT:
99+
100+
• SessionStart (incl. source=compact): structured
101+
``hookSpecificOutput.additionalContext`` — the documented SessionStart path.
102+
• PostCompact: plain stdout text — the documented "stdout is added to context"
103+
path for PostCompact (its JSON additionalContext support is unconfirmed).
104+
105+
Registering komi on BOTH SessionStart(compact) and PostCompact is the
106+
belt-and-suspenders part (see module docstring): each speaks its own correct
107+
format, so whichever event the host version actually honors, the learnings land.
108+
At compaction we frame the block so the model knows it's a re-application."""
109+
if event == "PostCompact":
110+
framed = (
111+
"Recalled learnings (re-applied after this conversation was compacted — "
112+
"keep using them):\n" + block
113+
)
114+
sys.stdout.write(framed) # plain stdout: PostCompact's add-to-context path
115+
sys.stdout.flush()
116+
return
117+
118+
# SessionStart (startup/resume/clear/compact): structured additionalContext.
119+
ctx = block
120+
if is_compaction:
121+
ctx = ("Recalled learnings (re-applied after this conversation was "
122+
"compacted — keep using them):\n" + block)
123+
_emit({"hookSpecificOutput": {"hookEventName": "SessionStart",
124+
"additionalContext": ctx}})
125+
126+
62127
def _merged_store(cwd: str) -> Store:
63128
"""Personal store is the base; if in a project, its learnings share the same
64129
index so a single recall query sees both. We open the personal store (which

komi/adapters/claude_code/setup.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,16 @@
2727

2828
from . import paths
2929

30-
HOOK_EVENTS = ("SessionStart", "Stop", "SubagentStop")
30+
HOOK_EVENTS = ("SessionStart", "Stop", "SubagentStop", "PostCompact")
3131
_HOOK_MODULES = {
3232
"SessionStart": "komi.adapters.claude_code.hook_recall",
3333
"Stop": "komi.adapters.claude_code.hook_distill",
3434
"SubagentStop": "komi.adapters.claude_code.hook_distill",
35+
# PostCompact re-injects recalled learnings after a /compact (the SessionStart
36+
# hook also fires with source=compact, but that path's injection is unreliable on
37+
# current Claude Code — issue #15174 — so we register both). hook_compact routes
38+
# through hook_recall.main(), which emits the format each event supports.
39+
"PostCompact": "komi.adapters.claude_code.hook_compact",
3540
}
3641
_HOOK_MARKER = "komi.adapters.claude_code"
3742

tests/test_compact_reinjection.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""Compact-aware re-injection: the recall hook must re-emit learnings after a
2+
/compact, in the format each event supports.
3+
4+
Background (see hook_recall module docstring): compaction can drop the learnings
5+
injected at SessionStart, so the agent stops applying them mid-session. We re-inject
6+
on two events — SessionStart(source=compact) via JSON additionalContext, and
7+
PostCompact via plain stdout — because neither is fully reliable alone on current
8+
Claude Code. These tests pin the routing and the installer registration.
9+
"""
10+
11+
import io
12+
import json
13+
import os
14+
import importlib
15+
import tempfile
16+
from unittest import mock
17+
18+
import pytest
19+
20+
from komi.adapters.claude_code import hook_recall as hr
21+
22+
23+
_BLOCK = "<komi-recall>SAMPLE LEARNING</komi-recall>"
24+
25+
26+
def _run_main(payload: dict) -> str:
27+
"""Drive hook_recall.main() with a given stdin payload + a stub recall block.
28+
Returns whatever it wrote to stdout."""
29+
out = io.StringIO()
30+
with mock.patch.object(hr, "build_block", lambda cwd, p: _BLOCK), \
31+
mock.patch.object(hr, "_maybe_sync_pool", lambda: None), \
32+
mock.patch.object(hr, "_read_stdin_json", lambda: payload), \
33+
mock.patch("sys.stdout", out):
34+
rc = hr.main()
35+
assert rc == 0
36+
return out.getvalue()
37+
38+
39+
# ── event routing ────────────────────────────────────────────────────────────
40+
41+
def test_startup_emits_json_additionalcontext_unframed():
42+
out = _run_main({"hook_event_name": "SessionStart", "source": "startup", "cwd": "."})
43+
obj = json.loads(out)
44+
assert obj["hookSpecificOutput"]["hookEventName"] == "SessionStart"
45+
ctx = obj["hookSpecificOutput"]["additionalContext"]
46+
assert _BLOCK in ctx
47+
assert "compacted" not in ctx # normal start: no re-application framing
48+
49+
50+
def test_sessionstart_compact_emits_json_with_framing():
51+
out = _run_main({"hook_event_name": "SessionStart", "source": "compact", "cwd": "."})
52+
obj = json.loads(out) # still JSON additionalContext
53+
ctx = obj["hookSpecificOutput"]["additionalContext"]
54+
assert _BLOCK in ctx
55+
assert "compacted" in ctx # tells the model these are re-applied
56+
57+
58+
def test_postcompact_emits_plain_stdout_not_json():
59+
out = _run_main({"hook_event_name": "PostCompact", "trigger": "manual", "cwd": "."})
60+
# PostCompact uses the plain-stdout add-to-context path, so it must NOT be JSON
61+
with pytest.raises(json.JSONDecodeError):
62+
json.loads(out)
63+
assert _BLOCK in out
64+
assert "compacted" in out
65+
66+
67+
def test_legacy_payload_behaves_as_session_start():
68+
# a bare/old payload (no hook_event_name) must still inject as SessionStart JSON
69+
out = _run_main({"cwd": "."})
70+
obj = json.loads(out)
71+
assert obj["hookSpecificOutput"]["hookEventName"] == "SessionStart"
72+
assert _BLOCK in obj["hookSpecificOutput"]["additionalContext"]
73+
74+
75+
def test_empty_block_emits_nothing_actionable():
76+
out = io.StringIO()
77+
with mock.patch.object(hr, "build_block", lambda cwd, p: ""), \
78+
mock.patch.object(hr, "_maybe_sync_pool", lambda: None), \
79+
mock.patch.object(hr, "_read_stdin_json",
80+
lambda: {"hook_event_name": "PostCompact", "trigger": "auto"}), \
81+
mock.patch("sys.stdout", out):
82+
hr.main()
83+
# nothing to inject → emit an empty JSON object, never a stray block
84+
assert out.getvalue() == "{}"
85+
86+
87+
def test_recall_failure_never_breaks_session():
88+
def boom(cwd, p):
89+
raise RuntimeError("store exploded")
90+
out = io.StringIO()
91+
with mock.patch.object(hr, "build_block", boom), \
92+
mock.patch.object(hr, "_maybe_sync_pool", lambda: None), \
93+
mock.patch.object(hr, "_read_stdin_json",
94+
lambda: {"hook_event_name": "PostCompact"}), \
95+
mock.patch("sys.stdout", out):
96+
rc = hr.main()
97+
assert rc == 0 # graceful, non-fatal
98+
assert "_note" in json.loads(out.getvalue()) # records why it skipped
99+
100+
101+
def test_compaction_skips_background_maintenance():
102+
"""A compaction re-inject must NOT kick off pool sync / curator (those belong to
103+
a genuine session start; firing them mid-session is wrong)."""
104+
called = {"sync": False, "curate": False}
105+
with mock.patch.object(hr, "build_block", lambda cwd, p: _BLOCK), \
106+
mock.patch.object(hr, "_maybe_sync_pool",
107+
lambda: called.__setitem__("sync", True)), \
108+
mock.patch.object(hr, "_read_stdin_json",
109+
lambda: {"hook_event_name": "PostCompact", "trigger": "manual"}), \
110+
mock.patch("sys.stdout", io.StringIO()):
111+
hr.main()
112+
assert called["sync"] is False # not synced on a compaction event
113+
114+
115+
def test_session_start_does_run_background_maintenance():
116+
called = {"sync": False}
117+
with mock.patch.object(hr, "build_block", lambda cwd, p: _BLOCK), \
118+
mock.patch.object(hr, "_maybe_sync_pool",
119+
lambda: called.__setitem__("sync", True)), \
120+
mock.patch.object(hr, "_read_stdin_json",
121+
lambda: {"hook_event_name": "SessionStart", "source": "startup"}), \
122+
mock.patch("sys.stdout", io.StringIO()):
123+
hr.main()
124+
assert called["sync"] is True # genuine start: maintenance runs
125+
126+
127+
# ── installer registers PostCompact ───────────────────────────────────────────
128+
129+
@pytest.fixture
130+
def home(tmp_path, monkeypatch):
131+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path))
132+
from komi.adapters.claude_code import paths, setup
133+
importlib.reload(paths)
134+
importlib.reload(setup)
135+
return setup
136+
137+
138+
def _cmds(setup_mod, event):
139+
data = json.loads(setup_mod.settings_path().read_text(encoding="utf-8"))
140+
return [h["command"] for e in data.get("hooks", {}).get(event, [])
141+
for h in e.get("hooks", [])]
142+
143+
144+
def test_install_registers_postcompact(home):
145+
setup = home
146+
setup._install_hooks()
147+
pc = _cmds(setup, "PostCompact")
148+
assert len(pc) == 1
149+
assert "hook_compact" in pc[0]
150+
assert pc[0].split(" -m ")[0].strip().strip('"') not in ("python", "python3") # absolute
151+
152+
153+
def test_install_postcompact_idempotent(home):
154+
setup = home
155+
setup._install_hooks(); setup._install_hooks(); setup._install_hooks()
156+
assert len(_cmds(setup, "PostCompact")) == 1
157+
158+
159+
def test_uninstall_removes_postcompact(home):
160+
setup = home
161+
setup._install_hooks()
162+
setup.uninstall(keep_data=True)
163+
komi_pc = [c for c in _cmds(setup, "PostCompact") if "komi" in c]
164+
assert komi_pc == []
165+
166+
167+
def test_plugin_manifest_has_postcompact():
168+
"""The plugin install path uses hooks/hooks.json; it must declare PostCompact too."""
169+
from pathlib import Path
170+
manifest = Path(__file__).resolve().parents[1] / "hooks" / "hooks.json"
171+
data = json.loads(manifest.read_text(encoding="utf-8"))
172+
pc = data["hooks"].get("PostCompact", [])
173+
cmds = [h["command"] for e in pc for h in e.get("hooks", [])]
174+
assert any("hook_compact" in c for c in cmds)

0 commit comments

Comments
 (0)