|
| 1 | +# `.sh` shim delegation strategy |
| 2 | + |
| 3 | +**Status:** active fork-ahead decision |
| 4 | +**First filed:** 2026-05-22 ([issue #69](https://github.com/techempower-org/mempalace/issues/69)) |
| 5 | +**Counter-position to:** upstream [MemPalace/mempalace#1069](https://github.com/MemPalace/mempalace/issues/1069) |
| 6 | +**Related discussion:** [MemPalace/mempalace#1497](https://github.com/MemPalace/mempalace/issues/1497) — gateway pattern recommendation |
| 7 | + |
| 8 | +## TL;DR |
| 9 | + |
| 10 | +Upstream wants to consolidate the per-event `.sh` hook wrappers |
| 11 | +(`mempal_save_hook.sh`, `mempal_precompact_hook.sh`, …) into thin |
| 12 | +shims that delegate to **`mempalace hook run --hook <name>`** — i.e. |
| 13 | +move all hook logic into the `mempalace` Python CLI. |
| 14 | + |
| 15 | +This fork went the **opposite direction**: the `.sh` shims delegate |
| 16 | +to **`palace-daemon/clients/hook.py`** (a stdlib-only Python script |
| 17 | +in a separate repo) which talks HTTP to a single FastAPI gateway |
| 18 | +(`disks.jphe.in:8085`). The `mempalace` Python package is no longer |
| 19 | +in the hook call path at all. |
| 20 | + |
| 21 | +We are not asking upstream to do this. We are documenting the |
| 22 | +divergence so future contributors don't "fix" the shims back to |
| 23 | +the upstream shape and so the upstream discussion in #1497 has a |
| 24 | +working reference. |
| 25 | + |
| 26 | +## Why we keep `.sh` shims |
| 27 | + |
| 28 | +The temptation is to delete them: `hooks.json` can call `python3 |
| 29 | +/path/to/hook.py` directly, and recent Claude Code releases honor |
| 30 | +that. We keep the shims for three concrete reasons: |
| 31 | + |
| 32 | +1. **Backward compatibility with stale Claude Code sessions.** A |
| 33 | + Claude Code session loads its hook config once, at startup. |
| 34 | + Sessions that started before the 2026-05-11 split-brain fix |
| 35 | + still have the *old* hook config in memory — the one that |
| 36 | + points at `.claude-plugin/hooks/mempal-stop-hook.sh`. Keeping |
| 37 | + the shim as a thin pass-through means those sessions still |
| 38 | + route through the daemon instead of erroring out or, worse, |
| 39 | + silently writing to a stale local palace. |
| 40 | +2. **Operational simplicity.** The shim is the one knob a host |
| 41 | + admin can flip without editing JSON. Override `PALACE_DAEMON_HOOK_PY` |
| 42 | + to point at a different `hook.py` location (CI fixture, |
| 43 | + non-default install path) and every harness that calls the |
| 44 | + shim picks it up. No need to chase `hooks.json` per repo. |
| 45 | +3. **Graceful absence.** If `palace-daemon/clients/hook.py` isn't |
| 46 | + present on this host (fresh checkout, CI runner without the |
| 47 | + sibling repo cloned), the shim exits 0. The harness keeps |
| 48 | + working; hooks become no-ops. The Python-CLI direction has |
| 49 | + no equivalent escape — a missing `mempalace` binary surfaces |
| 50 | + as a hook timeout. |
| 51 | + |
| 52 | +## Why delegate to palace-daemon |
| 53 | + |
| 54 | +Three constraints made the daemon the right write authority: |
| 55 | + |
| 56 | +| Upstream #1069 direction | This fork's direction | |
| 57 | +|---|---| |
| 58 | +| `mempalace hook run` (Python CLI) | `palace-daemon/clients/hook.py` (stdlib Python) | |
| 59 | +| Requires `mempalace` import at hook time | Hook has no `mempalace` dep | |
| 60 | +| Optional daemon routing via `PALACE_DAEMON_URL` | Always-on, via HTTP | |
| 61 | +| Library-level lock in `mempalace` | Single-writer gateway (daemon `_mine_sem`) | |
| 62 | +| Vulnerable to CLI version-mismatch hangs (upstream #1465) | Sidestepped — no CLI in the call path | |
| 63 | +| Failure mode if daemon down: undefined | Hook returns `{}` (silent), no error | |
| 64 | + |
| 65 | +The single-writer-gateway property is the load-bearing one. |
| 66 | +With 300K+ drawers and multiple harnesses (Claude Code, codex, |
| 67 | +gemini-cli, opencode, the MCP server) all writing concurrently, |
| 68 | +serialization through a single FastAPI process running on |
| 69 | +`disks.jphe.in:8085` is the only thing that keeps ChromaDB's |
| 70 | +HNSW from corrupting under concurrent mine jobs (upstream #1161). |
| 71 | + |
| 72 | +## The delegation pattern |
| 73 | + |
| 74 | +``` |
| 75 | +Claude Code Stop event |
| 76 | + │ |
| 77 | + ▼ |
| 78 | +hooks.json: "command": "/path/to/mempal-stop-hook.sh" |
| 79 | + │ |
| 80 | + ▼ |
| 81 | +mempal-stop-hook.sh ◄── thin .sh shim, ~5 lines of logic |
| 82 | + │ |
| 83 | + │ exec python3 $HOOK_PY --hook stop --harness claude-code |
| 84 | + ▼ |
| 85 | +palace-daemon/clients/hook.py ◄── stdlib Python, no mempalace import |
| 86 | + │ |
| 87 | + │ urllib.request → POST http://disks.jphe.in:8085/mine |
| 88 | + ▼ |
| 89 | +palace-daemon (FastAPI) ◄── single writer, _mine_sem=1 |
| 90 | + │ |
| 91 | + ▼ |
| 92 | +mempalace.mcp_server (in-process) |
| 93 | + │ |
| 94 | + ▼ |
| 95 | +postgres + pgvector + AGE (on disks.jphe.in) |
| 96 | +``` |
| 97 | + |
| 98 | +Two properties matter: |
| 99 | + |
| 100 | +- **The shim doesn't know about MemPalace internals.** It just |
| 101 | + forwards `argv` to `hook.py` and respects the |
| 102 | + `PALACE_DAEMON_HOOK_PY` override. |
| 103 | +- **`hook.py` doesn't know about the database.** It speaks HTTP |
| 104 | + to the daemon. Schema changes, backend swaps (chroma→pgvector, |
| 105 | + pgvector→whatever's next) don't ripple through hook code. |
| 106 | + |
| 107 | +## Shim template |
| 108 | + |
| 109 | +Three live shims in this repo follow the same shape — copy |
| 110 | +them when adding a new harness: |
| 111 | + |
| 112 | +```bash |
| 113 | +#!/bin/bash |
| 114 | +# MemPalace <event> Hook — thin wrapper delegating to palace-daemon's hook.py. |
| 115 | +# |
| 116 | +# Override the hook.py location with PALACE_DAEMON_HOOK_PY=/path/to/hook.py |
| 117 | +# — required on hosts where palace-daemon lives somewhere other than the |
| 118 | +# default below (e.g. CI fixtures, alternate install paths). |
| 119 | +# |
| 120 | +# If palace-daemon's hook.py is missing on this machine, we exit 0 (not |
| 121 | +# a hard error) so a <event> event from a host without palace-daemon |
| 122 | +# doesn't gum up the harness. |
| 123 | + |
| 124 | +HOOK_PY="${PALACE_DAEMON_HOOK_PY:-/home/jp/Projects/palace-daemon/clients/hook.py}" |
| 125 | +if [ -x "$(command -v python3)" ] && [ -f "$HOOK_PY" ]; then |
| 126 | + exec python3 "$HOOK_PY" --hook <event> --harness <harness> "$@" |
| 127 | +fi |
| 128 | +exit 0 |
| 129 | +``` |
| 130 | + |
| 131 | +Live examples: |
| 132 | + |
| 133 | +- `.claude-plugin/hooks/mempal-stop-hook.sh` — `--hook stop --harness claude-code` |
| 134 | +- `.claude-plugin/hooks/mempal-precompact-hook.sh` — `--hook precompact --harness claude-code` |
| 135 | +- `.codex-plugin/hooks/mempal-hook.sh` — generic codex shim (hook name from `$1`) |
| 136 | + |
| 137 | +`SessionStart` is registered directly in `hooks.json` (no shim) — |
| 138 | +it predates the split-brain fix and didn't have a stale-session |
| 139 | +problem to solve. New harnesses can follow either pattern. |
| 140 | + |
| 141 | +## Sample: search-via-daemon |
| 142 | + |
| 143 | +For CLI use beyond hooks, the same delegation pattern works for |
| 144 | +read operations. `scripts/mempalace-search.sh` (see file) is a |
| 145 | +1:1 demo: HTTP GET against `/search`, fall back silently if the |
| 146 | +daemon is unreachable. The daemon's API surface (from |
| 147 | +`palace-daemon/main.py`) covers everything a shim might want to |
| 148 | +expose: `/search`, `/list`, `/stats`, `/mine`, `/silent-save`, |
| 149 | +`/memory`, `/graph`, `/cypher`, `/health`. |
| 150 | + |
| 151 | +## When to re-converge |
| 152 | + |
| 153 | +This decision is reversible. Re-converge with upstream's #1069 |
| 154 | +if any of the following becomes true: |
| 155 | + |
| 156 | +- Upstream's gateway recommendation in #1497 lands with the |
| 157 | + same single-writer property and a Python entry point that's |
| 158 | + cheap to call (no cold-start cost). |
| 159 | +- The `mempalace` CLI grows a daemon-aware mode that doesn't |
| 160 | + require a separate sibling repo to install. |
| 161 | +- All harnesses we care about migrate off `.sh` hook configs |
| 162 | + (so the back-compat reason evaporates) AND the daemon's |
| 163 | + HTTP entry point becomes the *upstream* recommended pattern. |
| 164 | + |
| 165 | +Until then, the shims stay thin, the daemon stays the writer, |
| 166 | +and the `mempalace` package stays out of the hook call path. |
0 commit comments