Skip to content
Open
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
45 changes: 45 additions & 0 deletions mempalace/hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1599,6 +1599,40 @@ def hook_precompact(data: dict, harness: str):
_output({})


_AUTO_SAVE_HOOK_CONFIG_KEYS = {
"stop": "stop",
"precompact": "pre_compact",
"session-end": "session_end",
}


def _hook_auto_save_enabled(hook_name: str) -> bool:
"""Return effective auto-save enablement for one dispatched hook.

``hooks.auto_save`` and ``MEMPALACE_HOOKS_AUTO_SAVE`` remain the master
switch. Individual ``hooks.stop``, ``hooks.pre_compact``, and
``hooks.session_end`` booleans may opt out independently. Missing or
non-boolean per-hook values preserve the historical enabled behavior.
"""
config_key = _AUTO_SAVE_HOOK_CONFIG_KEYS.get(hook_name)
if config_key is None:
return True
try:
config = MempalaceConfig()
if not config.hooks_auto_save:
return False
file_config = getattr(config, "_file_config", {})
hooks = file_config.get("hooks", {}) if isinstance(file_config, dict) else {}
if not isinstance(hooks, dict):
return True
value = hooks.get(config_key, True)
return value if isinstance(value, bool) else True
except Exception:
# Preserve the existing save-on-config-read-failure behavior: a broken
# config read must not silently suppress memory capture.
return True


def run_hook(hook_name: str, harness: str):
"""Main entry point: read stdin JSON, dispatch to hook handler."""
try:
Expand All @@ -1619,4 +1653,15 @@ def run_hook(hook_name: str, harness: str):
print(f"Unknown hook: {hook_name}", file=sys.stderr)
sys.exit(1)

if not _hook_auto_save_enabled(hook_name):
if hook_name == "session-end":
try:
parsed = _parse_harness_input(data, harness)
session_id = parsed["session_id"]
except Exception:
session_id = "unknown"
_clear_session_last_save(session_id)
_output({})
return

handler(data, harness)
125 changes: 125 additions & 0 deletions tests/test_per_hook_auto_save.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import io
import json
from unittest.mock import MagicMock

import pytest

import mempalace.hooks_cli as hooks_cli
from mempalace.config import MempalaceConfig


def _config(tmp_path, monkeypatch, hooks):
monkeypatch.delenv("MEMPALACE_HOOKS_AUTO_SAVE", raising=False)
(tmp_path / "config.json").write_text(json.dumps({"hooks": hooks}), encoding="utf-8")
return MempalaceConfig(config_dir=str(tmp_path))


def _install_config(monkeypatch, cfg):
monkeypatch.setattr(hooks_cli, "MempalaceConfig", lambda: cfg)


def test_per_hook_controls_default_to_enabled(tmp_path, monkeypatch):
cfg = _config(tmp_path, monkeypatch, {})
_install_config(monkeypatch, cfg)
assert hooks_cli._hook_auto_save_enabled("stop") is True
assert hooks_cli._hook_auto_save_enabled("precompact") is True
assert hooks_cli._hook_auto_save_enabled("session-end") is True


def test_per_hook_controls_are_independent(tmp_path, monkeypatch):
cfg = _config(
tmp_path,
monkeypatch,
{"stop": False, "pre_compact": True, "session_end": False},
)
_install_config(monkeypatch, cfg)
assert hooks_cli._hook_auto_save_enabled("stop") is False
assert hooks_cli._hook_auto_save_enabled("precompact") is True
assert hooks_cli._hook_auto_save_enabled("session-end") is False


def test_master_auto_save_false_disables_every_hook(tmp_path, monkeypatch):
cfg = _config(
tmp_path,
monkeypatch,
{"auto_save": False, "stop": True, "pre_compact": True, "session_end": True},
)
_install_config(monkeypatch, cfg)
assert hooks_cli._hook_auto_save_enabled("stop") is False
assert hooks_cli._hook_auto_save_enabled("precompact") is False
assert hooks_cli._hook_auto_save_enabled("session-end") is False


def test_env_master_true_still_allows_per_hook_opt_out(tmp_path, monkeypatch):
(tmp_path / "config.json").write_text(
json.dumps({"hooks": {"auto_save": False, "stop": False}}),
encoding="utf-8",
)
monkeypatch.setenv("MEMPALACE_HOOKS_AUTO_SAVE", "true")
cfg = MempalaceConfig(config_dir=str(tmp_path))
_install_config(monkeypatch, cfg)
assert cfg.hooks_auto_save is True
assert hooks_cli._hook_auto_save_enabled("stop") is False
assert hooks_cli._hook_auto_save_enabled("precompact") is True


def test_non_boolean_per_hook_value_preserves_enabled_behavior(tmp_path, monkeypatch):
cfg = _config(tmp_path, monkeypatch, {"stop": "false"})
_install_config(monkeypatch, cfg)
assert hooks_cli._hook_auto_save_enabled("stop") is True


@pytest.mark.parametrize(
("hook_name", "config_key"),
[("stop", "stop"), ("precompact", "pre_compact")],
)
def test_disabled_hook_short_circuits_before_handler(
tmp_path, monkeypatch, hook_name, config_key
):
cfg = _config(tmp_path, monkeypatch, {config_key: False})
_install_config(monkeypatch, cfg)
output = []
handler = MagicMock()
monkeypatch.setattr(hooks_cli, "_output", output.append)
monkeypatch.setattr(
hooks_cli,
"hook_stop" if hook_name == "stop" else "hook_precompact",
handler,
)
monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps({"session_id": "s"})))

hooks_cli.run_hook(hook_name, "claude-code")

assert output == [{}]
handler.assert_not_called()


def test_disabled_session_end_skips_handler_but_keeps_cleanup(tmp_path, monkeypatch):
cfg = _config(tmp_path, monkeypatch, {"session_end": False})
_install_config(monkeypatch, cfg)
output = []
handler = MagicMock()
cleanup = MagicMock()
monkeypatch.setattr(hooks_cli, "_output", output.append)
monkeypatch.setattr(hooks_cli, "hook_session_end", handler)
monkeypatch.setattr(hooks_cli, "_clear_session_last_save", cleanup)
monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps({"session_id": "session-7"})))

hooks_cli.run_hook("session-end", "claude-code")

assert output == [{}]
handler.assert_not_called()
cleanup.assert_called_once_with("session-7")


def test_enabled_hook_dispatches_normally(tmp_path, monkeypatch):
cfg = _config(tmp_path, monkeypatch, {"stop": True})
_install_config(monkeypatch, cfg)
handler = MagicMock()
payload = {"session_id": "s", "transcript_path": ""}
monkeypatch.setattr(hooks_cli, "hook_stop", handler)
monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(payload)))

hooks_cli.run_hook("stop", "claude-code")

handler.assert_called_once_with(payload, "claude-code")