Skip to content

Commit ecd44f7

Browse files
authored
fix(hooks): stop precompact hook from blocking compaction (#856, #858) (#863)
* fix(hooks): stop precompact hook from blocking compaction The precompact hook unconditionally returned {"decision": "block"}, which in Claude Code means "cancel compaction" with no retry mechanism. This made /compact permanently broken for all plugin users. Changed hook_precompact() to mine the transcript synchronously (so data lands before compaction) and return {"decision": "allow"}. This matches the standalone bash hook in hooks/ which already uses allow. Also extracted _get_mine_dir() and _mine_sync() helpers so precompact can mine from the transcript directory, not just MEMPAL_DIR. Stop hook behavior is unchanged -- left for #673 which implements the full silent save path. Closes #856, closes #858. * fix: use empty JSON instead of invalid \"allow\" decision value Claude Code only recognizes \"block\" as a top-level decision value. \"allow\" is a permissionDecision value for PreToolUse hooks, not a valid top-level decision. The correct way to not block is to return empty JSON. Caught by #872.
1 parent b226251 commit ecd44f7

2 files changed

Lines changed: 131 additions & 41 deletions

File tree

mempalace/hooks_cli.py

Lines changed: 51 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -122,20 +122,53 @@ def _output(data: dict):
122122
print(json.dumps(data, indent=2, ensure_ascii=False))
123123

124124

125-
def _maybe_auto_ingest():
126-
"""If MEMPAL_DIR is set and exists, run mempalace mine in background."""
125+
def _get_mine_dir(transcript_path: str = "") -> str:
126+
"""Determine directory to mine from MEMPAL_DIR or transcript path."""
127127
mempal_dir = os.environ.get("MEMPAL_DIR", "")
128128
if mempal_dir and os.path.isdir(mempal_dir):
129-
try:
130-
log_path = STATE_DIR / "hook.log"
131-
with open(log_path, "a") as log_f:
132-
subprocess.Popen(
133-
[sys.executable, "-m", "mempalace", "mine", mempal_dir],
134-
stdout=log_f,
135-
stderr=log_f,
136-
)
137-
except OSError:
138-
pass
129+
return mempal_dir
130+
if transcript_path:
131+
path = Path(transcript_path).expanduser()
132+
if path.is_file():
133+
return str(path.parent)
134+
return ""
135+
136+
137+
def _maybe_auto_ingest(transcript_path: str = ""):
138+
"""Run mempalace mine in background if a mine directory is available."""
139+
mine_dir = _get_mine_dir(transcript_path)
140+
if not mine_dir:
141+
return
142+
try:
143+
STATE_DIR.mkdir(parents=True, exist_ok=True)
144+
log_path = STATE_DIR / "hook.log"
145+
with open(log_path, "a") as log_f:
146+
subprocess.Popen(
147+
[sys.executable, "-m", "mempalace", "mine", mine_dir],
148+
stdout=log_f,
149+
stderr=log_f,
150+
)
151+
except OSError:
152+
pass
153+
154+
155+
def _mine_sync(transcript_path: str = ""):
156+
"""Run mempalace mine synchronously (for precompact -- data must land first)."""
157+
mine_dir = _get_mine_dir(transcript_path)
158+
if not mine_dir:
159+
return
160+
try:
161+
STATE_DIR.mkdir(parents=True, exist_ok=True)
162+
log_path = STATE_DIR / "hook.log"
163+
with open(log_path, "a") as log_f:
164+
subprocess.run(
165+
[sys.executable, "-m", "mempalace", "mine", mine_dir],
166+
stdout=log_f,
167+
stderr=log_f,
168+
timeout=60,
169+
)
170+
except (OSError, subprocess.TimeoutExpired):
171+
pass
139172

140173

141174
SUPPORTED_HARNESSES = {"claude-code", "codex"}
@@ -192,7 +225,7 @@ def hook_stop(data: dict, harness: str):
192225
_log(f"TRIGGERING SAVE at exchange {exchange_count}")
193226

194227
# Optional: auto-ingest if MEMPAL_DIR is set
195-
_maybe_auto_ingest()
228+
_maybe_auto_ingest(transcript_path)
196229

197230
_output({"decision": "block", "reason": STOP_BLOCK_REASON})
198231
else:
@@ -214,29 +247,17 @@ def hook_session_start(data: dict, harness: str):
214247

215248

216249
def hook_precompact(data: dict, harness: str):
217-
"""Precompact hook: always block with comprehensive save instruction."""
250+
"""Precompact hook: mine transcript synchronously, then allow compaction."""
218251
parsed = _parse_harness_input(data, harness)
219252
session_id = parsed["session_id"]
253+
transcript_path = parsed["transcript_path"]
220254

221255
_log(f"PRE-COMPACT triggered for session {session_id}")
222256

223-
# Optional: auto-ingest synchronously before compaction (so memories land first)
224-
mempal_dir = os.environ.get("MEMPAL_DIR", "")
225-
if mempal_dir and os.path.isdir(mempal_dir):
226-
try:
227-
log_path = STATE_DIR / "hook.log"
228-
with open(log_path, "a") as log_f:
229-
subprocess.run(
230-
[sys.executable, "-m", "mempalace", "mine", mempal_dir],
231-
stdout=log_f,
232-
stderr=log_f,
233-
timeout=60,
234-
)
235-
except OSError:
236-
pass
257+
# Mine synchronously so data lands before compaction proceeds
258+
_mine_sync(transcript_path)
237259

238-
# Always block -- compaction = save everything
239-
_output({"decision": "block", "reason": PRECOMPACT_BLOCK_REASON})
260+
_output({})
240261

241262

242263
def run_hook(hook_name: str, harness: str):

tests/test_hooks_cli.py

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import contextlib
22
import io
33
import json
4+
import subprocess
45
from pathlib import Path
56
from unittest.mock import patch
67

@@ -9,8 +10,8 @@
910
from mempalace.hooks_cli import (
1011
SAVE_INTERVAL,
1112
STOP_BLOCK_REASON,
12-
PRECOMPACT_BLOCK_REASON,
1313
_count_human_messages,
14+
_get_mine_dir,
1415
_log,
1516
_maybe_auto_ingest,
1617
_parse_harness_input,
@@ -205,14 +206,13 @@ def test_session_start_passes_through(tmp_path):
205206
# --- hook_precompact ---
206207

207208

208-
def test_precompact_always_blocks(tmp_path):
209+
def test_precompact_allows(tmp_path):
209210
result = _capture_hook_output(
210211
hook_precompact,
211212
{"session_id": "test"},
212213
state_dir=tmp_path,
213214
)
214-
assert result["decision"] == "block"
215-
assert result["reason"] == PRECOMPACT_BLOCK_REASON
215+
assert result == {}
216216

217217

218218
# --- _log ---
@@ -238,7 +238,7 @@ def test_log_oserror_is_silenced(tmp_path):
238238

239239

240240
def test_maybe_auto_ingest_no_env(tmp_path):
241-
"""Without MEMPAL_DIR set, does nothing."""
241+
"""Without MEMPAL_DIR or transcript_path, does nothing."""
242242
with patch.dict("os.environ", {}, clear=True):
243243
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
244244
_maybe_auto_ingest() # should not raise
@@ -255,6 +255,17 @@ def test_maybe_auto_ingest_with_env(tmp_path):
255255
mock_popen.assert_called_once()
256256

257257

258+
def test_maybe_auto_ingest_with_transcript(tmp_path):
259+
"""Falls back to transcript directory when MEMPAL_DIR is not set."""
260+
transcript = tmp_path / "t.jsonl"
261+
transcript.write_text("")
262+
with patch.dict("os.environ", {}, clear=True):
263+
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
264+
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
265+
_maybe_auto_ingest(str(transcript))
266+
mock_popen.assert_called_once()
267+
268+
258269
def test_maybe_auto_ingest_oserror(tmp_path):
259270
"""OSError during subprocess spawn is silenced."""
260271
mempal_dir = tmp_path / "project"
@@ -265,6 +276,33 @@ def test_maybe_auto_ingest_oserror(tmp_path):
265276
_maybe_auto_ingest() # should not raise
266277

267278

279+
# --- _get_mine_dir ---
280+
281+
282+
def test_get_mine_dir_mempal_dir(tmp_path):
283+
"""MEMPAL_DIR takes priority over transcript_path."""
284+
mempal_dir = tmp_path / "project"
285+
mempal_dir.mkdir()
286+
transcript = tmp_path / "t.jsonl"
287+
transcript.write_text("")
288+
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
289+
assert _get_mine_dir(str(transcript)) == str(mempal_dir)
290+
291+
292+
def test_get_mine_dir_transcript_fallback(tmp_path):
293+
"""Falls back to transcript parent dir when MEMPAL_DIR is not set."""
294+
transcript = tmp_path / "t.jsonl"
295+
transcript.write_text("")
296+
with patch.dict("os.environ", {}, clear=True):
297+
assert _get_mine_dir(str(transcript)) == str(tmp_path)
298+
299+
300+
def test_get_mine_dir_empty():
301+
"""Returns empty string when nothing is available."""
302+
with patch.dict("os.environ", {}, clear=True):
303+
assert _get_mine_dir("") == ""
304+
305+
268306
# --- _parse_harness_input ---
269307

270308

@@ -333,7 +371,7 @@ def bad_write_text(*args, **kwargs):
333371

334372

335373
def test_precompact_with_mempal_dir(tmp_path):
336-
"""Precompact runs subprocess.run when MEMPAL_DIR is set."""
374+
"""Precompact runs subprocess.run (sync) when MEMPAL_DIR is set."""
337375
mempal_dir = tmp_path / "project"
338376
mempal_dir.mkdir()
339377
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
@@ -343,7 +381,7 @@ def test_precompact_with_mempal_dir(tmp_path):
343381
{"session_id": "test"},
344382
state_dir=tmp_path,
345383
)
346-
assert result["decision"] == "block"
384+
assert result == {}
347385
mock_run.assert_called_once()
348386

349387

@@ -358,7 +396,40 @@ def test_precompact_with_mempal_dir_oserror(tmp_path):
358396
{"session_id": "test"},
359397
state_dir=tmp_path,
360398
)
361-
assert result["decision"] == "block"
399+
assert result == {}
400+
401+
402+
def test_precompact_with_timeout(tmp_path):
403+
"""Precompact handles TimeoutExpired gracefully -- still allows."""
404+
mempal_dir = tmp_path / "project"
405+
mempal_dir.mkdir()
406+
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
407+
with patch(
408+
"mempalace.hooks_cli.subprocess.run",
409+
side_effect=subprocess.TimeoutExpired(cmd="mine", timeout=60),
410+
):
411+
result = _capture_hook_output(
412+
hook_precompact, {"session_id": "test"}, state_dir=tmp_path
413+
)
414+
assert result == {}
415+
416+
417+
def test_precompact_mines_transcript_dir(tmp_path, monkeypatch):
418+
"""Precompact mines transcript directory when no MEMPAL_DIR."""
419+
transcript = tmp_path / "t.jsonl"
420+
transcript.write_text("")
421+
monkeypatch.delenv("MEMPAL_DIR", raising=False)
422+
with patch("mempalace.hooks_cli.subprocess.run") as mock_run:
423+
result = _capture_hook_output(
424+
hook_precompact,
425+
{"session_id": "test", "transcript_path": str(transcript)},
426+
state_dir=tmp_path,
427+
)
428+
assert result == {}
429+
mock_run.assert_called_once()
430+
# Verify mine dir is the transcript's parent
431+
call_args = mock_run.call_args[0][0]
432+
assert str(tmp_path) in call_args[-1]
362433

363434

364435
# --- run_hook ---
@@ -399,9 +470,7 @@ def test_run_hook_dispatches_precompact(tmp_path):
399470
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
400471
with patch("mempalace.hooks_cli._output") as mock_output:
401472
run_hook("precompact", "claude-code")
402-
mock_output.assert_called_once()
403-
call_args = mock_output.call_args[0][0]
404-
assert call_args["decision"] == "block"
473+
mock_output.assert_called_once_with({})
405474

406475

407476
def test_run_hook_unknown_hook():

0 commit comments

Comments
 (0)