Skip to content

PreCompact hook unconditionally blocks /compact, making it unusable #1172

@chrisreeves48

Description

@chrisreeves48

Summary

The PreCompact hook in hooks_cli.py:hook_precompact() outputs {"decision": "block"} on every invocation, with no conditional logic and no pass-through path. Because Claude Code cancels the /compact action outright when a PreCompact hook blocks (the user never sees a mid-conversation reminder the way Stop blocks surface), there is no mechanism for the user or the assistant to ever get past the hook. The net effect: any user with the mempalace plugin enabled cannot ever successfully run /compact.

Root cause

mempalace/hooks_cli.py:193-216 (as of v3.0.14):

def hook_precompact(data: dict, harness: str):
    """Precompact hook: always block with comprehensive save instruction."""
    parsed = _parse_harness_input(data, harness)
    session_id = parsed["session_id"]

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

    # Optional: auto-ingest synchronously before compaction (so memories land first)
    mempal_dir = os.environ.get("MEMPAL_DIR", "")
    if mempal_dir and os.path.isdir(mempal_dir):
        try:
            ...
            subprocess.run([sys.executable, "-m", "mempalace", "mine", mempal_dir], ...)
        except OSError:
            pass

    # Always block -- compaction = save everything
    _output({"decision": "block", "reason": PRECOMPACT_BLOCK_REASON})

The docstring is explicit: "Precompact hook: always block with comprehensive save instruction." But there is no corresponding mechanism for letting compaction proceed afterward — the block message says "Save everything to MemPalace, then allow compaction to proceed" but there is no path to "allow."

Why this differs from the Stop hook

The Stop hook has conditional blocking logic at hook_stop() (~line 134): "block every N messages for auto-save." When Stop blocks, the assistant receives a system-reminder mid-conversation, responds with MemPalace tool calls, and the next Stop doesn't block. That loop works.

/compact can't benefit from that loop because it's a local slash command — when the PreCompact hook blocks, the compact operation cancels immediately, so the assistant never gets a turn to respond and unblock.

Reproduction

  1. Enable the mempalace@mempalace plugin in Claude Code.
  2. Start any session; have any amount of conversation.
  3. Run /compact.
  4. Compact fails with:
    Compaction blocked by PreCompact hook: COMPACTION IMMINENT (MemPalace). Save ALL session content before context is lost: ...
    
  5. Save everything to MemPalace (diary + drawers + KG facts). Retry /compact. Same failure.
  6. There is no sequence of MemPalace writes that will let /compact through.

Expected

After the user (or assistant) has saved recent context to MemPalace, /compact should be allowed to proceed.

Suggested fix

A hybrid approach that preserves the safety intent while being responsive to actual save state:

LAST_WRITE_FILE = STATE_DIR / "last_write.ts"
DEFAULT_COMPACT_WINDOW_SEC = 600  # 10 min

def _touch_last_write():
    """Call from diary_write, add_drawer, kg_add on success."""
    try:
        LAST_WRITE_FILE.write_text(str(time.time()))
    except OSError:
        pass

def hook_precompact(data: dict, harness: str):
    parsed = _parse_harness_input(data, harness)
    _log(f"PRE-COMPACT triggered for session {parsed['session_id']}")

    # existing MEMPAL_DIR auto-ingest stays as-is
    ...

    # Escape hatch: users who explicitly opt out of the safety check
    if os.environ.get("MEMPAL_ALLOW_COMPACT", "").lower() in ("1", "true", "yes"):
        _output({})
        return

    # Pass through if MemPalace has had writes recently
    threshold = int(os.environ.get("MEMPAL_COMPACT_WINDOW_SEC",
                                   str(DEFAULT_COMPACT_WINDOW_SEC)))
    if LAST_WRITE_FILE.exists():
        try:
            last_write = float(LAST_WRITE_FILE.read_text().strip())
            if time.time() - last_write < threshold:
                _output({})
                return
        except (OSError, ValueError):
            pass

    # Otherwise block with instruction
    _output({"decision": "block", "reason": PRECOMPACT_BLOCK_REASON})

Plus a small additional change to the block reason text so users hitting the block know their way out:

If you just saved and want to compact immediately, set MEMPAL_ALLOW_COMPACT=1 in your environment and retry, or wait for the Stop hook to fire another save.

Local workaround

Replace line 216 with _output({}):

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

Reverts on plugin upgrade. The synchronous mempalace mine MEMPAL_DIR at lines ~200-213 still runs, so MEMPAL_DIR-based memories land before compaction. Session-specific context relies on the Stop hook having fired its usual save reminders throughout the session, which in practice it does.

Environment

  • Plugin: mempalace@mempalace v3.0.14
  • Python: 3.9 (user-level install, ~/Library/Python/3.9/lib/python/site-packages/mempalace/)
  • Harness: Claude Code CLI on macOS (Darwin 25.4.0)

Happy to submit a PR against this approach if the design looks reasonable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/hooksClaude Code hook scripts (Stop, PreCompact, SessionStart)bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions