Skip to content

fix(hooks): treat absent ~/.mempalace as auto-save off#1305

Merged
igorls merged 2 commits intoMemPalace:developfrom
lcatlett:upstream/respect-absent-palace-dir
May 6, 2026
Merged

fix(hooks): treat absent ~/.mempalace as auto-save off#1305
igorls merged 2 commits intoMemPalace:developfrom
lcatlett:upstream/respect-absent-palace-dir

Conversation

@lcatlett
Copy link
Copy Markdown
Contributor

@lcatlett lcatlett commented May 2, 2026

Summary

When the user removes ~/.mempalace/ (a strong "do not auto-capture" signal), the next hook fire silently recreates the entire dir and ingests existing transcripts. This PR makes the absence of ~/.mempalace/ a kill-switch across all hooks.

Caught in the wild: ~146 drawers materialized in <1s after a deliberate rm -rf ~/.mempalace/, into a session that was explicitly not meant to be captured.

Root cause (three layers)

  1. _log() at hooks_cli.py:148 unconditionally calls STATE_DIR.mkdir(parents=True, exist_ok=True). So writing the hook log line itself recreates ~/.mempalace/hook_state/.
  2. With no config file present, hook_stop_auto_save / hook_precompact_auto_save default to True (no override to read). The fail-open semantics treat "no config" as "save enabled" — exactly backwards when the entire dir was just deleted.
  3. hook_session_start has no toggle at all and unconditionally _log()s + STATE_DIR.mkdir()s.

Fix

All four entry points (hook_stop, hook_precompact, hook_session_start, and _log itself) now check a new PALACE_ROOT = Path.home() / ".mempalace" constant and short-circuit (returning {} on stdout, never logging) when the directory is absent. The user-removable directory is now a kill-switch.

_output() writes directly to stdout fd 1 — no disk side effect. So the short-circuit response can be sent without recreating anything.

Tests

5 unit tests in tests/test_hooks_cli.py:

  • test_hook_stop_does_not_create_palace_dir_when_absent
  • test_hook_precompact_does_not_create_palace_dir_when_absent
  • test_hook_session_start_does_not_create_palace_dir_when_absent
  • test_log_does_not_create_palace_dir_when_absent
  • test_existing_dir_proceeds_normally (regression)

Full suite: 1469 passed, 1 skipped → 1474 passed, 1 skipped (+5, 0 regressions).

Related

Not a duplicate of any of the above, but adjacent.

Test plan

  • python3 -m pytest tests/test_hooks_cli.py -k "palace_dir or proceeds_normally" -v
  • Full suite: python3 -m pytest tests/ --ignore=tests/benchmarks -q
  • Live: rm -rf ~/.mempalace, fire all three hooks, confirm ~/.mempalace stays absent

@Qodo-Free-For-OSS
Copy link
Copy Markdown

Hi, The new kill-switch treats any existing ~/.mempalace path (including a file) as “enabled”, so hooks can proceed and then crash on STATE_DIR.mkdir() because the parent is not a directory.

Severity: remediation recommended | Category: reliability

How to fix: Use is_dir for PALACE_ROOT

Agent prompt to fix - you can give this to your LLM of choice:

Issue description

Hooks should only proceed when ~/.mempalace is an actual directory. Using Path.exists() allows a file at that path to bypass the kill-switch and later crash when STATE_DIR.mkdir() runs.

Issue Context

  • _palace_root_exists() is the new gate for all hook entrypoints.
  • Several hook paths call STATE_DIR.mkdir(...) without catching OSError.

Fix Focus Areas

  • mempalace/hooks_cli.py[22-30]
  • mempalace/hooks_cli.py[155-179]
  • mempalace/hooks_cli.py[565-715]

Found by Qodo code review

@igorls igorls added area/cli CLI commands area/hooks Claude Code hook scripts (Stop, PreCompact, SessionStart) bug Something isn't working storage labels May 2, 2026
@igorls igorls added this to the v3.3.5 milestone May 2, 2026
@igorls
Copy link
Copy Markdown
Member

igorls commented May 2, 2026

Thanks @lcatlett — small, correct, and the new tests look good. Two changes before merge:

1. Rebase against develop — currently DIRTY (CHANGELOG.md likely conflicts).

2. Address the Qodo bot commentPath.exists() returns True if a file (not a directory) sits at ~/.mempalace, which would let later STATE_DIR.mkdir() crash. One-line hardening:

def _palace_root_exists() -> bool:
    return PALACE_ROOT.is_dir()

Once both are in, I'll authorize CI on the fork PR and we can land it for v3.3.5.

lcatlett added 2 commits May 2, 2026 20:33
When the user removes ~/.mempalace/ (a strong "do not auto-capture"
signal), the next hook fire would silently recreate the entire dir
hierarchy and ingest existing transcripts:

1. _log() at hooks_cli.py:148 unconditionally calls
   STATE_DIR.mkdir(parents=True, exist_ok=True), so the act of
   writing the hook log line recreated ~/.mempalace/hook_state/
2. With no config file present, hook_stop_auto_save and
   hook_precompact_auto_save defaulted to True (no override to read)
3. The full save path then ran, materializing palace/, wal/,
   knowledge_graph.sqlite3, and N drawers from existing transcripts
   in ~/.claude/projects/*.jsonl

All four entry points (hook_stop, hook_precompact, hook_session_start,
and _log itself) now check a new PALACE_ROOT = Path.home() / ".mempalace"
constant first and short-circuit (returning {} on stdout, never logging)
when the dir is absent. The user-removable directory is now a kill-switch.

Five unit tests in tests/test_hooks_cli.py cover: hook_stop /
hook_precompact / hook_session_start do not create the dir when absent;
_log() does not create it when absent; existing dir proceeds normally
(regression).

Caught in the wild on a downstream fork: ~146 drawers materialized in
under a second after a deliberate `rm -rf ~/.mempalace/`, into a planning
session that was explicitly not meant to be captured.
Both @igorls and the Qodo bot flagged that `_palace_root_exists()` used
`Path.exists()`, which returns True for a regular file. A stray file at
`~/.mempalace` would let the kill-switch be bypassed and crash later in
`STATE_DIR.mkdir()` with NotADirectoryError.

Switched to `Path.is_dir()`. Also fold `_log()`'s inline check through
`_palace_root_exists()` so both kill-switch sites use the same predicate.

New test pins the behavior: a regular file at the palace root path is
treated as absent (hook short-circuits, _log does not crash, the stray
file is left untouched).
@lcatlett lcatlett force-pushed the upstream/respect-absent-palace-dir branch from 672bf0f to 2d50b21 Compare May 3, 2026 00:40
@igorls igorls merged commit d9ab5b7 into MemPalace:develop May 6, 2026
5 of 6 checks passed
igorls added a commit to cantenesse/mempalace that referenced this pull request May 6, 2026
The alias was placed below an explanatory comment block introduced by
MemPalace#1305, which trips ruff E402 (module-level import not at top of file).
Moved next to the existing 'from mempalace.hooks_cli import (...)' line.

CI lint went red on develop after MemPalace#1305 merged with the failing check;
this re-greens it so subsequent PRs do not inherit the failure.
xcarbo added a commit to xcarbo/mempalace that referenced this pull request May 7, 2026
Catches up on a heavy upstream day — 22 fixes merged in 24h plus prior
backlog. Highlights pulled in:

- MemPalace#1305 hooks: ~/.mempalace/ deletion is now a stable kill-switch (hooks
  no longer rebuild the dir hierarchy on Stop/PreCompact/SessionStart)
- MemPalace#1214 KG: reject inverted intervals (valid_to < valid_from) at write time —
  prevents silently invisible triples
- MemPalace#1067/MemPalace#1105 chroma: ChromaBackend.close_palace() now actually releases
  the SQLite file lock (PersistentClient.close() on evict + invalidation)
- MemPalace#1215 entity_registry: atomic save (tmp+fsync+rename) — no more
  corruption on crash mid-write
- MemPalace#1073/MemPalace#1107 mempalace compress: paginated drawer fetch — no longer
  trips SQLITE_MAX_VARIABLE_NUMBER on palaces >32k drawers
- MemPalace#1282 stdio: Windows console UTF-8 reconfig for cli/mcp_server/hooks_cli
- MemPalace#1164/MemPalace#1167 mcp KG: sanitize_iso_date() blocks malformed date strings
  silently producing empty result sets
- MemPalace#1136/MemPalace#1160 mcp: per-path KG cache for multi-tenant hosts that rotate
  MEMPALACE_PALACE_PATH between tool calls
- MemPalace#1286 mcp: retry _get_collection() once on transient failure
- MemPalace#1138 lint cleanup, MemPalace#1019 search-crash fix
- 4 new tools/ scripts (backup_claude_jsonls, find_orphan_claude_jsonls,
  render_jsonl, save.md)

Conflict resolution (CHANGELOG.md only — code files all auto-merged):

- 3.3.5 section: untouched (already merged in our prior commit; upstream
  added several new bug-fix entries which auto-merged cleanly)
- 3.3.4 Bug Fixes: kept upstream's new MemPalace#1305 entry; preserved our richer
  detail on topic-tunnels (MemPalace#1194/MemPalace#1195/MemPalace#1197), HNSW-bloat (MemPalace#1191),
  max_seq_id (MemPalace#1135), and auto-ingest (MemPalace#1230/MemPalace#1231) — upstream's shorter
  topic-tunnels entry was a strict subset of ours.

xdev patches preserved (still on this branch, untouched by merge):
- 6ef44cb fix(hooks): route CC transcripts via convo_miner with cwd-based wings
- 3fad61d fix(config): allow leading dash in wing names
- 3fc821a fix(config): tighten leading-char to allow dash but not underscore

Tests: 1557 passed, 1 skipped (full unit suite excluding benchmarks).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/cli CLI commands area/hooks Claude Code hook scripts (Stop, PreCompact, SessionStart) bug Something isn't working storage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants