Skip to content

Commit 00b6524

Browse files
rainxchzedclaude
andcommitted
feat: re-inject recalled learnings after /compact (compaction-aware recall)
After a /compact (manual or auto), the agent can lose the learnings injected at SessionStart and stop applying them mid-session. This makes the recall hook compaction-aware so it re-injects them. Research finding that shaped the design: no single compaction hook reliably injects context on current Claude Code — SessionStart(source=compact) fires but its additionalContext is dropped (issue #15174), and PostCompact's context injection is under-documented. So we register on BOTH and emit the format each one supports: - hook_recall.main() now routes by hook_event_name + source: * SessionStart (startup/resume/clear): JSON hookSpecificOutput.additionalContext (unchanged primary path; frozen-snapshot discipline preserved). * SessionStart (compact): same JSON path, with framing telling the model these are re-applied after compaction. * PostCompact: PLAIN stdout (the documented "stdout is added to context" path for PostCompact), framed the same way. Recall is recomputed fresh at compaction so it includes anything learned earlier this session. Background maintenance (pool sync, curator) runs ONLY on a genuine session start, never on a compaction re-inject. - new hook_compact.py: thin PostCompact entry point delegating to hook_recall.main(). - setup.py + plugin hooks/hooks.json register PostCompact (merge-not-clobber, idempotent, absolute interpreter path, removed on uninstall). Best-effort by design: if a host version injects on neither event, it degrades to a harmless no-op (learnings stay on disk, reload fully next session). Needs on-device verification given the upstream hook bugs. Tests: tests/test_compact_reinjection.py (event routing, framing, plain-vs-JSON, graceful failure, maintenance-only-on-start, installer registers + idempotent + uninstall + plugin manifest). Full suite 215 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 21a1085 commit 00b6524

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)