Skip to content
Closed
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
30 changes: 30 additions & 0 deletions mempalace/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,36 @@ def hook_silent_save(self):
"""Whether the stop hook saves directly (True) or blocks for MCP calls (False)."""
return self._file_config.get("hooks", {}).get("silent_save", True)

@property
def hooks_save_interval(self):
"""Number of human messages between auto-save checkpoints.

Set to 0 to disable the stop hook entirely.
Env var MEMPALACE_HOOKS_SAVE_INTERVAL overrides config file.
Default: 15.
"""
env_val = os.environ.get("MEMPALACE_HOOKS_SAVE_INTERVAL")
if env_val is not None:
try:
return max(0, int(env_val))
except ValueError:
pass
return self._file_config.get("hooks", {}).get("save_interval", 15)

@property
def hooks_precompact(self):
"""Whether the precompact hook blocks before context compaction.

This is the last chance to save before context is lost.
Disabled only by explicit opt-out.
Env var MEMPALACE_HOOKS_PRECOMPACT overrides config file.
Default: True.
"""
env_val = os.environ.get("MEMPALACE_HOOKS_PRECOMPACT")
if env_val is not None:
return env_val.lower() not in ("false", "0", "no")
return self._file_config.get("hooks", {}).get("precompact", True)

@property
def hook_desktop_toast(self):
"""Whether the stop hook shows a desktop notification via notify-send."""
Expand Down
29 changes: 25 additions & 4 deletions mempalace/hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
from datetime import datetime
from pathlib import Path

SAVE_INTERVAL = 15
from mempalace.config import MempalaceConfig

STATE_DIR = Path.home() / ".mempalace" / "hook_state"

STOP_BLOCK_REASON = (
Expand Down Expand Up @@ -203,12 +204,23 @@ def _parse_harness_input(data: dict, harness: str) -> dict:


def hook_stop(data: dict, harness: str):
"""Stop hook: block every N messages for auto-save."""
"""Stop hook: block every N messages for auto-save.

Interval is configurable via hooks.save_interval in config.json
or MEMPALACE_HOOKS_SAVE_INTERVAL env var. Set to 0 to disable.
"""
parsed = _parse_harness_input(data, harness)
session_id = parsed["session_id"]
stop_hook_active = parsed["stop_hook_active"]
transcript_path = parsed["transcript_path"]

save_interval = MempalaceConfig().hooks_save_interval

# Interval 0 = disabled
if save_interval == 0:
_output({})
return

# If already in a save cycle, let through (infinite-loop prevention)
if str(stop_hook_active).lower() in ("true", "1", "yes"):
_output({})
Expand All @@ -231,7 +243,7 @@ def hook_stop(data: dict, harness: str):

_log(f"Session {session_id}: {exchange_count} exchanges, {since_last} since last save")

if since_last >= SAVE_INTERVAL and exchange_count > 0:
if since_last >= save_interval and exchange_count > 0:
# Update last save point
try:
last_save_file.write_text(str(exchange_count), encoding="utf-8")
Expand Down Expand Up @@ -263,11 +275,20 @@ def hook_session_start(data: dict, harness: str):


def hook_precompact(data: dict, harness: str):
"""Precompact hook: mine transcript synchronously, then allow compaction."""
"""Precompact hook: mine transcript synchronously, then allow compaction.

Controlled separately from stop hook via hooks.precompact config
or MEMPALACE_HOOKS_PRECOMPACT env var. Default: enabled.
"""
parsed = _parse_harness_input(data, harness)
session_id = parsed["session_id"]
transcript_path = parsed["transcript_path"]

if not MempalaceConfig().hooks_precompact:
_log(f"PRE-COMPACT skipped (disabled) for session {session_id}")
_output({})
return

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

# Mine synchronously so data lands before compaction proceeds
Expand Down
83 changes: 83 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,86 @@ def test_kg_value_rejects_null_bytes():
def test_kg_value_rejects_over_length():
with pytest.raises(ValueError):
sanitize_kg_value("a" * 129)


# --- hooks config ---


def test_hooks_save_interval_default():
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
assert cfg.hooks_save_interval == 15


def test_hooks_save_interval_from_config():
tmpdir = tempfile.mkdtemp()
with open(os.path.join(tmpdir, "config.json"), "w") as f:
json.dump({"hooks": {"save_interval": 50}}, f)
cfg = MempalaceConfig(config_dir=tmpdir)
assert cfg.hooks_save_interval == 50


def test_hooks_save_interval_zero_disables():
tmpdir = tempfile.mkdtemp()
with open(os.path.join(tmpdir, "config.json"), "w") as f:
json.dump({"hooks": {"save_interval": 0}}, f)
cfg = MempalaceConfig(config_dir=tmpdir)
assert cfg.hooks_save_interval == 0


def test_hooks_save_interval_env_override():
os.environ["MEMPALACE_HOOKS_SAVE_INTERVAL"] = "30"
try:
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
assert cfg.hooks_save_interval == 30
finally:
del os.environ["MEMPALACE_HOOKS_SAVE_INTERVAL"]


def test_hooks_save_interval_env_zero():
os.environ["MEMPALACE_HOOKS_SAVE_INTERVAL"] = "0"
try:
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
assert cfg.hooks_save_interval == 0
finally:
del os.environ["MEMPALACE_HOOKS_SAVE_INTERVAL"]


def test_hooks_save_interval_negative_clamped():
os.environ["MEMPALACE_HOOKS_SAVE_INTERVAL"] = "-5"
try:
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
assert cfg.hooks_save_interval == 0
finally:
del os.environ["MEMPALACE_HOOKS_SAVE_INTERVAL"]


def test_hooks_precompact_default():
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
assert cfg.hooks_precompact is True


def test_hooks_precompact_disabled():
tmpdir = tempfile.mkdtemp()
with open(os.path.join(tmpdir, "config.json"), "w") as f:
json.dump({"hooks": {"precompact": False}}, f)
cfg = MempalaceConfig(config_dir=tmpdir)
assert cfg.hooks_precompact is False


def test_hooks_precompact_env_override():
os.environ["MEMPALACE_HOOKS_PRECOMPACT"] = "false"
try:
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
assert cfg.hooks_precompact is False
finally:
del os.environ["MEMPALACE_HOOKS_PRECOMPACT"]


def test_hooks_save_interval_and_precompact_independent():
"""Disabling stop hook doesn't affect precompact and vice versa."""
tmpdir = tempfile.mkdtemp()
with open(os.path.join(tmpdir, "config.json"), "w") as f:
json.dump({"hooks": {"save_interval": 0, "precompact": True}}, f)
cfg = MempalaceConfig(config_dir=tmpdir)
assert cfg.hooks_save_interval == 0
assert cfg.hooks_precompact is True
120 changes: 117 additions & 3 deletions tests/test_hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import pytest

from mempalace.hooks_cli import (
SAVE_INTERVAL,
STOP_BLOCK_REASON,
_count_human_messages,
_get_mine_dir,
Expand All @@ -23,6 +22,9 @@
run_hook,
)

# Default save interval used by existing tests (matches config default)
SAVE_INTERVAL = 15


# --- _sanitize_session_id ---

Expand Down Expand Up @@ -118,9 +120,14 @@ def _capture_hook_output(hook_fn, data, harness="claude-code", state_dir=None):
patches = [patch("mempalace.hooks_cli._output", side_effect=lambda d: buf.write(json.dumps(d)))]
if state_dir:
patches.append(patch("mempalace.hooks_cli.STATE_DIR", state_dir))
# If MempalaceConfig is not already patched by caller, provide defaults
mock_cfg = patch("mempalace.hooks_cli.MempalaceConfig")
patches.append(mock_cfg)
with contextlib.ExitStack() as stack:
for p in patches:
stack.enter_context(p)
mocks = [stack.enter_context(p) for p in patches]
cfg_mock = mocks[-1]
cfg_mock.return_value.hooks_save_interval = SAVE_INTERVAL
cfg_mock.return_value.hooks_precompact = True
hook_fn(data, harness)
return json.loads(buf.getvalue())

Expand Down Expand Up @@ -572,3 +579,110 @@ def test_stop_hook_rejects_injected_stop_hook_active(tmp_path):
# The injected value is not "true"/"1"/"yes", so the hook should NOT pass through
# It should count messages and block at the interval
assert result["decision"] == "block"


# --- configurable save interval ---


def _capture_hook_with_config(hook_fn, data, state_dir, save_interval=15, precompact=True):
"""Run a hook with specific config values and capture output + _mine_sync calls."""
import io

buf = io.StringIO()
with contextlib.ExitStack() as stack:
stack.enter_context(
patch("mempalace.hooks_cli._output", side_effect=lambda d: buf.write(json.dumps(d)))
)
stack.enter_context(patch("mempalace.hooks_cli.STATE_DIR", state_dir))
mock_cfg = stack.enter_context(patch("mempalace.hooks_cli.MempalaceConfig"))
mock_cfg.return_value.hooks_save_interval = save_interval
mock_cfg.return_value.hooks_precompact = precompact
mine_mock = stack.enter_context(patch("mempalace.hooks_cli._mine_sync"))
hook_fn(data, "claude-code")
return json.loads(buf.getvalue()), mine_mock


def test_stop_hook_disabled_by_zero_interval(tmp_path):
"""save_interval=0 disables the stop hook entirely."""
transcript = tmp_path / "t.jsonl"
_write_transcript(
transcript,
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(30)],
)
result, _ = _capture_hook_with_config(
hook_stop,
{"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)},
state_dir=tmp_path,
save_interval=0,
)
assert result == {}


def test_stop_hook_custom_interval(tmp_path):
"""save_interval=5 triggers after 5 messages, not 15."""
transcript = tmp_path / "t.jsonl"
_write_transcript(
transcript,
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(5)],
)
result, _ = _capture_hook_with_config(
hook_stop,
{"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)},
state_dir=tmp_path,
save_interval=5,
)
assert result["decision"] == "block"


def test_stop_hook_custom_interval_not_reached(tmp_path):
"""save_interval=50 does NOT trigger at 15 messages."""
transcript = tmp_path / "t.jsonl"
_write_transcript(
transcript,
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(15)],
)
result, _ = _capture_hook_with_config(
hook_stop,
{"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)},
state_dir=tmp_path,
save_interval=50,
)
assert result == {}


def test_precompact_disabled_by_config(tmp_path):
"""hooks.precompact=false skips the precompact mining (no _mine_sync call)."""
result, mine_mock = _capture_hook_with_config(
hook_precompact,
{"session_id": "test"},
state_dir=tmp_path,
precompact=False,
)
assert result == {}
mine_mock.assert_not_called()


def test_precompact_enabled_by_default(tmp_path):
"""Precompact mines synchronously by default and passes through (post-#863)."""
result, mine_mock = _capture_hook_with_config(
hook_precompact,
{"session_id": "test"},
state_dir=tmp_path,
save_interval=0,
precompact=True,
)
assert result == {}
mine_mock.assert_called_once()


def test_stop_disabled_precompact_still_works(tmp_path):
"""save_interval=0 doesn't affect precompact — they're independent."""
result, mine_mock = _capture_hook_with_config(
hook_precompact,
{"session_id": "test"},
state_dir=tmp_path,
save_interval=0,
precompact=True,
)
assert result == {}
mine_mock.assert_called_once()