Compare commits
39 Commits
98baabe7a0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b609cf5a69 | |||
| 6e1f4f30fc | |||
| f60cf9c732 | |||
| 2f9170428c | |||
| a25e22922b | |||
| c349d007e1 | |||
| 6e8172d93a | |||
| a94eb7fdd0 | |||
| ec436ed3ad | |||
| 2293f1c89b | |||
| 2e73a9eae8 | |||
| 4cb70ce3e1 | |||
| 08e344b047 | |||
| 00a95d1a2f | |||
| 29e660e18f | |||
| 3626946013 | |||
| 7e51055c96 | |||
| fdcd5871de | |||
| 052dbb8038 | |||
| 661ee20b39 | |||
| 35b1e3d81d | |||
| 96699f2a17 | |||
| e12b624cf7 | |||
| a3b8829991 | |||
| ce09d25c97 | |||
| 90e70fff61 | |||
| 16915f0e55 | |||
| 3d3a0fb125 | |||
| 118bd20fec | |||
| 5d8f523cb3 | |||
| 71c335148a | |||
| 79e0692dac | |||
| 75876e5c41 | |||
| 854ae41f65 | |||
| ef1d022fbc | |||
| 6352373a1f | |||
| 53d96adc65 | |||
| 14d253f929 | |||
| 9450a45194 |
@@ -2,23 +2,64 @@
|
||||
|
||||
## What this is
|
||||
|
||||
Producer-side tooling for [MemPalace](https://github.com/MemPalace/mempalace). Two thin wrappers in `bin/` plus the companion agent skill. Pairs with the consumer-side `mempalace` skill.
|
||||
Producer-side tooling for [MemPalace](https://github.com/MemPalace/mempalace). Three thin wrappers in `bin/` (opencode + pi session feeders, a docs miner), a companion agent skill, and an `extensions/` tree with per-harness bridges (currently `pi/`). Pairs with the consumer-side `mempalace` skill.
|
||||
|
||||
Read [`ARCHITECTURE.md`](ARCHITECTURE.md) first — it's the canonical spec for what this repo does and why.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
install.sh # Idempotent installer — symlinks bin/* into ~/.local/bin
|
||||
# and SKILL.md into ~/.agents/skills/opencode-mempalace-bridge/
|
||||
install.sh # Idempotent installer — see "What install.sh does" below
|
||||
ARCHITECTURE.md # Canonical spec: diagrams, setup recipe, ops notes, upstream roadmap
|
||||
README.md # Human-facing quickstart + per-tool usage reference
|
||||
SKILL.md # Agent skill (symlinked into ~/.agents/skills/ on install)
|
||||
docs/
|
||||
rfc-001-global-palace.md # Design: one primary palace + per-machine local fallback (mempalace-edge)
|
||||
rfc-002-joiner.md # Design: joining a machine-local palace into the primary
|
||||
bin/
|
||||
mempalace-docs # Docs-only MemPalace miner (bash wrapper)
|
||||
mempalace-session # Opencode session → MemPalace bridge (bash + inline Python)
|
||||
mempalace-pi-session # pi session → MemPalace bridge (bash + inline Python)
|
||||
mempalace-census # RFC 002 Phase A: read-only join census of a palace on disk
|
||||
# (classifies mined / diary / agent-authored, self-verifies
|
||||
# ids, emits --json manifest). Reads, never writes.
|
||||
contrib/ # systemd / launchd / cron templates for scheduling feeders
|
||||
extensions/
|
||||
pi/ # pi↔mempalace MCP bridge (bridge-only; pi's own config is in the pi-toolkit repo)
|
||||
mempalace.ts # Symlinked into ~/.pi/agent/extensions/ (MCP <→ pi glue)
|
||||
README.md # Bridge internals, Type.Unsafe gotcha, pi+mempalace deploy recipe
|
||||
```
|
||||
|
||||
## What `install.sh` does
|
||||
|
||||
Idempotent, safe to re-run. Always:
|
||||
|
||||
- Symlinks `bin/*` into `~/.local/bin/`.
|
||||
- Creates `~/.agents/skills/opencode-mempalace-bridge/` with a `SKILL.md` symlink and a `.skill-source` marker.
|
||||
|
||||
Gated on pi being installed (`~/.pi/agent/extensions/` exists):
|
||||
|
||||
- Symlinks `extensions/pi/mempalace.ts` into `~/.pi/agent/extensions/`. Backs up any real file in the way.
|
||||
|
||||
Probes (never halt, `warn` + `return 0`):
|
||||
|
||||
- `~/.local/bin` is on `$PATH`.
|
||||
- `~/.config/opencode/instructions/mempalace.md` exists (opencode wake-up protocol).
|
||||
- `mempalace` is registered as an MCP server in `~/.config/opencode/opencode.json`.
|
||||
- If pi is installed: pi-toolkit artifacts (`~/.pi/agent/keybindings.json` symlink, `~/.oh-my-zsh/custom/pi-env.zsh`) exist. Warns with a `git clone ssh://...pi-toolkit.git` pointer if missing.
|
||||
|
||||
All non-destructive: if something is already in place and points into this repo, prints "already linked" and moves on. If a non-symlink real file is in the way, backs it up with a timestamp.
|
||||
|
||||
**Not handled here any more** (split to [`pi-toolkit`](https://gitea.jordbo.se/joakimp/pi-toolkit) on 2026-05-05):
|
||||
|
||||
- `keybindings.json` symlink into `~/.pi/agent/`
|
||||
- `pi-env.zsh` cp into `~/.oh-my-zsh/custom/`
|
||||
- `settings.example.json` template + `check_pi_settings` probe
|
||||
- `check_aws_env` probe
|
||||
|
||||
Those are pi-generic concerns. This toolkit installs **only** the pi↔mempalace MCP bridge on top of whatever pi-toolkit set up.
|
||||
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Standalone executables** in `bin/` with `#!/usr/bin/env bash` shebang, no extension, `chmod +x`. Must work in non-interactive contexts (agent processes, cron, CI).
|
||||
@@ -30,18 +71,62 @@ bin/
|
||||
|
||||
## Adding a new wrapper
|
||||
|
||||
A third wrapper would justify factoring a shared helper library. Until then, copy the pattern from `mempalace-session` (richest example):
|
||||
Three wrappers live happily as standalone scripts — no shared helper library yet, because each one's stage-to-cache logic differs enough that the common surface is thin (arg parsing + `mempalace mine` invocation). A fourth wrapper might tip the balance; re-evaluate then. Until then, copy the pattern from `mempalace-session` (richest example):
|
||||
|
||||
1. Create `bin/<name>` with `#!/usr/bin/env bash` + `chmod +x`.
|
||||
2. Implement `--help`, `--dry-run`, `--no-repair` flags.
|
||||
2. Implement `--help`, `--dry-run`, `--repair` flags (repair is opt-in; `--no-repair` kept as deprecated alias).
|
||||
3. Stage to `~/.cache/<name>/<wing>/` with deterministic filenames.
|
||||
4. Invoke `mempalace mine ...` (choose `--mode convos` if input is chat-like).
|
||||
5. End with `mempalace repair` unless `--no-repair`.
|
||||
5. Do NOT end with `mempalace repair` unless `--repair` was explicitly passed. Repair is a destructive in-place HNSW rebuild and must never run on an unattended schedule.
|
||||
6. Update `README.md` with usage + rationale.
|
||||
7. Update `install.sh`? No — `bin/*` is auto-linked.
|
||||
8. Update `ARCHITECTURE.md` if the wrapper fills a new architectural gap.
|
||||
9. Update `SKILL.md` if agents should know when to invoke it.
|
||||
|
||||
## Adding a new harness extension
|
||||
|
||||
`extensions/<harness>/` is the home for **bridges** — code that lives
|
||||
inside an agent runtime (pi, claude-code, kiro, …) and talks to the
|
||||
mempalace MCP server. Currently only `extensions/pi/` exists. If you
|
||||
add a second one (e.g. `extensions/claude-code/`), follow the same
|
||||
shape:
|
||||
|
||||
1. **One directory per harness.** Never mix harnesses in one dir.
|
||||
2. **Bridge-only scope.** This toolkit owns the mempalace-side wiring;
|
||||
harness-generic config (keybindings, env loaders, settings templates)
|
||||
belongs in a sibling `<harness>-toolkit` repo, following the pattern
|
||||
established by [`pi-toolkit`](https://gitea.jordbo.se/joakimp/pi-toolkit)
|
||||
and [`opencode-toolkit`](https://gitea.jordbo.se/joakimp/opencode-toolkit).
|
||||
That boundary is load-bearing for `opencode-devbox`'s slim container
|
||||
path (mempalace opt-out, ~300 MB saved).
|
||||
3. **A `README.md`** covering: what the bridge does, harness-specific
|
||||
install path, debug knobs, and any gotchas (e.g. the pi `Type.Unsafe`
|
||||
schema passthrough). May also hold the "Deploying <harness> with
|
||||
mempalace" recipe since that straddles the two repos.
|
||||
4. **Gate `install.sh` steps on the harness being present.** Detect via
|
||||
a well-known path (pi uses `~/.pi/agent/extensions/`). Skip silently
|
||||
on machines without that harness. Never force-install.
|
||||
5. **Symlink the bridge code.** `mempalace.ts` (or equivalent) gets
|
||||
symlinked into the harness's extensions directory so edits flow
|
||||
through git. Back up any pre-existing real file to
|
||||
`<path>.bak.YYYYMMDD-HHMMSS` before linking.
|
||||
6. **Probe for the sibling toolkit.** After installing the bridge, check
|
||||
whether the harness's own base config is in place (e.g. for pi-toolkit:
|
||||
`~/.pi/agent/keybindings.json` symlink, `~/.oh-my-zsh/custom/pi-env.zsh`).
|
||||
Warn with a `git clone` pointer if missing. `warn` + `return 0`, never
|
||||
halt.
|
||||
7. **Mirror in `--uninstall`.** Every symlink this repo creates must have
|
||||
a matching removal step guarded by `link_if_into_repo`. Do **not**
|
||||
touch sibling-toolkit-owned files — point the user at
|
||||
`<harness>-toolkit/install.sh --uninstall` instead.
|
||||
8. **Update the root `README.md`** — repo-contents list + Ecosystem
|
||||
diagram's "Who owns what" table + Setup section's deploy summary.
|
||||
9. **Update this file's Structure block** to list the new
|
||||
`extensions/<harness>/` contents.
|
||||
|
||||
See `extensions/pi/README.md` and the `install_pi_extension` +
|
||||
`check_pi_toolkit` functions in `install.sh` for a worked example.
|
||||
|
||||
## Testing
|
||||
|
||||
Manual only. Integration-shaped:
|
||||
@@ -71,7 +156,15 @@ For `mempalace-docs`, test on a small repo (e.g. this one) first:
|
||||
- The companion skill lives at `~/.agents/skills/opencode-mempalace-bridge/SKILL.md` and is a **symlink into this repo**. Editing that file edits `SKILL.md` here. To propagate to Claude Code / Kiro, run `agents-sync` from [`cli_utils`](https://gitea.jordbo.se/joakimp/cli_utils).
|
||||
- The opencode DB path defaults to `~/.local/share/opencode/opencode.db`. Override via `$OPENCODE_DB` or `--db`.
|
||||
- The mempalace miner **skips symlinks** (as of v3.3.3 — `miner.py` line ~828). That's why the wrappers use `cp -p` / explicit file writes for staging, not symlinks.
|
||||
- The convos miner dedups on `source_file` path only (no mtime check). Staging filenames must be stable per session; deleting a staged JSONL forces a re-mine.
|
||||
- The convos miner dedups on `source_file` path **and** `mtime`
|
||||
(`file_already_mined(..., check_mtime=True)` in upstream `convo_miner.py`).
|
||||
A changed/grown transcript is detected, purged, and refiled — it is not
|
||||
silently skipped. What must still be stable across runs is the *staged
|
||||
path itself*: if a wrapper's staging dir is wiped, dedup has nothing to
|
||||
compare against and `mempalace sync` will treat the vanished sources as
|
||||
deleted and prune the drawers mined from them. (An earlier version of this
|
||||
file claimed "no mtime check" — that was wrong; verified against
|
||||
`convo_miner.py` 2026-08.)
|
||||
- The docs miner dedups on `source_file` path + `mtime`. That's why staging uses `cp -p` (preserves mtime).
|
||||
|
||||
## Colocated skill pattern
|
||||
|
||||
+95
-8
@@ -31,6 +31,18 @@ So on a machine using opencode + the "docs-first palace hygiene" policy, three g
|
||||
|
||||
The two wrappers in `bin/` close gaps **1** and **2**. Gap **3** is upstream work (see §6).
|
||||
|
||||
(Pi is a different story on gap 3: `bin/mempalace-pi-session` closes pi's
|
||||
version of gap 2 the same way `mempalace-session` closes opencode's, and pi's
|
||||
gap 3 is closed **without** any upstream dependency — its bridge
|
||||
extension self-triggers the feed on `session_shutdown` and a debounced
|
||||
`agent_settled`. **Version gate: extension-side feeding landed in
|
||||
mempalace-toolkit `29e660e` (2026-08-12), so a copy baked into an older
|
||||
container image cannot feed at all** — check the deployed file, not repo HEAD:
|
||||
`grep -c MEMPALACE_FEED "$(readlink -f ~/.pi/agent/extensions/mempalace.ts)"`.
|
||||
Zero means that machine still needs `contrib/`'s scheduled recipes. See §3's
|
||||
`mempalace-pi-session` subsection and
|
||||
[`extensions/pi/README.md`](extensions/pi/README.md).)
|
||||
|
||||
---
|
||||
|
||||
## 2. The architecture
|
||||
@@ -53,7 +65,7 @@ The two wrappers in `bin/` close gaps **1** and **2**. Gap **3** is upstream wor
|
||||
│ │ │ cache dir │
|
||||
└─────┬──────────┘ └────┬──────────────┘
|
||||
│ │
|
||||
│ ~/.cache/mempalace-docs/<wing>/ │ ~/.cache/mempalace-session/<wing>/
|
||||
│ ~/.cache/mempalace-docs/<wing>/ │ <palace-root>/opencode-stage/<wing>/
|
||||
│ │
|
||||
┌─────▼──────────┐ ┌────▼──────────────┐
|
||||
│ mempalace mine │ │ mempalace mine │
|
||||
@@ -120,7 +132,7 @@ What it drops: source code (`.py`, `.ts`, `.go`, `.rs`, …), lockfiles, `.git`,
|
||||
- `step-start` / `step-finish` → dropped as noise.
|
||||
- `reasoning` → kept, prefixed with `[reasoning]`.
|
||||
4. Serialize as Claude Code JSONL (`{"type": "user"|"assistant", "message": {"content": [...]}}`) — the one convos format the miner already understands.
|
||||
5. Stage at `~/.cache/mempalace-session/<wing>/<slug>_<id>.jsonl` with `mtime` = `session.time_updated` (deterministic, stable under dedup).
|
||||
5. Stage at `<palace-root>/opencode-stage/<wing>/<slug>_<id>.jsonl` with `mtime` = `session.time_updated` (deterministic, stable under dedup).
|
||||
|
||||
**Filters:**
|
||||
|
||||
@@ -128,7 +140,74 @@ What it drops: source code (`.py`, `.ts`, `.go`, `.rs`, …), lockfiles, `.git`,
|
||||
- `--since YYYY-MM-DD` — incremental catch-up.
|
||||
- `--session <id>` — one-shot mode.
|
||||
|
||||
**Then:** invokes `mempalace mine --mode convos` against the cache dir, followed by `mempalace repair` (unless `--no-repair`).
|
||||
**Then:** invokes `mempalace mine --mode convos` against the cache dir. A post-mine `mempalace repair` is **opt-in** via `--repair` — it is intentionally *not* the default because the in-place HNSW rebuild has corrupted live palaces on past runs. Never pass `--repair` from an unattended schedule.
|
||||
|
||||
### `bin/mempalace-pi-session` — pi coding-agent → palace bridge
|
||||
|
||||
**Input:** pi's own JSONL session transcripts under `~/.pi/agent/sessions/**/*.jsonl`
|
||||
(no SQLite export step needed — pi already writes files, unlike opencode).
|
||||
**Output:** palace drawers in `wing_conversations` (or `--wing` override), same
|
||||
Claude Code JSONL staging shape as `mempalace-session` above.
|
||||
|
||||
The transform pipeline is the same shape as `mempalace-session`'s (synthetic
|
||||
`[session: title | cwd | date | source: pi]` header, per-message dispatch,
|
||||
`toolCall`→`tool_use`, `toolResult`→`tool_result`, `mtime` copied onto the
|
||||
staged file for dedup stability) — see the script's own header comment for
|
||||
the exhaustive per-role mapping. What's architecturally different from the
|
||||
opencode wrapper is why this one has a **two-phase mode** and a **remote
|
||||
transport**, neither of which `mempalace-session` needs:
|
||||
|
||||
**Two phases, because the palace is single-writer.** `mempalace` enforces
|
||||
this with a per-palace `flock` (`palace.py`); the CLI's own error is explicit:
|
||||
`palace ... is held by PID <n> (mempalace-mcp); wait for it to finish`. Unlike
|
||||
opencode's session mine (which always runs *between* agent sessions, when
|
||||
nothing else holds the palace), pi's own bridge extension
|
||||
(`extensions/pi/mempalace.ts`) holds the palace open via its `mempalace-mcp`
|
||||
subprocess for the *entire* live session — and that extension is also what
|
||||
triggers the feed, on `session_shutdown` and a debounced `agent_settled`. An
|
||||
unattended `mempalace mine` invoked from anywhere else during that window
|
||||
would simply fail. So the wrapper splits:
|
||||
|
||||
- `--prepare` — export, threshold, stage (+ `rsync` in remote mode). Never
|
||||
opens the palace. Prints `MINE_SOURCE=<path>`.
|
||||
- (default, no `--prepare`) — the above, then mines it. If that mine hits
|
||||
contention ("is held by"), it's treated as **success, not failure** (exit
|
||||
0 with an informational message) — the holder's own extension will mine
|
||||
what got staged. This inverts the CLI's own convention (`MineAlreadyRunning`
|
||||
→ exit 1) deliberately, because at the wrapper layer the contention has a
|
||||
benign interpretation the raw CLI can't know about.
|
||||
|
||||
The pi extension calls `--prepare` as a subprocess, then feeds `MINE_SOURCE`
|
||||
to `mempalace_mine` through its own already-open MCP client — the only
|
||||
process that can write during a live session, because it *is* the lock
|
||||
holder. This is the mechanism behind [`extensions/pi/README.md` § Automatic
|
||||
transcript feeding](extensions/pi/README.md#automatic-transcript-feeding).
|
||||
|
||||
**Remote transport, because there is no remote-palace CLI.** `mempalace`'s
|
||||
`--backend` flag selects a *vector store* (chroma/qdrant/pgvector/milvus),
|
||||
not a remote palace — the only remote surface is the HTTP MCP server
|
||||
(`mempalace-mcp --transport http`, see [RFC-001](docs/rfc-001-global-palace.md)).
|
||||
And `mempalace_mine`'s `source` path is expanded **in that server process**,
|
||||
so a remote server has no way to see this machine's staged exports. `--mode
|
||||
remote` (auto-selected when `$MEMPALACE_REMOTE_URL` is set) therefore
|
||||
`rsync`s the stage to a per-device inbox on the palace host, then asks the
|
||||
server to mine its own local copy of that inbox over the same HTTP
|
||||
`tools/call` transport the extension uses. Requires
|
||||
`$MEMPALACE_PI_SSH_TARGET`; see `--help` for the rest
|
||||
(`MEMPALACE_PI_SSH_CONFIG`, `MEMPALACE_PI_REMOTE_PATH`, `MEMPALACE_PI_DEVICE`).
|
||||
|
||||
**Filters:** two gates, both required — stricter than `mempalace-session`'s
|
||||
single filter because pi's transcripts have a failure mode opencode's don't:
|
||||
|
||||
- `--min-messages N` (default 4) — same idea as opencode's filter, raised
|
||||
because pi's tool loops inflate turn counts fast.
|
||||
- `--min-assistant-chars N` (default 1000) — counts assistant *text* only,
|
||||
tool results excluded. Needed because pi expands skill/context text into
|
||||
the user prompt: an abandoned session can have a huge "user" turn and
|
||||
almost no assistant output (observed case: 13,380 injected-context user
|
||||
chars answered with 38 assistant chars), so a message-count-only filter
|
||||
would have filed 22 pure-noise drawers from that one session. Real
|
||||
sessions on the same corpus measured 15,900–100,000 assistant chars.
|
||||
|
||||
---
|
||||
|
||||
@@ -168,7 +247,11 @@ The devbox uses two named Docker volumes so these persist across container recre
|
||||
- `devbox-palace` → `~/.mempalace/palace` (the palace itself)
|
||||
- `devbox-data` → `~/.local/share/opencode` (opencode's SQLite DB)
|
||||
|
||||
Code at `/workspace/mempalace-toolkit` is a bind mount from the host — survives container recreate and syncs via gitea. Staging directories (`~/.cache/mempalace-{docs,session}/`) are ephemeral but cheap to rebuild.
|
||||
Code at `/workspace/mempalace-toolkit` is a bind mount from the host — survives container recreate and syncs via gitea. The docs staging dir (`~/.cache/mempalace-docs/`) is ephemeral and cheap to rebuild.
|
||||
The **conversation** staging dirs are not: `<palace-root>/opencode-stage/` and
|
||||
`<palace-root>/pi-stage/` hold the exact paths the palace's `source_file` dedup keys on, so they
|
||||
live beside the palace deliberately and share its lifetime. Wiping one forces a full re-mine at
|
||||
best, and lets a scoped `mempalace sync` prune every drawer mined from it at worst.
|
||||
|
||||
**After container recreate**, just re-run `./install.sh` (idempotent) to relink `bin/` into the fresh `~/.local/bin/`.
|
||||
|
||||
@@ -210,7 +293,7 @@ That makes the routine worth codifying:
|
||||
|
||||
**Default: weekly.** Dedup is free on unchanged sessions, and `wing_conversations` growth is roughly linear in user activity. Weekly is frequent enough that searches almost always include recent context, and infrequent enough that the cost is negligible.
|
||||
|
||||
**Daily** is fine but wasteful — you'll pay the post-mine `repair` cost seven times more often than you need. If you want daily runs, add `--no-repair` and schedule a separate weekly repair.
|
||||
**Daily** is fine. Repair is now opt-in (`--repair`) and should never be set on an unattended schedule — run it manually from a quiet session if you suspect stale HNSW state.
|
||||
|
||||
**Monthly** is too infrequent. You'll search for "that thing we discussed last Tuesday" and miss it.
|
||||
|
||||
@@ -304,7 +387,7 @@ Quick-start (cron):
|
||||
```bash
|
||||
sed "s|USER|$USER|g" contrib/cron/mempalace-session.cron \
|
||||
| (crontab -l 2>/dev/null; cat) | crontab -
|
||||
mkdir -p ~/.cache/mempalace-session
|
||||
mkdir -p ~/.cache/mempalace-logs
|
||||
```
|
||||
|
||||
#### Verification
|
||||
@@ -323,7 +406,7 @@ A healthy run produces one of:
|
||||
- **Incremental run**: zero to a few dozen new drawers (whatever grew since last run).
|
||||
- **Rerun with no new activity**: zero new drawers, only the repair step runs.
|
||||
|
||||
A run that files far more drawers than expected may indicate a staging-dir wipe (forcing a full re-mine) — check `~/.cache/mempalace-session/<wing>/` modification times.
|
||||
A run that files far more drawers than expected may indicate a staging-dir wipe (forcing a full re-mine) — check `<palace-root>/opencode-stage/<wing>/` modification times.
|
||||
|
||||
### Cost profile (reference)
|
||||
|
||||
@@ -355,14 +438,18 @@ These gaps should ideally close upstream, making the wrappers thinner or obsolet
|
||||
3. **Opencode harness in `hooks_cli.py`** — mempalace's hooks CLI only knows `claude-code` + `codex` today. Adding `opencode` would let the auto-save diary path work on opencode too. Pairs with #2 above.
|
||||
4. **SQLite mode for `mempalace mine --mode convos`** — if upstream ever adds direct SQLite ingest for opencode, `mempalace-session` loses its reason to exist (the export-to-JSONL dance goes away).
|
||||
|
||||
When #1 merges, retire `mempalace-docs` to a thin shim. When #2 + #3 land together, `mempalace-session` becomes a manual-only fallback (cron / backfill) while hooks handle live saves.
|
||||
When #1 merges, retire `mempalace-docs` to a thin shim. When #2 + #3 land together, `mempalace-session` becomes a manual-only fallback (cron / backfill) while hooks handle live saves. (`mempalace-pi-session` has no equivalent entry here: pi's bridge extension already self-triggers the feed with no upstream dependency — see §3 — **provided the deployed copy is ≥ `29e660e` (2026-08-12); older baked images cannot feed, so `contrib/`'s schedulers remain load-bearing there**.)
|
||||
|
||||
Separately tracked in [`docs/rfc-001-global-palace.md`](docs/rfc-001-global-palace.md): moving from one palace *per machine* to a **single primary palace with per-machine local fallback** (`mempalace-edge`). That RFC also records upstream items of its own — server-side `origin_device` provenance stamped from a per-device credential, per-wing ACLs, a `mempalace_kg_supersede` tool-classification fix, and a guard against running `mempalace sync` on a shared palace.
|
||||
|
||||
---
|
||||
|
||||
## 7. See also
|
||||
|
||||
- [`docs/rfc-001-global-palace.md`](docs/rfc-001-global-palace.md) — design for a fleet-wide primary palace with offline-capable local fallback. **Read before attempting to "centralize" or "sync" palaces** — it documents two silently destructive footguns (`--palace` vs `MEMPALACE_PALACE_PATH`, and `mempalace sync`).
|
||||
- [`README.md`](README.md) — human-facing quickstart + per-tool usage reference.
|
||||
- [`AGENTS.md`](AGENTS.md) — repo conventions for AI agents modifying this codebase.
|
||||
- [`SKILL.md`](SKILL.md) — agent skill (producer side), symlinked into `~/.agents/skills/opencode-mempalace-bridge/` by `install.sh`.
|
||||
- [`extensions/pi/README.md`](extensions/pi/README.md) — pi coding-agent bring-up: the MemPalace MCP bridge extension, mosh-friendly keybindings, settings template for starting pi without `--model`, and the `~/.config/pi/.env` + zsh loader pattern for AWS env vars. Out of scope for this document (which is producer-side feeding), but linked from `install.sh` which handles both.
|
||||
- `~/.agents/skills/mempalace/SKILL.md` — agent skill for the **consumer** side (searching, diary, KG). Pair with `SKILL.md` in this repo.
|
||||
- [`cli_utils`](https://gitea.jordbo.se/joakimp/cli_utils) — sibling repo: shell quality-of-life tools. Origin of these wrappers before the 2026-04-30 split.
|
||||
|
||||
@@ -5,15 +5,59 @@ Producer-side tooling for [MemPalace](https://github.com/MemPalace/mempalace)
|
||||
**What this repo contains:**
|
||||
|
||||
- `bin/mempalace-session` — exports [opencode](https://github.com/anomalyco/opencode) session history from its local SQLite DB to Claude Code JSONL, then mines it via `mempalace mine --mode convos`.
|
||||
- `bin/mempalace-pi-session` — the same idea for the [pi coding-agent](https://github.com/earendil-works/pi): exports its native JSONL session transcripts and mines them the same way. Unlike `mempalace-session`, this one is normally invoked *for you* — the pi bridge extension (below) runs it automatically on `session_shutdown` and a debounced `agent_settled`, so most pi machines never need the `contrib/` scheduling templates at all. See [`mempalace-pi-session`](#mempalace-pi-session) below and [`extensions/pi/README.md` § Automatic transcript feeding](extensions/pi/README.md#automatic-transcript-feeding).
|
||||
- `bin/mempalace-docs` — mines project directories into MemPalace while excluding source code, keeping the palace signal-dense.
|
||||
- `bin/mempalace-census` — read-only census of a palace **on disk**: classifies every drawer as mined / diary / agent-authored, so you know what a cross-machine join would actually move (and what dedupes it) before writing anything. Implements [RFC 002](docs/rfc-002-joiner.md) Phase A. Never writes — safe against a live palace.
|
||||
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — **canonical spec**: architecture diagram, component details, setup recipe, operational notes, upstream-retirement roadmap.
|
||||
- [`SKILL.md`](SKILL.md) — the companion agent skill, symlinked into `~/.agents/skills/opencode-mempalace-bridge/` on install.
|
||||
- [`extensions/pi/`](extensions/pi/) — the pi↔mempalace MCP bridge (a TypeScript extension symlinked into `~/.pi/agent/extensions/`). Pi's own base config (keybindings, env loader, settings template) is in the sibling [`pi-toolkit`](https://gitea.jordbo.se/joakimp/pi-toolkit) repo — split out 2026-05-05 so `opencode-devbox` can build slim containers without mempalace.
|
||||
|
||||
**If you're just trying to get this working on a new machine → jump to [Setup](#setup).**
|
||||
**If you want the full architecture story → read [`ARCHITECTURE.md`](ARCHITECTURE.md).**
|
||||
|
||||
---
|
||||
|
||||
## Ecosystem
|
||||
|
||||
This repo is the memory-layer hub in a family of composable toolkits.
|
||||
Each `install.sh` is independent and idempotent; install only what you
|
||||
need. MemPalace can be opted out entirely (the `opencode-devbox` slim
|
||||
container path exercises this).
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ myconfigs │ dotfiles: ~/.config/pi/.env, ~/.config/opencode/.env,
|
||||
└────────┬─────────┘ tmux CSI-u, zsh loaders, … (git-crypt encrypted)
|
||||
│ provision
|
||||
┌──────────────┼──────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────────┐
|
||||
│opencode- │ │pi-toolkit│ │ (pi/opencode │
|
||||
│ toolkit │ │ │ │ installed │
|
||||
│ │ │ │ │ upstream) │
|
||||
└─────┬────┘ └─────┬────┘ └──────────────┘
|
||||
│ │
|
||||
│ (optional) │ (optional)
|
||||
└──────┬───────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│mempalace-toolkit│ ← this repo
|
||||
└─────────────────┘ detects pi / opencode and installs bridges
|
||||
for whichever are present
|
||||
```
|
||||
|
||||
Who owns what:
|
||||
|
||||
| Repo | Scope |
|
||||
|---|---|
|
||||
| [`myconfigs`](https://gitea.jordbo.se/joakimp/myconfigs) | Dotfiles (git-crypt encrypted). Ships `.env` files each toolkit below sources. |
|
||||
| [`opencode-toolkit`](https://gitea.jordbo.se/joakimp/opencode-toolkit) | Opencode's own shell glue (env loader for `~/.config/opencode/.env`). No mempalace dep. |
|
||||
| [`pi-toolkit`](https://gitea.jordbo.se/joakimp/pi-toolkit) | Pi's own base config (keybindings, env loader, `settings.example.json`). No mempalace dep. |
|
||||
| **`mempalace-toolkit` (this)** | Memory layer. Mining wrappers in `bin/`, `opencode-mempalace-bridge` skill, pi↔mempalace MCP extension. Probes for the other toolkits' artifacts; installs bridges where relevant. |
|
||||
| [`opencode-devbox`](https://gitea.jordbo.se/joakimp/opencode-devbox) | Docker containers that compose any subset via independent `install.sh` invocations. |
|
||||
|
||||
---
|
||||
|
||||
## Why this exists
|
||||
|
||||
MemPalace is the agent memory layer. Its stock CLI has two gaps that bite on a machine running opencode with a docs-first palace policy:
|
||||
@@ -221,7 +265,7 @@ cd ~/mempalace-toolkit
|
||||
./install.sh
|
||||
```
|
||||
|
||||
The installer symlinks `bin/*` into `~/.local/bin/` and optionally installs the agent skill into `~/.agents/skills/opencode-mempalace-bridge/`.
|
||||
The installer symlinks `bin/*` into `~/.local/bin/` and installs the agent skill into `~/.agents/skills/opencode-mempalace-bridge/`. If [pi](https://github.com/earendil-works/pi) is installed (detected via `~/.pi/agent/extensions/`), it also symlinks [`extensions/pi/mempalace.ts`](extensions/pi/) into that directory so the pi↔mempalace bridge tracks version control. On machines without pi this step is silently skipped. Works on macOS and Linux.
|
||||
|
||||
Ensure `~/.local/bin` is on `$PATH`:
|
||||
|
||||
@@ -231,6 +275,29 @@ export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
**If `install.sh` reports `Skipping <name> — already exists`:** there's a leftover symlink or file at `~/.local/bin/<name>` from a previous install (e.g. the pre-split `cli_utils` days). The installer prints the exact `rm && ./install.sh` command to fix it — remove the stale entry and re-run. It will never clobber an existing file without the user explicitly removing it first.
|
||||
|
||||
### Deploying pi on a new machine (full recipe)
|
||||
|
||||
If the target machine also runs [pi](https://github.com/earendil-works/pi), the recipe is: install [`pi-toolkit`](https://gitea.jordbo.se/joakimp/pi-toolkit) first (pi's own base config), then this toolkit (adds the pi↔mempalace MCP bridge). Full step-by-step: **[`extensions/pi/README.md` § Deploying pi with mempalace on a new machine](extensions/pi/README.md#deploying-pi-with-mempalace-on-a-new-machine)**.
|
||||
|
||||
Quick summary:
|
||||
|
||||
```bash
|
||||
# 1. Dotfiles (tmux extended-keys, ~/.config/pi/.env, ...)
|
||||
git clone <myconfigs> && cd myconfigs && ./provision.sh --profile <profile>
|
||||
|
||||
# 2. pi upstream 3. pi-toolkit (pi base config)
|
||||
brew install pi-coding-agent git clone ssh://git@gitea.jordbo.se:2222/joakimp/pi-toolkit.git
|
||||
cd pi-toolkit && ./install.sh
|
||||
|
||||
# 4. settings bootstrap
|
||||
cp ~/pi-toolkit/settings.example.json ~/.pi/agent/settings.json && $EDITOR !$
|
||||
|
||||
# 5. mempalace CLI 6. This repo (adds the bridge)
|
||||
uv tool install mempalace cd ~/mempalace-toolkit && ./install.sh
|
||||
|
||||
# 7. Open fresh shell, run `pi`. Wake-up auto-injection proves end-to-end.
|
||||
```
|
||||
|
||||
### First mine
|
||||
|
||||
```bash
|
||||
@@ -258,7 +325,17 @@ A machine running only one of these has half a memory. Full treatment with pract
|
||||
|
||||
### Keeping it fresh (automation)
|
||||
|
||||
Manual invocation is fine while you're actively driving the machine, but long-running devboxes benefit from a weekly automated mine. [`contrib/`](contrib/) ships ready-to-install templates:
|
||||
**pi:** nothing to set up. The pi bridge extension
|
||||
(`extensions/pi/mempalace.ts`) feeds the palace by itself — see
|
||||
[`extensions/pi/README.md` § Automatic transcript feeding](extensions/pi/README.md#automatic-transcript-feeding).
|
||||
The templates below aren't needed unless you're running
|
||||
`mempalace-pi-session` somewhere without that extension (e.g. a bare pi
|
||||
install, or a host-side catch-up job).
|
||||
|
||||
**opencode**, and pi installs without the bridge: manual invocation is fine
|
||||
while you're actively driving the machine, but long-running devboxes benefit
|
||||
from a weekly automated mine. [`contrib/`](contrib/) ships ready-to-install
|
||||
templates:
|
||||
|
||||
- **systemd user timer** (recommended on Linux): survives reboots, catches missed runs, logs to `journalctl`.
|
||||
- **launchd user agent** (recommended on macOS): native-equivalent — logs to `~/Library/Logs/`, single-instance guarantees, `ProcessType=Background` throttling.
|
||||
@@ -320,7 +397,7 @@ mempalace-docs <directory> # mine with wing = dirname
|
||||
mempalace-docs <directory> --wing my_project # override wing name
|
||||
mempalace-docs <directory> --agent alice # record agent on drawers
|
||||
mempalace-docs <directory> --dry-run # list files, don't file
|
||||
mempalace-docs <directory> --no-repair # skip post-mine repair
|
||||
mempalace-docs <directory> --repair # opt-in post-mine repair (risky, interactive only)
|
||||
mempalace-docs --help
|
||||
```
|
||||
|
||||
@@ -332,6 +409,33 @@ mempalace-docs --help
|
||||
|
||||
---
|
||||
|
||||
## `mempalace-census`
|
||||
|
||||
Read-only join census of a palace **on local disk**. Answers *"what would joining this palace into the primary actually move, and what dedupes it?"* — [RFC 002](docs/rfc-002-joiner.md) Phase A.
|
||||
|
||||
```bash
|
||||
mempalace-census # default palace, human report
|
||||
mempalace-census --palace /mnt/tor-ms22/palace # a palace rsynced from another machine
|
||||
mempalace-census --kg /path/knowledge_graph.sqlite3
|
||||
mempalace-census --json > manifest.json # machine-readable, feeds Phase B/C
|
||||
mempalace-census --no-verify-ids # skip id recompute (faster on huge palaces)
|
||||
mempalace-census --help
|
||||
```
|
||||
|
||||
**Classification**, by descending signal strength:
|
||||
|
||||
| Class | Identified by | Join action |
|
||||
| --- | --- | --- |
|
||||
| `DIARY` | metadata `type='diary_entry'` | replay + RFC 001 §7.6 suffix skip |
|
||||
| `MINED` | truthy `source_file` | **re-mine on the target, never replay** — ids are path-derived, so replay duplicates |
|
||||
| `AGENT-AUTHORED` | neither | replay, idempotent by content id |
|
||||
|
||||
**It self-verifies rather than trusting the docs.** For every replayable drawer it reassembles content from chunks and recomputes the upstream id, comparing against the stored one — checking the id recipe, the chunk reassembly order and the classifier in a single pass. On the reference palace: 176/176 accounted for. This check is what caught three wrong assumptions now written up in [RFC 002 §2.1](docs/rfc-002-joiner.md) — most usefully that `id_recipe` is *not* a mined-only marker, and that `update_drawer` preserves a drawer's id while rewriting content, so 15% of agent-authored drawers no longer reproduce their own content hash (`edited_since_filing` in the manifest).
|
||||
|
||||
**Safety:** opens every sqlite file with `mode=ro` and never writes — no `-wal`/`-shm` files are created, so it is safe to run while `mempalace-serve` is live. It reads **local disk only** and deliberately ignores `MEMPALACE_REMOTE_URL` (it warns if that is set, so you don't mistake a local census for a remote one).
|
||||
|
||||
---
|
||||
|
||||
## `mempalace-session`
|
||||
|
||||
Opencode → MemPalace session bridge. Reads `~/.local/share/opencode/opencode.db`, transforms each session into Claude Code JSONL, and files via `mempalace mine --mode convos`.
|
||||
@@ -344,7 +448,7 @@ mempalace-session --since 2026-04-01 # only sessions updated on/aft
|
||||
mempalace-session --min-messages 6 # stricter short-session filter
|
||||
mempalace-session --db /custom/path/opencode.db # non-default DB location
|
||||
mempalace-session --dry-run # export + list, skip mine
|
||||
mempalace-session --no-repair # skip post-mine index repair
|
||||
mempalace-session --repair # opt-in post-mine repair (risky, interactive only)
|
||||
mempalace-session --help
|
||||
```
|
||||
|
||||
@@ -356,12 +460,12 @@ mempalace-session --help
|
||||
- Tool outputs → `tool_result` blocks in a follow-up human message, folded back into the assistant turn by the mempalace normalizer.
|
||||
- `step-start` / `step-finish` parts are dropped as noise. `reasoning` parts are kept with a `[reasoning]` prefix.
|
||||
|
||||
**Dedup:** staging at `~/.cache/mempalace-session/<wing>/` with deterministic per-session filenames (`<slug>_<id>.jsonl`). The convos miner keys on `source_file`, so re-runs skip unchanged sessions. To force re-mining a session, delete its JSONL from the staging dir.
|
||||
**Dedup:** staging at `<palace-root>/opencode-stage/<wing>/` (override: `$MEMPALACE_SESSION_STAGE`) with deterministic per-session filenames (`<slug>_<id>.jsonl`). The convos miner keys on `source_file`, so re-runs skip unchanged sessions. To force re-mining a session, delete its JSONL from the staging dir.
|
||||
|
||||
**`--dry-run` is dedup-aware.** Each session is tagged `[NEW]` (would be filed) or `[SKIP]` (already in the palace), and the summary breaks down the count:
|
||||
|
||||
```
|
||||
Exported 62 session(s) to ~/.cache/mempalace-session/wing_conversations
|
||||
Exported 62 session(s) to /home/you/.mempalace/opencode-stage/wing_conversations
|
||||
0 new → will be filed on mine
|
||||
62 already filed → will be skipped (dedup by source_file)
|
||||
|
||||
@@ -376,6 +480,105 @@ If the palace is unreachable (fresh install, moved, permission-denied) the wrapp
|
||||
|
||||
---
|
||||
|
||||
## `mempalace-pi-session`
|
||||
|
||||
Pi coding-agent → MemPalace session bridge. Reads pi's own JSONL session
|
||||
transcripts under `~/.pi/agent/sessions/`, converts each qualifying session
|
||||
to Claude Code JSONL, and files via `mempalace mine --mode convos`. On most
|
||||
machines you never run this by hand — the pi bridge extension
|
||||
(`extensions/pi/mempalace.ts`) invokes it automatically; see
|
||||
[`extensions/pi/README.md` § Automatic transcript feeding](extensions/pi/README.md#automatic-transcript-feeding).
|
||||
Manual invocation is for a bare pi install without that extension, a
|
||||
host-side catch-up job, or just poking at the export by hand.
|
||||
|
||||
```bash
|
||||
mempalace-pi-session # export + mine everything qualifying
|
||||
mempalace-pi-session --wing my_convos # custom wing (default: wing_conversations)
|
||||
mempalace-pi-session --session <uuid-prefix> # one session only
|
||||
mempalace-pi-session --since 2026-04-01 # only sessions updated on/after date
|
||||
mempalace-pi-session --min-messages 6 # stricter turn-count filter (default: 4)
|
||||
mempalace-pi-session --min-assistant-chars 2000 # stricter "did anything happen" filter (default: 1000)
|
||||
mempalace-pi-session --dry-run # export + list, skip mine
|
||||
mempalace-pi-session --prepare # export + stage only; print MINE_SOURCE=<path>, never opens the palace
|
||||
mempalace-pi-session --mode remote # ship the stage to a remote palace host instead of mining locally
|
||||
mempalace-pi-session --help
|
||||
```
|
||||
|
||||
**Why `--prepare` exists:** the palace is single-writer. If a pi session for
|
||||
this same palace is currently open, its own bridge extension already holds
|
||||
the palace via `mempalace-mcp`, so an unattended `mempalace mine` from
|
||||
anywhere else fails with `palace ... is held by PID <n>`. `--prepare` does
|
||||
only the palace-free half (export, threshold, staging, and the `rsync` in
|
||||
remote mode) and hands the mine off to whoever already holds the lock —
|
||||
which is exactly what the pi extension does with its own MCP client. Run
|
||||
without `--prepare`, the tool does the mine itself, and treats that
|
||||
contention as success (exit 0, informational message) rather than failure —
|
||||
the holder will mine what got staged.
|
||||
|
||||
**What gets exported per session:**
|
||||
|
||||
- Synthetic header injected as the first user turn
|
||||
(`[session: <title> | <cwd> | <date> | source: pi]`) so the palace can find
|
||||
sessions by topic, not just by ID, and so pi/opencode/other results stay
|
||||
distinguishable in search.
|
||||
- User/assistant messages extracted from pi's JSONL `message` entries.
|
||||
- Assistant `toolCall` blocks → Claude Code `tool_use` blocks.
|
||||
- `toolResult` role messages → `tool_result` blocks, folded back into the
|
||||
assistant turn by the mempalace normalizer.
|
||||
- `bashExecution`, `custom` (display-only), `branchSummary`,
|
||||
`compactionSummary` → rendered as text annotations.
|
||||
- `thinking` blocks and image content → dropped (noise / not text).
|
||||
|
||||
**Filter — two gates, both required** (this is stricter than
|
||||
`mempalace-session`'s single message-count filter, and deliberately so —
|
||||
see below):
|
||||
|
||||
1. `--min-messages` user+assistant turns (default **4**).
|
||||
2. `--min-assistant-chars` characters of assistant *text*, tool results
|
||||
excluded (default **1000**).
|
||||
|
||||
The second gate exists because message count alone isn't enough for pi: pi
|
||||
expands skill/context text into the user prompt, so an abandoned session can
|
||||
have a huge "user" turn and almost nothing on the assistant side — e.g. a
|
||||
real observed case with a 13,380-char injected-context user turn answered
|
||||
"Ready. What would you like to work on?" (38 chars). Total size said
|
||||
substantial; assistant size correctly said nothing happened. Measured real
|
||||
sessions on the same machine ran 15,900–100,000 assistant chars, so the
|
||||
1000-char default sits with wide margin on both sides.
|
||||
|
||||
**Dedup:** staging under `$MEMPALACE_PI_STAGE/<wing>/` (default
|
||||
`<palace-root>/pi-stage/<wing>/` — beside the palace, so the stage cannot be
|
||||
wiped independently of the dedup keys pointing at it) with deterministic per-session-UUID
|
||||
filenames, and the export preserves the source session's `mtime` on the
|
||||
staged file. The convos miner is mtime-aware (see the Gotchas in
|
||||
[`AGENTS.md`](AGENTS.md) — an older version of this doc claimed otherwise),
|
||||
so re-runs on an unchanged session are a no-op, and re-feeding a **grown**
|
||||
session (a live one being fed mid-conversation) purges and refiles that
|
||||
session's drawers instead of duplicating them.
|
||||
|
||||
**Staging must persist.** Dedup keys on the *staged* path, not the original
|
||||
transcript, so if the stage is wiped, a `mempalace sync` scoped to include it
|
||||
sees those source files as gone and prunes the drawers mined from them —
|
||||
deleting the memories, not just the cache. This is why the stage now defaults to
|
||||
`<palace-root>/pi-stage`: it inherits whatever persistence the palace has, so
|
||||
the files and the dedup keys that reference them cannot be separated by
|
||||
wiping something that merely looks disposable. Override with
|
||||
`$MEMPALACE_PI_STAGE` only if you have somewhere *more* durable than the palace.
|
||||
|
||||
**Remote palace:** if `$MEMPALACE_REMOTE_URL` is set, there is no
|
||||
remote-palace CLI to mine into directly — `mempalace_mine` expands its
|
||||
source path in the *server* process, which cannot see this machine's staged
|
||||
exports. `--mode remote` (or `--mode auto`, which detects
|
||||
`$MEMPALACE_REMOTE_URL`) instead `rsync`s the stage to a per-device inbox on
|
||||
the palace host and asks the server to mine its own local copy. Requires
|
||||
`$MEMPALACE_PI_SSH_TARGET` (`user@host:path`); see `MEMPALACE_PI_SSH_CONFIG`,
|
||||
`MEMPALACE_PI_REMOTE_PATH`, and `MEMPALACE_PI_DEVICE` in `--help` for the
|
||||
rest. Deploying that primary — newt, DNS, and why the auth is a shared bearer
|
||||
token rather than per-device proxy users — is
|
||||
[`docs/phase-1-exposure-runbook.md`](docs/phase-1-exposure-runbook.md).
|
||||
|
||||
---
|
||||
|
||||
## Companion agent skill
|
||||
|
||||
Installing this repo symlinks `SKILL.md` into `~/.agents/skills/opencode-mempalace-bridge/SKILL.md`, where it's auto-discovered by opencode (and by Claude Code / Kiro if you run `agents-sync` from [`cli_utils`](https://gitea.jordbo.se/joakimp/cli_utils)).
|
||||
|
||||
@@ -20,6 +20,19 @@ The `mempalace` skill covers *using* the palace (search, diary, KG). This skill
|
||||
|
||||
Both follow the same **stage-to-cache-then-mine** idiom — they curate input into `~/.cache/…/<wing>/`, then delegate to `mempalace mine`.
|
||||
|
||||
**Pi is out of scope for this skill — conditionally.** The pi coding-agent has
|
||||
its own wrapper, `mempalace-pi-session`, but — unlike opencode — pi's bridge
|
||||
extension (`extensions/pi/mempalace.ts`) invokes it automatically on session
|
||||
shutdown and a debounced mid-session tick, so a pi machine running
|
||||
**mempalace-toolkit ≥ `29e660e` (2026-08-12)** needs none of this skill's
|
||||
manual/scheduled recipe. Older installs — and any container image baked before
|
||||
that date — **do** still need it: having the extension is necessary but not
|
||||
sufficient, because the pre-`29e660e` extension has no feed path at all.
|
||||
Check the *deployed* file rather than the repo:
|
||||
`grep -c MEMPALACE_FEED "$(readlink -f ~/.pi/agent/extensions/mempalace.ts)"`
|
||||
— zero means fall back to the recipes below. See
|
||||
`extensions/pi/README.md` § Automatic transcript feeding in the same repo.
|
||||
|
||||
## When to Load This Skill
|
||||
|
||||
- User asks "how does the palace get fed?" or mentions setting up mempalace on a new machine.
|
||||
@@ -86,7 +99,14 @@ A docs-heavy repo should produce ~5–10 drawers per file. >15 drawers/file on a
|
||||
### Dedup is free — re-running is safe
|
||||
|
||||
- `mempalace-docs`: dedup keyed on `source_file` path + `mtime`. Unchanged files skipped.
|
||||
- `mempalace-session`: dedup keyed on `source_file` path alone (no mtime check for convos). Staging filenames are deterministic per session (`<slug>_<id>.jsonl`), so re-runs skip already-filed sessions.
|
||||
- `mempalace-session` / `mempalace-pi-session`: the convos miner also dedups
|
||||
on `source_file` path + `mtime` (`file_already_mined(..., check_mtime=True)`
|
||||
in upstream `convo_miner.py` — a prior version of this line said "no mtime
|
||||
check", which was wrong). Staging filenames are deterministic per session,
|
||||
so a re-run on unchanged content is a no-op, and a grown/changed session is
|
||||
detected, purged, and refiled rather than duplicated. What must stay
|
||||
stable is the staging *path itself* — wiping the staging dir makes dedup
|
||||
(and `mempalace sync`) treat those sources as gone.
|
||||
|
||||
Second run immediately after first → 0 new drawers, only the post-mine `repair` step runs (~5 min on 5k drawers).
|
||||
|
||||
@@ -110,8 +130,10 @@ mempalace-session --session ses_abc123 # one specific session
|
||||
### Force re-mine
|
||||
|
||||
```bash
|
||||
rm -rf ~/.cache/mempalace-session/<wing>/ # nukes staging dir
|
||||
rm -rf <palace-root>/opencode-stage/<wing>/ # nukes staging dir
|
||||
mempalace-session # stages + mines fresh
|
||||
# Do NOT run `mempalace sync` between those two commands: with the stage gone,
|
||||
# a scoped sync prunes the drawers mined from it instead of refiling them.
|
||||
```
|
||||
|
||||
Staging is ephemeral by design; the palace is the source of truth.
|
||||
@@ -138,7 +160,7 @@ Suggest invoking the tool when any of these apply:
|
||||
| Occasional opencode user | Monthly manual or weekly automated |
|
||||
| Fresh machine / first setup | One-shot full backfill, then schedule |
|
||||
| "I'm about to rebuild the container" | Run now, as a checkpoint |
|
||||
| Automated daily mines | Pass `--no-repair` + schedule weekly repair separately |
|
||||
| Automated daily mines | Repair is now opt-in (`--repair`); **never** set it on an unattended schedule. Run `mempalace repair` by hand from a quiet session if HNSW genuinely needs rebuilding. |
|
||||
|
||||
Don't suggest running more often than daily — the post-mine HNSW repair (~5 min on 5k drawers) dominates cost, and session growth is slow enough that daily is already overkill.
|
||||
|
||||
|
||||
Executable
+332
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env bash
|
||||
# mempalace-census — RFC 002 Phase A: classify a palace by what a join could move.
|
||||
#
|
||||
# Answers "what would joining THIS palace into the primary actually move, and
|
||||
# what dedupes it?" before any writer exists. Read-only: opens every sqlite file
|
||||
# with `mode=ro` and never writes, so it is safe to run against a live palace
|
||||
# while `mempalace-serve` is up.
|
||||
#
|
||||
# Classification (RFC 002 §2), by descending signal strength:
|
||||
# DIARY metadata type='diary_entry' → replay + §7.6 suffix skip
|
||||
# MINED source_file set / id_recipe → RE-MINE on the target, never
|
||||
# replay (ids are path-derived,
|
||||
# so replay duplicates)
|
||||
# AGENT-AUTHORED neither → replay, idempotent by content id
|
||||
#
|
||||
# It also self-verifies rather than trusting the docs. For every classified
|
||||
# drawer it recomputes the upstream ID from reassembled content and compares to
|
||||
# the stored ID. That checks three things at once: the ID recipe, the chunk
|
||||
# reassembly order, and the classification. A mismatch rate above ~0 means one
|
||||
# of those assumptions is wrong for this palace — investigate before joining.
|
||||
#
|
||||
# ⚠ Recipe note: upstream's ids.py DOCSTRINGS claim the hash input is
|
||||
# f"{wing}|{room}|{content}", and ids.py:31 defines _DELIM = "|". Both are
|
||||
# misleading — _DELIM is dead code and _delimited_sha256() actually
|
||||
# length-prefixes each part: "".join(f"{len(p)}:{p}"). Verified empirically:
|
||||
# length-prefixed reproduces real IDs 5/5, pipe-joined 0/5. Diary IDs are
|
||||
# different again — a PLAIN sha256(entry)[:12], not length-prefixed.
|
||||
#
|
||||
# This reads a palace on LOCAL DISK. It is not a client of a remote palace and
|
||||
# deliberately ignores MEMPALACE_REMOTE_URL — pass --palace to point at a copy
|
||||
# rsynced from another machine.
|
||||
#
|
||||
# Usage:
|
||||
# mempalace-census # default palace, human report
|
||||
# mempalace-census --palace /mnt/tor-ms22/palace
|
||||
# mempalace-census --json > manifest.json # machine-readable, feeds Phase B/C
|
||||
# mempalace-census --no-verify-ids # skip recompute (faster on huge palaces)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────────────────
|
||||
PALACE="${MEMPALACE_PALACE:-$HOME/.mempalace/palace}"
|
||||
KG=""
|
||||
FORMAT="text"
|
||||
VERIFY="1"
|
||||
|
||||
usage() {
|
||||
sed -n '2,36p' "$0" | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
# ── Argument parsing ──────────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
--palace) PALACE="${2:?--palace needs a path}"; shift 2 ;;
|
||||
--kg) KG="${2:?--kg needs a path}"; shift 2 ;;
|
||||
--json) FORMAT="json"; shift ;;
|
||||
--no-verify-ids) VERIFY="0"; shift ;;
|
||||
*) echo "mempalace-census: unknown argument '$1' (try --help)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Path resolution ───────────────────────────────────────────────────────────
|
||||
DB="$PALACE/chroma.sqlite3"
|
||||
if [[ ! -f "$DB" ]]; then
|
||||
echo "mempalace-census: no chroma.sqlite3 under '$PALACE'" >&2
|
||||
echo " pass --palace /path/to/palace (the dir CONTAINING chroma.sqlite3)" >&2
|
||||
exit 2
|
||||
fi
|
||||
# KG lives beside the palace dir, not inside it.
|
||||
[[ -n "$KG" ]] || KG="$(cd "$(dirname "$PALACE")" && pwd)/knowledge_graph.sqlite3"
|
||||
|
||||
if [[ "$FORMAT" == "text" && -n "${MEMPALACE_REMOTE_URL:-}" ]]; then
|
||||
echo "note: MEMPALACE_REMOTE_URL is set but ignored — this tool reads local disk." >&2
|
||||
echo " censusing: $DB" >&2
|
||||
fi
|
||||
|
||||
# ── Census ────────────────────────────────────────────────────────────────────
|
||||
PALACE_DB="$DB" KG_DB="$KG" FMT="$FORMAT" VERIFY="$VERIFY" python3 - <<'PY'
|
||||
import hashlib, json, os, re, sqlite3, sys
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
DB, KG = os.environ["PALACE_DB"], os.environ["KG_DB"]
|
||||
FMT, VERIFY = os.environ["FMT"], os.environ["VERIFY"] == "1"
|
||||
CHUNK_RE = re.compile(r"_chunk_(\d+)$")
|
||||
|
||||
def ro(path):
|
||||
return sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
||||
|
||||
# Upstream ids.py::_delimited_sha256 — length-prefixed, NOT delimiter-joined.
|
||||
def drawer_hash(parts, trunc=24):
|
||||
key = "".join(f"{len(str(p))}:{p}" for p in parts).encode()
|
||||
return hashlib.sha256(key).hexdigest()[:trunc]
|
||||
|
||||
con = ro(DB)
|
||||
|
||||
# Both collections share one sqlite file. Filtering by collection is mandatory:
|
||||
# an embeddings-wide query over-counts by the closet population (~10%).
|
||||
counts_by_collection = dict(
|
||||
con.execute(
|
||||
"SELECT c.name, COUNT(*) FROM embeddings e "
|
||||
"JOIN segments s ON s.id = e.segment_id "
|
||||
"JOIN collections c ON c.id = s.collection GROUP BY c.name"
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
rows = defaultdict(dict)
|
||||
for eid, key, sval, ival in con.execute(
|
||||
"SELECT e.embedding_id, m.key, m.string_value, m.int_value "
|
||||
"FROM embeddings e "
|
||||
"JOIN segments s ON s.id = e.segment_id "
|
||||
"JOIN collections c ON c.id = s.collection "
|
||||
"JOIN embedding_metadata m ON m.id = e.id "
|
||||
"WHERE c.name = 'mempalace_drawers'"
|
||||
):
|
||||
# Chroma splits metadata by type across columns — numeric values (chunk_index,
|
||||
# source_mtime, line_start, normalize_version) land in int_value and leave
|
||||
# string_value NULL. Reading only string_value silently nulls every numeric
|
||||
# key, which made the miner-marker cross-check below report 100% conflict.
|
||||
rows[eid][key] = sval if sval is not None else ival
|
||||
|
||||
# Collapse chunk rows into parent drawers. A chunked drawer has NO parent row
|
||||
# (verified), so the parent is the id with the _chunk_NNNNNN suffix stripped.
|
||||
parents = defaultdict(lambda: {"chunks": {}, "meta": None})
|
||||
for eid, meta in rows.items():
|
||||
m = CHUNK_RE.search(eid)
|
||||
base = eid[: m.start()] if m else eid
|
||||
idx = int(m.group(1)) if m else (meta.get("chunk_index") or 0)
|
||||
p = parents[base]
|
||||
p["chunks"][idx] = meta.get("chroma:document") or ""
|
||||
# Keep the lowest-index row's metadata as canonical for the parent.
|
||||
if p["meta"] is None or idx == 0:
|
||||
p["meta"] = meta
|
||||
|
||||
def classify(base, meta):
|
||||
if meta.get("type") == "diary_entry" or base.startswith("diary_"):
|
||||
return "diary"
|
||||
# id_recipe is NOT a mined-only marker — the server stamps 'v3' on every
|
||||
# v3 id, content-hashed ones included. Using it here misclassified all 60
|
||||
# agent-authored drawers in the reference palace as mined, which is the
|
||||
# dangerous direction: Phase C would try to re-mine drawers that have no
|
||||
# source file and silently drop them. A non-empty source_file is the real
|
||||
# discriminator (note the miner writes '' rather than omitting the key, so
|
||||
# presence-of-key is not enough — it must be truthy after strip()).
|
||||
if (meta.get("source_file") or "").strip():
|
||||
return "mined"
|
||||
return "agent_authored"
|
||||
|
||||
cls = Counter()
|
||||
wings = Counter()
|
||||
months = Counter()
|
||||
machines = Counter()
|
||||
agents = Counter()
|
||||
verify = {"checked": 0, "match": 0, "mismatch": 0, "drift": 0,
|
||||
"samples": [], "drift_samples": []}
|
||||
manifest = {"agent_authored": [], "diary": []}
|
||||
signal_conflicts = []
|
||||
|
||||
for base, p in sorted(parents.items()):
|
||||
meta = p["meta"] or {}
|
||||
kind = classify(base, meta)
|
||||
cls[kind] += 1
|
||||
wings[meta.get("wing") or "?"] += 1
|
||||
if meta.get("filed_at"):
|
||||
months[str(meta["filed_at"])[:7]] += 1
|
||||
if meta.get("source_machine"):
|
||||
machines[meta["source_machine"]] += 1
|
||||
if meta.get("added_by"):
|
||||
agents[meta["added_by"]] += 1
|
||||
|
||||
# Cross-check the classification against the miner's OWN markers
|
||||
# (source_mtime / normalize_version are written by the miner and by nothing
|
||||
# else). A split here means this palace has a shape the classifier hasn't
|
||||
# been taught, and the counts above are soft.
|
||||
miner_marked = bool(meta.get("source_mtime") or meta.get("normalize_version"))
|
||||
if miner_marked != (kind == "mined"):
|
||||
signal_conflicts.append(base)
|
||||
|
||||
if kind == "mined":
|
||||
continue
|
||||
|
||||
content = "".join(p["chunks"][i] for i in sorted(p["chunks"]))
|
||||
wing, room = meta.get("wing") or "", meta.get("room") or ""
|
||||
|
||||
if kind == "agent_authored":
|
||||
expect = f"drawer_{wing}_{room}_{drawer_hash((wing, room, content))}"
|
||||
drifted = expect != base
|
||||
if VERIFY:
|
||||
verify["checked"] += 1
|
||||
# A mismatch here is NOT a broken recipe — update_drawer preserves the
|
||||
# original id while rewriting (and re-chunking) content, so an edited
|
||||
# drawer's content hash legitimately stops reproducing its id. Named
|
||||
# separately because it breaks one obvious Phase C strategy: you
|
||||
# cannot "recompute the content id and check whether the target has
|
||||
# it" — for drifted drawers that lookup misses and you duplicate.
|
||||
# Replay by STORED id.
|
||||
if drifted:
|
||||
verify["drift"] += 1
|
||||
if len(verify["drift_samples"]) < 5:
|
||||
verify["drift_samples"].append({"stored": base, "recomputed": expect})
|
||||
else:
|
||||
verify["match"] += 1
|
||||
manifest["agent_authored"].append(
|
||||
{"id": base, "wing": wing, "room": room, "chars": len(content),
|
||||
"chunks": len(p["chunks"]), "filed_at": meta.get("filed_at"),
|
||||
"added_by": meta.get("added_by"),
|
||||
"content_id": expect, "edited_since_filing": drifted}
|
||||
)
|
||||
else: # diary — id suffix is a PLAIN sha256(entry)[:12]
|
||||
suffix = base.rsplit("_", 1)[-1]
|
||||
recomputed = hashlib.sha256(content.encode()).hexdigest()[:12]
|
||||
if VERIFY:
|
||||
verify["checked"] += 1
|
||||
if suffix == recomputed:
|
||||
verify["match"] += 1
|
||||
else:
|
||||
verify["mismatch"] += 1
|
||||
if len(verify["samples"]) < 5:
|
||||
verify["samples"].append({"stored": base, "recomputed_suffix": recomputed})
|
||||
manifest["diary"].append(
|
||||
{"id": base, "wing": wing, "chars": len(content), "chunks": len(p["chunks"]),
|
||||
"agent": meta.get("agent"), "topic": meta.get("topic"),
|
||||
"date": meta.get("date"), "dedup_suffix": suffix,
|
||||
"suffix_verified": suffix == recomputed}
|
||||
)
|
||||
|
||||
# ── Knowledge graph ───────────────────────────────────────────────────────────
|
||||
kg = {"present": os.path.isfile(KG)}
|
||||
if kg["present"]:
|
||||
k = ro(KG)
|
||||
try:
|
||||
kg["open_facts"] = k.execute("SELECT COUNT(*) FROM triples WHERE valid_to IS NULL").fetchone()[0]
|
||||
kg["closed_facts"] = k.execute("SELECT COUNT(*) FROM triples WHERE valid_to IS NOT NULL").fetchone()[0]
|
||||
kg["entities"] = k.execute("SELECT COUNT(*) FROM entities").fetchone()[0]
|
||||
kg["predicates"] = dict(
|
||||
k.execute("SELECT predicate, COUNT(*) FROM triples GROUP BY 1 ORDER BY 2 DESC LIMIT 10").fetchall()
|
||||
)
|
||||
except sqlite3.Error as e:
|
||||
kg["error"] = str(e)
|
||||
|
||||
report = {
|
||||
"palace": DB,
|
||||
"kg": KG,
|
||||
"rows_by_collection": counts_by_collection,
|
||||
"parent_drawers": sum(cls.values()),
|
||||
"classes": dict(cls),
|
||||
"replay_surface": cls["diary"] + cls["agent_authored"],
|
||||
"by_wing": dict(wings.most_common()),
|
||||
"filed_at_by_month": dict(sorted(months.items())),
|
||||
"source_machine": dict(machines),
|
||||
"added_by": dict(agents.most_common()),
|
||||
"id_verification": verify if VERIFY else "skipped",
|
||||
"signal_conflicts": len(signal_conflicts),
|
||||
"knowledge_graph": kg,
|
||||
"manifest": manifest,
|
||||
}
|
||||
|
||||
if FMT == "json":
|
||||
print(json.dumps(report, indent=2, sort_keys=False))
|
||||
sys.exit(0)
|
||||
|
||||
# ── Human report ──────────────────────────────────────────────────────────────
|
||||
def bar(n, total, width=28):
|
||||
return "█" * max(1, round(width * n / total)) if n and total else ""
|
||||
|
||||
print(f"\n palace : {DB}")
|
||||
print(f" kg : {KG}{'' if kg['present'] else ' (absent)'}")
|
||||
print("\n ── rows per collection ─────────────────────────────────────")
|
||||
for name, n in sorted(counts_by_collection.items()):
|
||||
note = " ← derived at mine time, NOT joinable" if "closet" in name else ""
|
||||
print(f" {name:<20} {n:>7}{note}")
|
||||
|
||||
total = sum(cls.values())
|
||||
print(f"\n ── parent drawers: {total} ───────────────────────────────────")
|
||||
labels = {
|
||||
"mined": "MINED re-mine on target, never replay",
|
||||
"diary": "DIARY replay + §7.6 suffix skip",
|
||||
"agent_authored": "AGENT-AUTHORED replay, idempotent by content id",
|
||||
}
|
||||
for k in ("mined", "diary", "agent_authored"):
|
||||
n = cls.get(k, 0)
|
||||
pct = 100.0 * n / total if total else 0
|
||||
print(f" {n:>7} {pct:>5.1f}% {labels[k]}")
|
||||
print(f"\n → REPLAY SURFACE: {report['replay_surface']} records "
|
||||
f"({100.0 * report['replay_surface'] / total if total else 0:.1f}% of the palace)")
|
||||
|
||||
if VERIFY:
|
||||
v = verify
|
||||
state = "OK" if v["mismatch"] == 0 else "⚠ MISMATCH"
|
||||
print(f"\n ── id recipe / reassembly self-check: {state} ─────────────")
|
||||
print(f" recomputed {v['checked']} ids — {v['match']} reproduce their stored id, "
|
||||
f"{v['mismatch']} unexplained")
|
||||
for s in v["samples"]:
|
||||
print(f" stored: {s.get('stored')}")
|
||||
print(f" recomputed: {s.get('recomputed') or s.get('recomputed_suffix')}")
|
||||
if v["drift"]:
|
||||
print(f"\n {v['drift']} agent-authored drawers EDITED SINCE FILING "
|
||||
f"(content hash no longer reproduces the id).")
|
||||
print(" update_drawer keeps the id and re-chunks, so this is expected — but it")
|
||||
print(" means Phase C must replay by STORED id. Recomputing the content id and")
|
||||
print(" probing the target for it would miss these and duplicate them.")
|
||||
for s in v["drift_samples"][:3]:
|
||||
print(f" {s['stored']}")
|
||||
if signal_conflicts:
|
||||
print(f"\n ⚠ {len(signal_conflicts)} drawers where the class disagrees with the miner's")
|
||||
print(" own markers (source_mtime / normalize_version) — classifier needs teaching")
|
||||
|
||||
print("\n ── by wing ─────────────────────────────────────────────────")
|
||||
for w, n in wings.most_common(10):
|
||||
print(f" {n:>7} {w}")
|
||||
|
||||
if months:
|
||||
print("\n ── filed_at spread (an MCP replay would flatten all of this) ")
|
||||
mx = max(months.values())
|
||||
for m, n in sorted(months.items()):
|
||||
print(f" {m} {n:>6} {bar(n, mx)}")
|
||||
|
||||
if machines:
|
||||
print("\n ── source_machine ──────────────────────────────────────────")
|
||||
for m, n in machines.most_common():
|
||||
print(f" {n:>7} {m}")
|
||||
|
||||
if kg["present"] and "error" not in kg:
|
||||
print("\n ── knowledge graph ─────────────────────────────────────────")
|
||||
print(f" {kg['entities']:>7} entities")
|
||||
print(f" {kg['open_facts']:>7} open facts (server guard dedupes → replay as-is)")
|
||||
print(f" {kg['closed_facts']:>7} closed facts (NO server guard → client pre-query)")
|
||||
elif kg.get("error"):
|
||||
print(f"\n ⚠ knowledge graph unreadable: {kg['error']}")
|
||||
|
||||
print("\n next: --json > manifest.json feeds RFC 002 Phase B/C.\n")
|
||||
PY
|
||||
+14
-4
@@ -36,7 +36,7 @@ AGENT="${USER:-mempalace}"
|
||||
WING=""
|
||||
SRC=""
|
||||
DRY_RUN=0
|
||||
NO_REPAIR=0
|
||||
DO_REPAIR=0
|
||||
|
||||
# File patterns to include. Docs + config + intent-bearing scripts.
|
||||
# Everything else (code) is excluded by omission.
|
||||
@@ -77,7 +77,13 @@ Options:
|
||||
--wing <name> Override wing name (default: source directory name)
|
||||
--agent <name> Agent name recorded on drawers (default: $USER)
|
||||
--dry-run List files that would be mined; do not file
|
||||
--no-repair Skip `mempalace repair` after mining
|
||||
--repair Run `mempalace repair` after mining (opt-in).
|
||||
WARNING: repair does a destructive in-place HNSW
|
||||
rebuild. If it races a live MCP connection or crashes
|
||||
mid-rebuild, it can wipe the collection. Only pass
|
||||
this from a quiet, interactive context. Not safe for
|
||||
unattended cron/launchd schedules.
|
||||
--no-repair (Deprecated; no-repair is now the default.)
|
||||
-h, --help Show this help
|
||||
|
||||
What gets mined:
|
||||
@@ -109,7 +115,8 @@ while [[ $# -gt 0 ]]; do
|
||||
--wing) WING="${2:-}"; shift 2 ;;
|
||||
--agent) AGENT="${2:-}"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--no-repair) NO_REPAIR=1; shift ;;
|
||||
--repair) DO_REPAIR=1; shift ;;
|
||||
--no-repair) shift ;; # deprecated alias; no-repair is the default
|
||||
--) shift; break ;;
|
||||
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 1 ;;
|
||||
*) if [[ -z "$SRC" ]]; then SRC="$1"; shift; else echo "error: unexpected arg: $1" >&2; exit 1; fi ;;
|
||||
@@ -258,8 +265,11 @@ if ! mempalace mine "$STAGE" --agent "$AGENT" --wing "$WING"; then
|
||||
fi
|
||||
|
||||
# ── Repair index ─────────────────────────────────────────────────────
|
||||
if [[ $NO_REPAIR -eq 0 ]]; then
|
||||
if [[ $DO_REPAIR -eq 1 ]]; then
|
||||
echo ""
|
||||
echo "WARNING: --repair runs an in-place HNSW rebuild that has wiped"
|
||||
echo " live palaces on past runs. Proceeding in 3 seconds..."
|
||||
sleep 3
|
||||
echo "Rebuilding HNSW index..."
|
||||
mempalace repair --yes
|
||||
fi
|
||||
|
||||
Executable
+942
@@ -0,0 +1,942 @@
|
||||
#!/usr/bin/env bash
|
||||
# mempalace-pi-session — mine pi coding-agent session history into MemPalace
|
||||
#
|
||||
# Pi persists every session (verbatim user/assistant turns + tool calls + tool
|
||||
# results) as newline-delimited JSONL under ~/.pi/agent/sessions/. Pi has no
|
||||
# upstream MemPalace integration and mempalace-toolkit's existing wrapper
|
||||
# (`mempalace-session`) only handles opencode's SQLite DB, so pi sessions are
|
||||
# currently invisible to the palace.
|
||||
#
|
||||
# Strategy (mirrors mempalace-session):
|
||||
# 1. Walk ~/.pi/agent/sessions/**/*.jsonl and export each qualifying session
|
||||
# to a Claude Code JSONL file (format the mempalace normalizer speaks).
|
||||
# 2. Stage exports under $MEMPALACE_PI_STAGE/<wing> (default
|
||||
# <palace-root>/pi-stage/<wing> — alongside the palace it feeds).
|
||||
# 3. Run `mempalace mine --mode convos` against the staging dir.
|
||||
#
|
||||
# TWO PHASES (--prepare), because the palace is single-writer
|
||||
# mempalace refuses a CLI mine while another process holds the palace:
|
||||
# "palace ... is held by PID <n> (mempalace-mcp); wait for it to finish"
|
||||
# A live pi session ALWAYS has a holder — the mempalace extension's own
|
||||
# mempalace-mcp. So an unattended CLI mine only works when no session is
|
||||
# live (e.g. a container-start catch-up); during a session the mine must be
|
||||
# performed by the process that already holds the palace. Hence:
|
||||
#
|
||||
# --prepare export + stage (+ rsync in remote mode) and print
|
||||
# MINE_SOURCE=<path>, without ever opening the palace.
|
||||
# (default) the above, then mine it ourselves. Contention is treated as
|
||||
# success-with-nothing-to-do, not failure: the holder's own
|
||||
# extension will mine what we staged.
|
||||
#
|
||||
# The pi mempalace extension drives exactly this: it runs --prepare on
|
||||
# session_shutdown and on a debounced agent_settled, then calls
|
||||
# mempalace_mine on MINE_SOURCE through its existing MCP client.
|
||||
#
|
||||
# TRANSPORTS (--mode, default auto)
|
||||
# local Mine into the local palace with the mempalace CLI.
|
||||
# remote $MEMPALACE_REMOTE_URL is set, so the palace lives on another host.
|
||||
# There is no remote-palace CLI — only the HTTP MCP server — and
|
||||
# mempalace_mine expands its source path in the SERVER process, so
|
||||
# the server cannot see this machine's staged exports. We therefore
|
||||
# rsync the stage into a per-device inbox on the palace host and ask
|
||||
# the server to mine its own local path. Requires
|
||||
# MEMPALACE_PI_SSH_TARGET (where to rsync) and
|
||||
# MEMPALACE_PI_REMOTE_PATH (what that inbox is called server-side).
|
||||
#
|
||||
# MEMPALACE_PI_REMOTE_PATH is the path AS THE SERVER PROCESS SEES
|
||||
# IT, and the default (/data/feed) assumes a CONTAINERIZED server
|
||||
# with the inbox bind-mounted there. A NATIVE server (systemd unit /
|
||||
# uv tool / plain `mempalace serve`) sees host paths, so there it
|
||||
# must equal the path half of MEMPALACE_PI_SSH_TARGET. Get this
|
||||
# wrong and rsync still succeeds while the mine fails with
|
||||
# "source directory not found" — so a mismatch between the two is
|
||||
# warned about at ship time, and the mine's own failure is now
|
||||
# detected properly (see classify() in run_remote_mine).
|
||||
#
|
||||
# Labelling: every exported transcript begins with a synthetic header
|
||||
# [session: <title> | <cwd> | <YYYY-MM-DD> | source: pi]
|
||||
# so post-mine search results are self-identifying (pi vs opencode vs other).
|
||||
#
|
||||
# Dedup: mempalace convos mode keys on source_file (absolute staging path).
|
||||
# Staging paths are deterministic per pi session UUID, and the export copies
|
||||
# the source session's mtime onto the staged file, so re-runs are idempotent
|
||||
# until session content actually changes. A GROWN session is purged and
|
||||
# refiled for that source_file by the miner, so re-feeding a live session
|
||||
# refreshes its drawers instead of duplicating them.
|
||||
#
|
||||
# Staging location: source_file dedup keys on the staged path, so if the stage
|
||||
# is wiped the palace is left with drawers whose source files look deleted.
|
||||
# `mempalace sync` prunes exactly those — but only within the scope it is
|
||||
# given. Measured on this layout: scoped at the palace root the staged sources
|
||||
# are in scope (kept 651), while a wing-only sync reports them out_of_scope and
|
||||
# leaves them alone. So the data loss is conditional on how sync is invoked,
|
||||
# which is far too thin a margin to rely on.
|
||||
#
|
||||
# The stage therefore defaults NEXT TO THE PALACE (<palace-root>/pi-stage,
|
||||
# resolved the way mempalace itself resolves the palace: $MEMPALACE_PALACE_PATH
|
||||
# → $MEMPAL_PALACE_PATH → ~/.mempalace/config.json → ~/.mempalace/palace).
|
||||
#
|
||||
# That makes the invariant structural rather than documented: the stage and the
|
||||
# dedup keys that reference it share one lifetime, so the dangerous state —
|
||||
# palace survives, stage does not — can no longer be reached by wiping
|
||||
# something that merely looks disposable. A cache dir (the obvious choice, and
|
||||
# the old default) is exactly wrong here: it persists just long enough to look
|
||||
# correct, then takes the memories with it. Override with MEMPALACE_PI_STAGE
|
||||
# only if the target is at least as durable as the palace.
|
||||
#
|
||||
# In remote mode the local stage is only a shipping buffer — dedup lives on the
|
||||
# server, keyed by the server-side inbox path — so its durability is moot there.
|
||||
#
|
||||
# Session filter: two gates, both required.
|
||||
# 1. --min-messages <N> user+assistant turns (default 4). Tool loops inflate
|
||||
# assistant turns fast in pi, so a real working session clears this
|
||||
# easily; a single abandoned prompt does not.
|
||||
# 2. --min-assistant-chars <N> characters of assistant *text* (default 1000),
|
||||
# excluding tool results. Assistant volume, not total volume: pi expands
|
||||
# skills/context into the user prompt, so an abandoned session can carry a
|
||||
# 13k-char "user" message answered with "Ready. What would you like to
|
||||
# work on?" — total size says substantial, assistant size correctly says
|
||||
# nothing happened.
|
||||
#
|
||||
# Usage:
|
||||
# mempalace-pi-session
|
||||
# mempalace-pi-session --prepare
|
||||
# mempalace-pi-session --mode remote
|
||||
# mempalace-pi-session --wing <name>
|
||||
# mempalace-pi-session --session <uuid-prefix>
|
||||
# mempalace-pi-session --since 2026-04-01
|
||||
# mempalace-pi-session --min-messages 6
|
||||
# mempalace-pi-session --dry-run
|
||||
# mempalace-pi-session --help
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 success (including "nothing qualified", "another run holds the lock",
|
||||
# and "palace held by a live session")
|
||||
# 1 usage / argument error
|
||||
# 2 pi sessions dir missing
|
||||
# 3 mempalace CLI not installed / rsync missing in remote mode
|
||||
# 4 mine failed
|
||||
# 5 remote transport failed (rsync or HTTP tools/call)
|
||||
#
|
||||
# Dependencies: bash, python3 (stdlib only), mempalace (v3.3.3+);
|
||||
# rsync + ssh in remote mode.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# HOME can legitimately be unset: `docker run --entrypoint="" <image>` inherits
|
||||
# no HOME when the image config declares none (pi-devbox's does not — HOME is
|
||||
# normally set by its entrypoint, which --entrypoint="" skips), and every
|
||||
# default below is HOME-anchored under `set -u`, so the script died at line 1 of
|
||||
# real work with "HOME: unbound variable". Derive it from the passwd database —
|
||||
# exactly what python's expanduser() falls back to — so the script, and
|
||||
# especially the palace-free --self-test, runs in a bare container too.
|
||||
# pi-devbox v1.8.0 lost a release to this same "the image sets HOME" assumption.
|
||||
: "${HOME:=$(python3 -c 'import os, pwd; print(pwd.getpwuid(os.getuid()).pw_dir)' 2>/dev/null || echo /tmp)}"
|
||||
export HOME
|
||||
|
||||
# ── Defaults ─────────────────────────────────────────────────────────
|
||||
AGENT="${USER:-mempalace}"
|
||||
WING="wing_conversations"
|
||||
SESSION_ID=""
|
||||
SINCE=""
|
||||
MIN_MESSAGES=4
|
||||
MIN_ASSISTANT_CHARS=1000
|
||||
DRY_RUN=0
|
||||
DO_REPAIR=0
|
||||
PREPARE_ONLY=0
|
||||
SELF_TEST=0
|
||||
MODE="auto"
|
||||
REASON=""
|
||||
PI_SESSIONS_DIR="${PI_SESSIONS_DIR:-$HOME/.pi/agent/sessions}"
|
||||
|
||||
# Resolve the palace ROOT (the dir holding palace/, knowledge_graph.sqlite3,
|
||||
# config.json) using mempalace's own precedence, so the stage lands next to
|
||||
# whichever palace this host actually feeds. Mirrors config.py:palace_path()
|
||||
# (env → config.json → default) and takes the parent. Only evaluated when
|
||||
# MEMPALACE_PI_STAGE is unset, so the common path costs nothing.
|
||||
palace_root() {
|
||||
python3 - <<'PY' 2>/dev/null || echo "$HOME/.mempalace"
|
||||
import json, os
|
||||
p = os.environ.get("MEMPALACE_PALACE_PATH") or os.environ.get("MEMPAL_PALACE_PATH")
|
||||
if p:
|
||||
p = os.path.abspath(os.path.expanduser(p))
|
||||
else:
|
||||
cfg = os.path.expanduser("~/.mempalace/config.json")
|
||||
p = None
|
||||
if os.path.exists(cfg):
|
||||
try:
|
||||
with open(cfg) as fh:
|
||||
v = json.load(fh).get("palace_path")
|
||||
p = os.path.expanduser(v) if v else None
|
||||
except Exception:
|
||||
p = None
|
||||
p = p or os.path.expanduser("~/.mempalace/palace")
|
||||
print(os.path.dirname(p.rstrip("/")))
|
||||
PY
|
||||
}
|
||||
STAGE_ROOT="${MEMPALACE_PI_STAGE:-$(palace_root)/pi-stage}"
|
||||
|
||||
# Remote transport (see TRANSPORTS in the header)
|
||||
REMOTE_URL="${MEMPALACE_REMOTE_URL:-}"
|
||||
REMOTE_TOKEN="${MEMPALACE_REMOTE_TOKEN:-}"
|
||||
SSH_TARGET="${MEMPALACE_PI_SSH_TARGET:-}"
|
||||
SSH_CONFIG="${MEMPALACE_PI_SSH_CONFIG:-}"
|
||||
REMOTE_PATH="${MEMPALACE_PI_REMOTE_PATH:-/data/feed}"
|
||||
DEVICE="${MEMPALACE_PI_DEVICE:-$(hostname)}"
|
||||
|
||||
# ── Usage ────────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
mempalace-pi-session — mine pi coding-agent session history into MemPalace
|
||||
|
||||
Usage:
|
||||
mempalace-pi-session [options]
|
||||
|
||||
Options:
|
||||
--wing <name> Target wing (default: wing_conversations)
|
||||
--session <prefix> Export one session only (match on UUID prefix)
|
||||
--since <YYYY-MM-DD> Only sessions last modified on/after this date
|
||||
--min-messages <N> Skip sessions with fewer than N user+assistant
|
||||
turns (default: 4)
|
||||
--min-assistant-chars <N>
|
||||
Skip sessions with fewer than N characters of
|
||||
assistant text, tool results excluded (default: 1000).
|
||||
Catches abandoned sessions whose bulk is injected
|
||||
skill/context text in the user prompt.
|
||||
--agent <name> Agent name recorded on drawers (default: $USER)
|
||||
--sessions-dir <path> Path to pi sessions dir (default: $PI_SESSIONS_DIR
|
||||
or ~/.pi/agent/sessions)
|
||||
--stage <path> Staging root (default: $MEMPALACE_PI_STAGE, else
|
||||
<palace-root>/pi-stage — next to the palace, so the
|
||||
stage cannot be wiped independently of the dedup keys
|
||||
that point at it). Exports go in <root>/<wing>.
|
||||
See "Staging location" in the header before moving it.
|
||||
--mode <m> auto|local|remote (default: auto — remote when
|
||||
$MEMPALACE_REMOTE_URL is set)
|
||||
--prepare Export + stage (+ rsync in remote mode), print
|
||||
MINE_SOURCE=<path>, and stop without opening the
|
||||
palace. For callers that will do the mine themselves
|
||||
through a live MCP connection.
|
||||
--reason <label> Label this run in its output (e.g. shutdown, tick,
|
||||
container-start). Useful when triggers log to a file.
|
||||
--dry-run Export + list; do not mine into palace. Each session
|
||||
is tagged [NEW] or [SKIP] based on whether its
|
||||
source_file is already in the palace. In remote mode
|
||||
the tag is [?]: dedup is decided by the palace host,
|
||||
which this machine's local palace copy cannot answer.
|
||||
--self-test Run the remote-mine response classifier against
|
||||
recorded MCP responses and exit. Needs no palace, no
|
||||
network and no sessions dir.
|
||||
--repair Run `mempalace repair` after mining (opt-in).
|
||||
WARNING: repair does a destructive in-place HNSW
|
||||
rebuild. If it races a live MCP connection or
|
||||
crashes mid-rebuild, it can wipe the collection.
|
||||
Only pass this from a quiet, interactive context.
|
||||
Not safe for unattended cron/launchd schedules.
|
||||
--no-repair (Deprecated; no-repair is now the default.)
|
||||
-h, --help Show this help
|
||||
|
||||
Idempotency:
|
||||
Re-running on the same corpus is safe. The export step writes every
|
||||
qualifying session to the cache; the mine step dedups by source_file so
|
||||
already-filed sessions are skipped without re-embedding.
|
||||
|
||||
Transcript shape per session:
|
||||
- Synthetic header as first user turn:
|
||||
[session: <title> | <cwd> | <YYYY-MM-DD> | source: pi]
|
||||
- User/assistant messages extracted from pi JSONL `message` entries
|
||||
- Assistant toolCall blocks → Claude Code `tool_use` blocks
|
||||
- `toolResult` role messages → `tool_result` blocks (folded back into
|
||||
the assistant turn by the normalizer)
|
||||
- `bashExecution`, `custom(display=true)`, `branchSummary`,
|
||||
`compactionSummary` → rendered as text annotations
|
||||
- `thinking` content blocks → dropped (noise)
|
||||
- Image content blocks → dropped (palace embeds text only)
|
||||
|
||||
Dedup:
|
||||
- source_file = absolute staging path (deterministic per pi session UUID)
|
||||
- Re-runs skip unchanged sessions; a GROWN session (mtime changed) has its
|
||||
old drawers purged and is refiled, so re-feeding a live session refreshes
|
||||
rather than duplicates.
|
||||
- To force re-mining, delete the staging dir:
|
||||
rm -rf <palace-root>/pi-stage/<wing>/
|
||||
That forces a refile — but do NOT run `mempalace sync` while the stage is
|
||||
missing, or the drawers mined from it get pruned instead.
|
||||
|
||||
Rationale:
|
||||
Two complementary paths feed the palace from pi, and they cover different
|
||||
failure modes:
|
||||
- The pi mempalace bridge extension (extensions/pi/mempalace.ts) drives
|
||||
this script with --prepare on session_shutdown and on a debounced
|
||||
agent_settled, then mines through its own live MCP connection. That is
|
||||
the primary path: it needs no scheduling and it is the only way to write
|
||||
while a session holds the palace.
|
||||
- Running this script directly is the batch/recovery path: a
|
||||
container-start or host-level catch-up that picks up transcripts nothing
|
||||
mined at the time — notably after a SIGKILL, where no pi handler runs at
|
||||
all. It reads the durable on-disk JSONL, so it does not care whether the
|
||||
session that produced it exited cleanly.
|
||||
EOF
|
||||
}
|
||||
|
||||
# ── Remote mine over MCP ─────────────────────────────────────────────
|
||||
# Usage: run_remote_mine <url> <token> <source> <wing> <agent>
|
||||
# run_remote_mine --self-test
|
||||
#
|
||||
# WHY THIS IS A FUNCTION WITH A SELF-TEST: MCP answers a hard tool failure with
|
||||
# HTTP 200 and a JSON-RPC *result* whose content[].text holds the tool's own
|
||||
# JSON as an ESCAPED STRING. This code used to decide success with
|
||||
# `'"error"' in body`, which can never match those bytes (they are \"error\"),
|
||||
# so on 2026-08-15 a mine that failed with
|
||||
# {"success": false, "error": "source directory not found: '/data/feed/...'"}
|
||||
# was reported as "Done. Wing updated." and nothing was filed. A silent
|
||||
# false success in a feeder is worse than a crash: the only artifact says it
|
||||
# worked. The fixtures below pin that exact body so it cannot come back.
|
||||
run_remote_mine() {
|
||||
python3 - "$@" <<'PY'
|
||||
import json, sys, urllib.error, urllib.request
|
||||
|
||||
|
||||
def classify(body):
|
||||
"""Return (ok, note) for an MCP tools/call response body.
|
||||
|
||||
ok=False means the mine demonstrably failed. note carries the reason, or —
|
||||
when ok is True — an "unverified" caveat if the response contained no JSON
|
||||
tool payload to adjudicate. Never claim more than the bytes support.
|
||||
"""
|
||||
try:
|
||||
env = json.loads(body)
|
||||
except ValueError:
|
||||
return False, "response was not JSON: " + body[:200].replace("\n", " ")
|
||||
if not isinstance(env, dict):
|
||||
return False, "response was not a JSON object"
|
||||
if env.get("error") is not None: # JSON-RPC transport-level error
|
||||
return False, "JSON-RPC error: " + json.dumps(env["error"])[:300]
|
||||
result = env.get("result")
|
||||
if not isinstance(result, dict):
|
||||
return False, "response carried no result object"
|
||||
if result.get("isError"):
|
||||
return False, "MCP isError set: " + json.dumps(result.get("content"))[:300]
|
||||
saw_payload = False
|
||||
for item in result.get("content") or []:
|
||||
text = item.get("text") if isinstance(item, dict) else None
|
||||
if not isinstance(text, str):
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(text) # the escaped inner JSON
|
||||
except ValueError:
|
||||
continue # plain prose content: nothing to judge
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
saw_payload = True
|
||||
if payload.get("success") is False:
|
||||
return False, str(payload.get("error") or "tool reported success=false")
|
||||
if payload.get("error"):
|
||||
return False, str(payload["error"])
|
||||
if not saw_payload:
|
||||
return True, "unverified: no JSON tool payload in the response"
|
||||
return True, ""
|
||||
|
||||
|
||||
FIXTURES = [
|
||||
# 1. The real 2026-08-15 failure: HTTP 200, JSON-RPC result, tool failed.
|
||||
('{"jsonrpc": "2.0", "id": 1, "result": {"content": [{"type": "text", '
|
||||
'"text": "{\\n \\"success\\": false,\\n \\"error\\": \\"source directory '
|
||||
'not found: \'/data/feed/emb-7kj4vr4g\'\\"\\n}"}]}}', False),
|
||||
# 2. A real success.
|
||||
('{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":'
|
||||
'"{\\"success\\": true, \\"mode\\": \\"convos\\", \\"output\\": \\"Drawers filed: 12\\"}"}]}}', True),
|
||||
# 3. JSON-RPC level error (bad method, auth rejected at protocol level).
|
||||
('{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}', False),
|
||||
# 4. MCP tool-level isError flag.
|
||||
('{"jsonrpc":"2.0","id":1,"result":{"isError":true,"content":[{"type":"text","text":"boom"}]}}', False),
|
||||
# 5. Not JSON at all (proxy error page, 502 HTML).
|
||||
('<html><body>502 Bad Gateway</body></html>', False),
|
||||
# 6. Success-shaped envelope with prose content: cannot be called a failure.
|
||||
('{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"mined 3 files"}]}}', True),
|
||||
]
|
||||
|
||||
args = sys.argv[1:]
|
||||
|
||||
if args[:1] == ["--self-test"]:
|
||||
failures = 0
|
||||
for i, (body, want_ok) in enumerate(FIXTURES, 1):
|
||||
got_ok, note = classify(body)
|
||||
if got_ok != want_ok:
|
||||
failures += 1
|
||||
print(f" [{'ok ' if got_ok == want_ok else 'FAIL'}] fixture {i}: "
|
||||
f"want_ok={want_ok} got_ok={got_ok} note={note[:70]!r}")
|
||||
# Regression guard: the detector this replaced must be shown blind to #1.
|
||||
old_detector_sees_it = '"error"' in FIXTURES[0][0]
|
||||
if old_detector_sees_it:
|
||||
failures += 1
|
||||
print(f" [{'ok ' if not old_detector_sees_it else 'FAIL'}] regression guard: "
|
||||
f"substring detector sees fixture 1? {old_detector_sees_it} (must be False)")
|
||||
print("SELF-TEST " + ("FAILED" if failures else "PASSED"))
|
||||
sys.exit(1 if failures else 0)
|
||||
|
||||
url, token, source, wing, agent = args[:5]
|
||||
payload = json.dumps({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "mempalace_mine",
|
||||
"arguments": {"source": source, "mode": "convos", "wing": wing, "agent": agent},
|
||||
},
|
||||
}).encode()
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
urllib.request.Request(url, data=payload, headers=headers), timeout=900
|
||||
) as resp:
|
||||
body = resp.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace")[:500] if hasattr(exc, "read") else ""
|
||||
print(f"error: remote mine transport failed: HTTP {exc.code} {exc.reason} {detail}",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except urllib.error.URLError as exc:
|
||||
print(f"error: remote mine transport failed: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(body[:4000])
|
||||
ok, note = classify(body)
|
||||
if not ok:
|
||||
print(f"error: remote mine reported failure: {note}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if note:
|
||||
print(f"warning: remote mine {note}", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
PY
|
||||
}
|
||||
|
||||
# ── Parse args ───────────────────────────────────────────────────────
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
--wing) WING="${2:-}"; shift 2 ;;
|
||||
--session) SESSION_ID="${2:-}"; shift 2 ;;
|
||||
--since) SINCE="${2:-}"; shift 2 ;;
|
||||
--min-messages) MIN_MESSAGES="${2:-}"; shift 2 ;;
|
||||
--min-assistant-chars) MIN_ASSISTANT_CHARS="${2:-}"; shift 2 ;;
|
||||
--stage) STAGE_ROOT="${2:-}"; shift 2 ;;
|
||||
--mode) MODE="${2:-}"; shift 2 ;;
|
||||
--prepare) PREPARE_ONLY=1; shift ;;
|
||||
--reason) REASON="${2:-}"; shift 2 ;;
|
||||
--agent) AGENT="${2:-}"; shift 2 ;;
|
||||
--sessions-dir) PI_SESSIONS_DIR="${2:-}"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--self-test) SELF_TEST=1; shift ;;
|
||||
--repair) DO_REPAIR=1; shift ;;
|
||||
--no-repair) shift ;; # deprecated alias; no-repair is the default
|
||||
--) shift; break ;;
|
||||
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 1 ;;
|
||||
*) echo "error: unexpected arg: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# The classifier self-test is pure logic: no palace, no network, no sessions
|
||||
# dir. Dispatch before preflight so it runs anywhere, including in CI.
|
||||
if [[ $SELF_TEST -eq 1 ]]; then
|
||||
echo "mempalace-pi-session --self-test: remote-mine response classifier"
|
||||
run_remote_mine --self-test
|
||||
exit $?
|
||||
fi
|
||||
|
||||
# ── Preflight ────────────────────────────────────────────────────────
|
||||
if [[ ! -d "$PI_SESSIONS_DIR" ]]; then
|
||||
echo "error: pi sessions dir not found at $PI_SESSIONS_DIR" >&2
|
||||
echo " override with --sessions-dir <path> or PI_SESSIONS_DIR env var" >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$MODE" in
|
||||
auto) if [[ -n "$REMOTE_URL" ]]; then MODE="remote"; else MODE="local"; fi ;;
|
||||
local|remote) ;;
|
||||
*) echo "error: --mode must be auto|local|remote" >&2; exit 1 ;;
|
||||
esac
|
||||
# The mempalace CLI is only needed when WE do the mine. --prepare never opens
|
||||
# the palace, and remote mode talks to the server over HTTP.
|
||||
if [[ $PREPARE_ONLY -eq 0 && "$MODE" == "local" ]] && ! command -v mempalace >/dev/null 2>&1; then
|
||||
echo "error: mempalace CLI not found in PATH" >&2
|
||||
exit 3
|
||||
fi
|
||||
if [[ "$MODE" == "remote" ]]; then
|
||||
command -v rsync >/dev/null 2>&1 || { echo "error: rsync not found (needed for --mode remote)" >&2; exit 3; }
|
||||
if [[ -z "$SSH_TARGET" ]]; then
|
||||
echo "error: MEMPALACE_PI_SSH_TARGET unset (needed for --mode remote)" >&2
|
||||
exit 1
|
||||
fi
|
||||
# The devbox generates a dedicated LAN-jump key/config; prefer it if present.
|
||||
if [[ -z "$SSH_CONFIG" && -f "$HOME/.ssh-local/config" ]]; then
|
||||
SSH_CONFIG="$HOME/.ssh-local/config"
|
||||
fi
|
||||
# Remote mode names the same inbox twice: where rsync PUTS the files, and
|
||||
# what the SERVER is told to mine. They may legitimately differ
|
||||
# (containerized server: host dir bind-mounted elsewhere), but when they
|
||||
# differ by accident rsync still succeeds and only the mine fails — the
|
||||
# 2026-08-15 /data/feed incident, where transcripts shipped for hours and
|
||||
# were filed nowhere. Warn in PREFLIGHT so --dry-run and --prepare see it
|
||||
# too, not just a full run that gets as far as shipping.
|
||||
SHIP_PATH="$SSH_TARGET"
|
||||
[[ "$SHIP_PATH" == *:* ]] && SHIP_PATH="${SHIP_PATH##*:}"
|
||||
if [[ "${SHIP_PATH%/}" != "${REMOTE_PATH%/}" ]]; then
|
||||
echo "note: shipping to '${SHIP_PATH%/}/$DEVICE' but asking the server to mine"
|
||||
echo " '${REMOTE_PATH%/}/$DEVICE'. Correct only if the palace server sees"
|
||||
echo " '${SHIP_PATH%/}' at '${REMOTE_PATH%/}' (containerized server with a bind"
|
||||
echo " mount). A NATIVE server (systemd unit / uv tool) sees host paths —"
|
||||
echo " then set MEMPALACE_PI_REMOTE_PATH='${SHIP_PATH%/}'."
|
||||
fi
|
||||
fi
|
||||
for _n in MIN_MESSAGES MIN_ASSISTANT_CHARS; do
|
||||
if ! [[ "${!_n}" =~ ^[0-9]+$ ]]; then
|
||||
_flag="--$(printf '%s' "${_n,,}" | tr '_' '-')"
|
||||
echo "error: $_flag must be an integer" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Staging dir ──────────────────────────────────────────────────────
|
||||
# Deterministic per-wing path so source_file dedup works across re-runs. See
|
||||
# "Staging location" in the header for why this should not be disposable.
|
||||
STAGE="${STAGE_ROOT%/}/$WING"
|
||||
mkdir -p "$STAGE"
|
||||
|
||||
[[ -n "$REASON" ]] && echo "mempalace-pi-session [$REASON] mode=$MODE stage=$STAGE"
|
||||
|
||||
# ── Single-writer guard ──────────────────────────────────────────────
|
||||
# Non-blocking: overlapping triggers (a session_shutdown landing on top of a
|
||||
# debounced mid-session run) must not queue or race. Losing a run is harmless
|
||||
# — the next one re-exports from scratch.
|
||||
exec 9>"${STAGE_ROOT%/}/.lock"
|
||||
if command -v flock >/dev/null 2>&1 && ! flock -n 9; then
|
||||
echo "another mempalace-pi-session run holds the lock; skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Export sessions (Python heredoc) ────────────────────────────────
|
||||
# Parses pi JSONL files and writes Claude Code JSONL per session into $STAGE.
|
||||
# Also classifies each export as NEW/ALREADY FILED (by source_file lookup)
|
||||
# so --dry-run reports the real mine-set size. Classification is advisory;
|
||||
# `mempalace mine --mode convos` is still the authoritative dedup.
|
||||
export_count=$(python3 - "$PI_SESSIONS_DIR" "$STAGE" "$SESSION_ID" "$SINCE" "$MIN_MESSAGES" "$MIN_ASSISTANT_CHARS" "$MODE" <<'PY'
|
||||
import json, os, sqlite3, sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sessions_dir, stage, session_filter, since, min_messages, min_assistant_chars, mode = sys.argv[1:8]
|
||||
min_messages = int(min_messages)
|
||||
min_assistant_chars = int(min_assistant_chars)
|
||||
stage = Path(stage)
|
||||
sessions_dir = Path(sessions_dir)
|
||||
|
||||
# Convert --since YYYY-MM-DD to epoch seconds (comparing against file mtime)
|
||||
since_epoch = None
|
||||
if since:
|
||||
try:
|
||||
since_epoch = datetime.strptime(since, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()
|
||||
except ValueError:
|
||||
print(f"error: --since must be YYYY-MM-DD, got {since!r}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# ── Load palace's already-filed source_files (best-effort, read-only) ──
|
||||
# already_filed is None in remote mode: unknown, not empty. The dedup that
|
||||
# matters happens on the PALACE HOST and is keyed on the REMOTE inbox path, so
|
||||
# this machine's local palace file cannot answer the question — and answering
|
||||
# it anyway is how a preview comes to say "6 already filed" about a palace it
|
||||
# is not feeding (2026-08-15). An honest "[?]" beats a confident wrong number.
|
||||
already_filed = None if mode == "remote" else set()
|
||||
# Mirror mempalace's own resolution order (config.py): MEMPALACE_PALACE_PATH,
|
||||
# then the legacy MEMPAL_PALACE_PATH, then the default. NOT "MEMPALACE_PATH" —
|
||||
# that name is not a mempalace concept, and reading it silently degraded this
|
||||
# NEW/SKIP preview to "everything is new" wherever some other tool had set it.
|
||||
palace_path = (
|
||||
os.environ.get("MEMPALACE_PALACE_PATH")
|
||||
or os.environ.get("MEMPAL_PALACE_PATH")
|
||||
or os.path.expanduser("~/.mempalace/palace")
|
||||
)
|
||||
chroma_db = Path(palace_path) / "chroma.sqlite3"
|
||||
if already_filed is not None and chroma_db.is_file():
|
||||
try:
|
||||
pcon = sqlite3.connect(f"file:{chroma_db}?mode=ro", uri=True)
|
||||
for (sf,) in pcon.execute(
|
||||
"SELECT DISTINCT string_value FROM embedding_metadata "
|
||||
"WHERE key='source_file' AND string_value LIKE ?",
|
||||
(f"{stage}%",),
|
||||
):
|
||||
if sf:
|
||||
already_filed.add(sf)
|
||||
pcon.close()
|
||||
except sqlite3.Error:
|
||||
pass # palace unreachable → miner will dedup
|
||||
|
||||
def extract_text(content):
|
||||
"""Flatten a message content (string | list-of-blocks) to plain text.
|
||||
|
||||
Drops image + thinking blocks; keeps text + renders toolCall/toolResult
|
||||
stubs inline. Returns ("", [tool_uses], [tool_results]) where tool_uses
|
||||
are collected for assistant messages and tool_results for toolResult
|
||||
messages.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
return content, [], []
|
||||
if not isinstance(content, list):
|
||||
return "", [], []
|
||||
text_parts = []
|
||||
tool_uses = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
bt = block.get("type")
|
||||
if bt == "text":
|
||||
t = block.get("text", "")
|
||||
if t:
|
||||
text_parts.append(t)
|
||||
elif bt == "thinking":
|
||||
# Drop reasoning content — high-noise, low-signal for search.
|
||||
continue
|
||||
elif bt == "image":
|
||||
# Palace is text-only.
|
||||
continue
|
||||
elif bt == "toolCall":
|
||||
tool_uses.append({
|
||||
"type": "tool_use",
|
||||
"id": block.get("id") or "",
|
||||
"name": block.get("name") or "tool",
|
||||
"input": block.get("arguments") or {},
|
||||
})
|
||||
return "\n".join(text_parts), tool_uses, []
|
||||
|
||||
def load_session(path: Path):
|
||||
"""Parse a pi JSONL session file. Returns (header, entries) or None."""
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
lines = [ln for ln in f.read().splitlines() if ln.strip()]
|
||||
except OSError:
|
||||
return None
|
||||
if not lines:
|
||||
return None
|
||||
try:
|
||||
header = json.loads(lines[0])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if header.get("type") != "session":
|
||||
return None
|
||||
entries = []
|
||||
for ln in lines[1:]:
|
||||
try:
|
||||
entries.append(json.loads(ln))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return header, entries
|
||||
|
||||
def derive_title(entries, fallback: str) -> str:
|
||||
"""Prefer session_info.name; else truncated first user message."""
|
||||
# session_info entries: most-recent wins
|
||||
name = None
|
||||
for e in entries:
|
||||
if e.get("type") == "session_info" and e.get("name"):
|
||||
name = e["name"]
|
||||
if name:
|
||||
return name[:120]
|
||||
for e in entries:
|
||||
if e.get("type") != "message":
|
||||
continue
|
||||
msg = e.get("message") or {}
|
||||
if msg.get("role") != "user":
|
||||
continue
|
||||
text, _, _ = extract_text(msg.get("content"))
|
||||
text = " ".join(text.split()) # collapse whitespace
|
||||
if text:
|
||||
return (text[:80] + "…") if len(text) > 80 else text
|
||||
return fallback
|
||||
|
||||
# Discover session files
|
||||
paths = sorted(sessions_dir.rglob("*.jsonl"))
|
||||
if session_filter:
|
||||
paths = [p for p in paths if session_filter in p.name]
|
||||
|
||||
exported = 0
|
||||
skipped_short = 0
|
||||
skipped_quiet = 0
|
||||
skipped_malformed = 0
|
||||
skipped_already_filed = 0
|
||||
|
||||
for path in paths:
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if since_epoch is not None and mtime < since_epoch:
|
||||
continue
|
||||
|
||||
parsed = load_session(path)
|
||||
if parsed is None:
|
||||
skipped_malformed += 1
|
||||
continue
|
||||
header, entries = parsed
|
||||
session_uuid = header.get("id") or path.stem
|
||||
cwd = header.get("cwd") or "?"
|
||||
header_ts = header.get("timestamp") or ""
|
||||
try:
|
||||
date_str = header_ts[:10] if header_ts else datetime.fromtimestamp(
|
||||
mtime, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
except Exception:
|
||||
date_str = datetime.fromtimestamp(mtime, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
# Count user+assistant message entries for the min-messages filter
|
||||
turn_count = sum(
|
||||
1 for e in entries
|
||||
if e.get("type") == "message"
|
||||
and (e.get("message") or {}).get("role") in ("user", "assistant")
|
||||
)
|
||||
if turn_count < min_messages:
|
||||
skipped_short += 1
|
||||
continue
|
||||
|
||||
title = derive_title(entries, fallback=session_uuid[:8])
|
||||
assistant_chars = 0
|
||||
out_lines = []
|
||||
out_lines.append({
|
||||
"type": "user",
|
||||
"message": {
|
||||
"content": f"[session: {title} | {cwd} | {date_str} | source: pi]"
|
||||
},
|
||||
})
|
||||
|
||||
for e in entries:
|
||||
t = e.get("type")
|
||||
if t == "message":
|
||||
msg = e.get("message") or {}
|
||||
role = msg.get("role")
|
||||
if role == "user":
|
||||
text, _, _ = extract_text(msg.get("content"))
|
||||
if text.strip():
|
||||
out_lines.append({"type": "user", "message": {"content": text}})
|
||||
elif role == "assistant":
|
||||
text, tool_uses, _ = extract_text(msg.get("content"))
|
||||
assistant_chars += len(text.strip())
|
||||
blocks = []
|
||||
if text.strip():
|
||||
blocks.append({"type": "text", "text": text})
|
||||
blocks.extend(tool_uses)
|
||||
if not blocks:
|
||||
continue
|
||||
# Simplify single-text to string (matches mempalace-session).
|
||||
if len(blocks) == 1 and blocks[0].get("type") == "text":
|
||||
content = blocks[0]["text"]
|
||||
else:
|
||||
content = blocks
|
||||
out_lines.append({"type": "assistant", "message": {"content": content}})
|
||||
elif role == "toolResult":
|
||||
text, _, _ = extract_text(msg.get("content"))
|
||||
tool_id = msg.get("toolCallId") or ""
|
||||
if not tool_id:
|
||||
continue
|
||||
out_lines.append({
|
||||
"type": "human",
|
||||
"message": {
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_id,
|
||||
"content": text or "(no output)",
|
||||
}],
|
||||
},
|
||||
})
|
||||
elif role == "bashExecution":
|
||||
# Rendered as a synthetic assistant annotation so the
|
||||
# command + output stay associated with the surrounding turn.
|
||||
cmd = msg.get("command") or ""
|
||||
out = msg.get("output") or ""
|
||||
exit_code = msg.get("exitCode")
|
||||
note = f"[user-bash] $ {cmd}\nexit={exit_code}\n{out}".strip()
|
||||
if note:
|
||||
out_lines.append({"type": "user", "message": {"content": note}})
|
||||
elif role == "custom":
|
||||
if not msg.get("display"):
|
||||
continue
|
||||
text, _, _ = extract_text(msg.get("content"))
|
||||
if text.strip():
|
||||
ctype = msg.get("customType") or "custom"
|
||||
out_lines.append({
|
||||
"type": "user",
|
||||
"message": {"content": f"[custom:{ctype}] {text}"},
|
||||
})
|
||||
elif role in ("branchSummary", "compactionSummary"):
|
||||
summary = msg.get("summary") or ""
|
||||
if summary.strip():
|
||||
out_lines.append({
|
||||
"type": "user",
|
||||
"message": {"content": f"[{role}] {summary}"},
|
||||
})
|
||||
# thinking-only / empty messages silently dropped
|
||||
elif t in (
|
||||
"model_change", "thinking_level_change", "compaction",
|
||||
"branch_summary", "label", "session_info", "custom",
|
||||
"custom_message",
|
||||
):
|
||||
# Non-conversational entries: drop. (custom_message with
|
||||
# display=true could be included but we already get it via the
|
||||
# "custom" message role above when pi materializes one.)
|
||||
continue
|
||||
|
||||
# Need at least 2 turns (header + one real turn) for the normalizer.
|
||||
if len(out_lines) < 2:
|
||||
skipped_short += 1
|
||||
continue
|
||||
|
||||
# Assistant *text* volume, tool results excluded: the signal that the
|
||||
# session actually did something, independent of how much injected
|
||||
# skill/context text inflated the user side.
|
||||
if assistant_chars < min_assistant_chars:
|
||||
skipped_quiet += 1
|
||||
print(
|
||||
f" [QUIET] {path.name} ({turn_count} turns, {assistant_chars} assistant chars)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
|
||||
out_path = stage / f"pi_{session_uuid}.jsonl"
|
||||
with out_path.open("w", encoding="utf-8") as f:
|
||||
for obj in out_lines:
|
||||
f.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
||||
|
||||
# Preserve session mtime on the staging file for dedup stability.
|
||||
try:
|
||||
os.utime(out_path, (mtime, mtime))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
exported += 1
|
||||
if already_filed is None:
|
||||
is_filed = False # unknowable here; the palace host decides
|
||||
status = "? "
|
||||
else:
|
||||
is_filed = str(out_path) in already_filed
|
||||
if is_filed:
|
||||
skipped_already_filed += 1
|
||||
status = "SKIP" if is_filed else "NEW "
|
||||
print(f" [{status}] {out_path.name} ({turn_count} turns)", file=sys.stderr)
|
||||
|
||||
print(f"EXPORTED {exported}")
|
||||
print(f"ALREADY_FILED {-1 if already_filed is None else skipped_already_filed}")
|
||||
if skipped_short:
|
||||
print(f"SKIPPED_SHORT {skipped_short}", file=sys.stderr)
|
||||
if skipped_quiet:
|
||||
print(f"SKIPPED_QUIET {skipped_quiet}", file=sys.stderr)
|
||||
if skipped_malformed:
|
||||
print(f"SKIPPED_MALFORMED {skipped_malformed}", file=sys.stderr)
|
||||
PY
|
||||
)
|
||||
|
||||
# Parse counts from stdout
|
||||
count="$(printf '%s\n' "$export_count" | awk '/^EXPORTED / { print $2 }')"
|
||||
count="${count:-0}"
|
||||
already_filed="$(printf '%s\n' "$export_count" | awk '/^ALREADY_FILED / { print $2 }')"
|
||||
already_filed="${already_filed:-0}"
|
||||
# -1 means "unknown" (remote mode), so guard the arithmetic.
|
||||
if [[ "$already_filed" -lt 0 ]]; then to_file="$count"; else to_file=$(( count - already_filed )); fi
|
||||
|
||||
if [[ "$count" -eq 0 ]]; then
|
||||
echo "no sessions qualified for export"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Exported $count session(s) to $STAGE"
|
||||
if [[ "$already_filed" -lt 0 ]]; then
|
||||
# Remote mode: dedup lives on the palace host, keyed on the remote inbox
|
||||
# path. Do not translate "unknown" into a number.
|
||||
to_file="$count"
|
||||
echo " all $count shipped → the palace host dedups by source_file (remote mode:"
|
||||
echo " this machine cannot preview what it already holds)"
|
||||
else
|
||||
echo " $to_file new → will be filed on mine"
|
||||
echo " $already_filed already filed → will be skipped (dedup by source_file)"
|
||||
fi
|
||||
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
echo ""
|
||||
if [[ "$already_filed" -lt 0 ]]; then
|
||||
echo "--dry-run: skipping ship+mine. A real run would ship $count session(s) to"
|
||||
echo " ${SSH_TARGET%/}/$DEVICE/ and let the palace host dedup them."
|
||||
elif [[ "$to_file" -eq 0 ]]; then
|
||||
echo "--dry-run: no new sessions to mine. A real run would skip all $count."
|
||||
else
|
||||
echo "--dry-run: skipping mine step. A real run would file $to_file new session(s)."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Ship to the palace host (remote mode only) ───────────────────────
|
||||
# mempalace_mine expands its source path in the SERVER process, so in remote
|
||||
# mode the exports have to physically exist over there. rsync --update is the
|
||||
# idempotent half; the mine is the other half.
|
||||
MINE_SOURCE="$STAGE"
|
||||
if [[ "$MODE" == "remote" ]]; then
|
||||
ssh_cmd="ssh"
|
||||
[[ -n "$SSH_CONFIG" ]] && ssh_cmd="ssh -F $SSH_CONFIG"
|
||||
echo ""
|
||||
echo "Shipping stage to ${SSH_TARGET%/}/$DEVICE/ ..."
|
||||
if ! rsync -a --update --no-owner --no-group \
|
||||
-e "$ssh_cmd" \
|
||||
--include='*.jsonl' --exclude='*' \
|
||||
"$STAGE/" "${SSH_TARGET%/}/$DEVICE/"; then
|
||||
echo "error: rsync to ${SSH_TARGET%/}/$DEVICE/ failed" >&2
|
||||
exit 5
|
||||
fi
|
||||
MINE_SOURCE="${REMOTE_PATH%/}/$DEVICE"
|
||||
fi
|
||||
|
||||
# ── Phase boundary ───────────────────────────────────────────────────
|
||||
# --prepare hands the source path to the caller (the pi mempalace extension),
|
||||
# which mines it through the MCP client that already holds the palace.
|
||||
if [[ $PREPARE_ONLY -eq 1 ]]; then
|
||||
echo ""
|
||||
printf 'MINE_SOURCE=%s\n' "$MINE_SOURCE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Run the mine ─────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Mining into wing '$WING'..."
|
||||
if [[ "$MODE" == "remote" ]]; then
|
||||
if ! run_remote_mine "$REMOTE_URL" "$REMOTE_TOKEN" "$MINE_SOURCE" "$WING" "$AGENT"; then
|
||||
echo "error: remote mine failed" >&2
|
||||
exit 5
|
||||
fi
|
||||
else
|
||||
# Capture output so palace-level contention can be told apart from a real
|
||||
# failure. A live pi session holds the palace through its own mempalace-mcp,
|
||||
# and that session's extension mines what we just staged — so contention
|
||||
# means "already handled", not "broken".
|
||||
set +e
|
||||
mine_out="$(mempalace mine "$MINE_SOURCE" --mode convos --wing "$WING" --agent "$AGENT" 2>&1)"
|
||||
mine_rc=$?
|
||||
set -e
|
||||
printf '%s\n' "$mine_out"
|
||||
if [[ $mine_rc -ne 0 ]]; then
|
||||
if printf '%s' "$mine_out" | grep -q "is held by"; then
|
||||
echo ""
|
||||
echo "palace is held by a live session; it will mine the staged exports itself"
|
||||
exit 0
|
||||
fi
|
||||
echo "error: mempalace mine failed" >&2
|
||||
exit 4
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Repair index ─────────────────────────────────────────────────────
|
||||
if [[ $DO_REPAIR -eq 1 ]]; then
|
||||
echo ""
|
||||
echo "WARNING: --repair runs an in-place HNSW rebuild that has wiped"
|
||||
echo " live palaces on past runs. Proceeding in 3 seconds..."
|
||||
sleep 3
|
||||
echo "Rebuilding HNSW index..."
|
||||
mempalace repair --yes
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done. Wing '$WING' updated. Remember to reconnect any live MCP sessions."
|
||||
+69
-13
@@ -10,13 +10,26 @@
|
||||
# Strategy:
|
||||
# 1. Read opencode.db and export each qualifying session to a Claude Code
|
||||
# JSONL file (format the mempalace normalizer already understands).
|
||||
# 2. Stage exports under ~/.cache/mempalace-session/<wing>/.
|
||||
# 2. Stage exports under <palace-root>/opencode-stage/<wing>/ (override with
|
||||
# MEMPALACE_SESSION_STAGE).
|
||||
# 3. Run `mempalace mine --mode convos` against the staging dir.
|
||||
#
|
||||
# Dedup: mempalace convos mode keys on source_file (absolute staging path).
|
||||
# The staging path is deterministic (per-wing under XDG_CACHE_HOME) so re-runs
|
||||
# The staging path is deterministic (per-wing under the palace root) so re-runs
|
||||
# are idempotent as long as session content hasn't changed.
|
||||
#
|
||||
# Staging location: because dedup keys on the staged path, wiping the stage
|
||||
# leaves the palace holding drawers whose source files look deleted, and
|
||||
# `mempalace sync` prunes exactly those when they fall inside the scope it is
|
||||
# given. The stage therefore lives next to the palace it feeds (resolved via
|
||||
# $MEMPALACE_PALACE_PATH → $MEMPAL_PALACE_PATH → ~/.mempalace/config.json →
|
||||
# ~/.mempalace/palace), so stage and dedup keys share one lifetime and the
|
||||
# dangerous state — palace survives, stage does not — cannot be reached by
|
||||
# wiping something that merely looks disposable. It used to default under
|
||||
# ~/.cache, which is disposable on exactly the hosts where this runs
|
||||
# unattended. Override with MEMPALACE_SESSION_STAGE only if the target is at
|
||||
# least as durable as the palace.
|
||||
#
|
||||
# Session filter: sessions with fewer than --min-messages messages (default 3)
|
||||
# are skipped to avoid filing throwaway /exit'd sessions.
|
||||
#
|
||||
@@ -47,7 +60,7 @@ SESSION_ID=""
|
||||
SINCE=""
|
||||
MIN_MESSAGES=3
|
||||
DRY_RUN=0
|
||||
NO_REPAIR=0
|
||||
DO_REPAIR=0
|
||||
OPENCODE_DB="${OPENCODE_DB:-$HOME/.local/share/opencode/opencode.db}"
|
||||
|
||||
# ── Usage ────────────────────────────────────────────────────────────
|
||||
@@ -69,7 +82,13 @@ Options:
|
||||
--dry-run Export + list; do not mine into palace. Each session
|
||||
is tagged [NEW] or [SKIP] based on whether its
|
||||
source_file is already present in the palace.
|
||||
--no-repair Skip `mempalace repair` after mining
|
||||
--repair Run `mempalace repair` after mining (opt-in).
|
||||
WARNING: repair does a destructive in-place HNSW
|
||||
rebuild. If it races a live MCP connection or
|
||||
crashes mid-rebuild, it can wipe the collection.
|
||||
Only pass this from a quiet, interactive context.
|
||||
Not safe for unattended cron/launchd schedules.
|
||||
--no-repair (Deprecated; no-repair is now the default.)
|
||||
-h, --help Show this help
|
||||
|
||||
Idempotency:
|
||||
@@ -81,12 +100,12 @@ Idempotency:
|
||||
|
||||
What gets mined:
|
||||
- Each qualifying session → one Claude Code JSONL file
|
||||
- Staged under ~/.cache/mempalace-session/<wing>/
|
||||
- Staged under <palace-root>/opencode-stage/<wing>/
|
||||
- Filed via `mempalace mine --mode convos`
|
||||
|
||||
Transcript shape per session:
|
||||
- Synthetic header as first user turn:
|
||||
[session: <title> | <directory> | <YYYY-MM-DD>]
|
||||
[session: <title> | <directory> | <YYYY-MM-DD> | source: opencode]
|
||||
- User/assistant messages extracted from message.data + part.data
|
||||
- Tool calls → Claude Code `tool_use` blocks
|
||||
- Tool outputs → `tool_result` blocks (folded into the assistant turn by the
|
||||
@@ -97,7 +116,9 @@ Transcript shape per session:
|
||||
Dedup:
|
||||
- source_file = absolute staging path (deterministic per session ID)
|
||||
- Re-runs skip unchanged sessions. To force re-mining, delete the staging
|
||||
dir: rm -rf ~/.cache/mempalace-session/<wing>/
|
||||
dir: rm -rf <palace-root>/opencode-stage/<wing>/
|
||||
(that only forces a refile; do NOT run `mempalace sync` while the stage is
|
||||
missing, or the drawers mined from it get pruned instead)
|
||||
|
||||
Rationale:
|
||||
Opencode lacks a session-stopping hook (upstream PRs #16598, #16769 still
|
||||
@@ -117,7 +138,8 @@ while [[ $# -gt 0 ]]; do
|
||||
--agent) AGENT="${2:-}"; shift 2 ;;
|
||||
--db) OPENCODE_DB="${2:-}"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--no-repair) NO_REPAIR=1; shift ;;
|
||||
--repair) DO_REPAIR=1; shift ;;
|
||||
--no-repair) shift ;; # deprecated alias; no-repair is the default
|
||||
--) shift; break ;;
|
||||
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 1 ;;
|
||||
*) echo "error: unexpected arg: $1" >&2; exit 1 ;;
|
||||
@@ -140,8 +162,31 @@ if ! [[ "$MIN_MESSAGES" =~ ^[0-9]+$ ]]; then
|
||||
fi
|
||||
|
||||
# ── Staging dir ──────────────────────────────────────────────────────
|
||||
# Deterministic per-wing path so source_file dedup works across re-runs.
|
||||
CACHE_ROOT="${XDG_CACHE_HOME:-$HOME/.cache}/mempalace-session"
|
||||
# Deterministic per-wing path so source_file dedup works across re-runs, and
|
||||
# anchored to the palace root so the stage cannot be wiped independently of the
|
||||
# dedup keys that reference it (see "Staging location" in the header).
|
||||
# Mirrors mempalace config.py:palace_path() precedence, then takes the parent.
|
||||
palace_root() {
|
||||
python3 - <<'PY' 2>/dev/null || echo "$HOME/.mempalace"
|
||||
import json, os
|
||||
p = os.environ.get("MEMPALACE_PALACE_PATH") or os.environ.get("MEMPAL_PALACE_PATH")
|
||||
if p:
|
||||
p = os.path.abspath(os.path.expanduser(p))
|
||||
else:
|
||||
cfg = os.path.expanduser("~/.mempalace/config.json")
|
||||
p = None
|
||||
if os.path.exists(cfg):
|
||||
try:
|
||||
with open(cfg) as fh:
|
||||
v = json.load(fh).get("palace_path")
|
||||
p = os.path.expanduser(v) if v else None
|
||||
except Exception:
|
||||
p = None
|
||||
p = p or os.path.expanduser("~/.mempalace/palace")
|
||||
print(os.path.dirname(p.rstrip("/")))
|
||||
PY
|
||||
}
|
||||
CACHE_ROOT="${MEMPALACE_SESSION_STAGE:-$(palace_root)/opencode-stage}"
|
||||
STAGE="$CACHE_ROOT/$WING"
|
||||
mkdir -p "$STAGE"
|
||||
|
||||
@@ -178,7 +223,15 @@ if since:
|
||||
# isn't reachable (first install, moved, permission-denied), we fall through
|
||||
# to "everything is new" — the mine step will do the real dedup anyway.
|
||||
already_filed = set()
|
||||
palace_path = os.environ.get("MEMPALACE_PATH", os.path.expanduser("~/.mempalace/palace"))
|
||||
# Mirror mempalace's own resolution order (config.py): MEMPALACE_PALACE_PATH,
|
||||
# then the legacy MEMPAL_PALACE_PATH, then the default. NOT "MEMPALACE_PATH" —
|
||||
# that name is not a mempalace concept, and reading it silently degraded this
|
||||
# NEW/SKIP preview to "everything is new" wherever some other tool had set it.
|
||||
palace_path = (
|
||||
os.environ.get("MEMPALACE_PALACE_PATH")
|
||||
or os.environ.get("MEMPAL_PALACE_PATH")
|
||||
or os.path.expanduser("~/.mempalace/palace")
|
||||
)
|
||||
chroma_db = Path(palace_path) / "chroma.sqlite3"
|
||||
if chroma_db.is_file():
|
||||
try:
|
||||
@@ -250,7 +303,7 @@ for sess in sessions:
|
||||
date_str = datetime.fromtimestamp(
|
||||
sess["time_created"] / 1000, tz=timezone.utc
|
||||
).strftime("%Y-%m-%d")
|
||||
header = f"[session: {title} | {directory} | {date_str}]"
|
||||
header = f"[session: {title} | {directory} | {date_str} | source: opencode]"
|
||||
out_lines.append({"type": "user", "message": {"content": header}})
|
||||
|
||||
for msg in messages:
|
||||
@@ -392,8 +445,11 @@ if ! mempalace mine "$STAGE" --mode convos --wing "$WING" --agent "$AGENT"; then
|
||||
fi
|
||||
|
||||
# ── Repair index ─────────────────────────────────────────────────────
|
||||
if [[ $NO_REPAIR -eq 0 ]]; then
|
||||
if [[ $DO_REPAIR -eq 1 ]]; then
|
||||
echo ""
|
||||
echo "WARNING: --repair runs an in-place HNSW rebuild that has wiped"
|
||||
echo " live palaces on past runs. Proceeding in 3 seconds..."
|
||||
sleep 3
|
||||
echo "Rebuilding HNSW index..."
|
||||
mempalace repair --yes
|
||||
fi
|
||||
|
||||
+114
-10
@@ -1,14 +1,113 @@
|
||||
# contrib/ — automation recipes for `mempalace-session`
|
||||
# contrib/ — automation recipes for `mempalace-session` and `mempalace-pi-session`
|
||||
|
||||
Manual invocation of `mempalace-session` is fine on a machine you actively drive. For long-running devboxes, a weekly automated mine keeps the palace fresh without thinking about it. This directory ships ready-to-use templates for two common scheduling mechanisms.
|
||||
Manual invocation of the session-mining wrappers is fine on a machine you actively drive. For long-running devboxes, a weekly automated mine keeps the palace fresh without thinking about it. This directory ships ready-to-use templates for two common scheduling mechanisms, for each wrapper — plus `systemd/mempalace-serve.service`, which is not a mining job at all but the **shared-palace server** ([its own section below](#mempalace-serveservice--the-shared-palace-server)).
|
||||
|
||||
> **pi machines: check whether you need this at all.** If the pi bridge
|
||||
> extension (`extensions/pi/mempalace.ts`) is installed **and is ≥ `29e660e`
|
||||
> (2026-08-12)**, it already feeds the
|
||||
> palace by itself on `session_shutdown` and a debounced `agent_settled` —
|
||||
> see [`extensions/pi/README.md` § Automatic transcript feeding](../extensions/pi/README.md#automatic-transcript-feeding).
|
||||
> ⚠️ **"Installed" is not enough** — the pre-`29e660e` extension has no feed
|
||||
> path at all, and a container image baked before that date ships exactly that
|
||||
> copy. Check the *deployed* file, not the repo clone:
|
||||
> `grep -c MEMPALACE_FEED "$(readlink -f ~/.pi/agent/extensions/mempalace.ts)"`
|
||||
> — zero means the templates below are **not** optional on that machine. As of
|
||||
> 2026-08-14 the entire pi-devbox fleet returns zero.
|
||||
> The templates below were written when scheduling was the *only* path for
|
||||
> both harnesses; that's still true for **opencode** (no such extension
|
||||
> exists), but for pi they're now a fallback — useful for a bare pi install
|
||||
> without the bridge, a host-level catch-up job, or belt-and-braces coverage
|
||||
> of a hard container kill (the extension's triggers don't fire on `SIGKILL`).
|
||||
|
||||
> **Before using either**: confirm the toolkit is installed and the wrapper works —
|
||||
> `mempalace-session --dry-run` should list qualifying sessions. If that errors, fix the install before scheduling.
|
||||
> `mempalace-session --dry-run` (and/or `mempalace-pi-session --dry-run`) should list qualifying sessions. If that errors, fix the install before scheduling.
|
||||
|
||||
Pick **one**. Running both would double-mine (harmless — dedup skips everything on the second run — but wastes wall time on the HNSW repair).
|
||||
Pick **one scheduler** (systemd *or* launchd *or* cron). The opencode and pi jobs can be installed side by side and staggered — templates ship with Mon 03:00 for opencode, Tue 03:00 for pi to avoid racing the post-mine HNSW repair.
|
||||
|
||||
## Templates at a glance
|
||||
|
||||
| File | What it schedules | When |
|
||||
|---|---|---|
|
||||
| `systemd/mempalace-session.{service,timer}` | opencode → palace | Mon 03:00 |
|
||||
| `systemd/mempalace-pi-session.{service,timer}` | pi → palace | Tue 03:00 |
|
||||
| `systemd/mempalace-session-devbox.{service,timer}` | opencode (inside a devbox container) → palace | Mon 03:00 |
|
||||
| `launchd/se.jordbo.mempalace-session.plist` | opencode → palace (macOS) | Mon 03:00 |
|
||||
| `launchd/se.jordbo.mempalace-pi-session.plist` | pi → palace (macOS) | Tue 03:00 |
|
||||
| `cron/mempalace-session.cron` | opencode → palace | Mon 03:00 |
|
||||
| `cron/mempalace-pi-session.cron` | pi → palace | Tue 03:00 |
|
||||
| `cron/mempalace-session-devbox.cron` | opencode (devbox) → palace | Mon 03:00 |
|
||||
| `systemd/mempalace-serve.service` | **not a mining job** — runs the shared palace *server* | always-on |
|
||||
|
||||
The pi variants are drop-in copies of the opencode variants with script name and schedule updated; the install recipes below apply equally — just swap `mempalace-session` for `mempalace-pi-session` and the schedule day.
|
||||
|
||||
---
|
||||
|
||||
## `mempalace-serve.service` — the shared palace server
|
||||
|
||||
The odd one out in this directory: every other template *feeds* a palace on a schedule, this one
|
||||
**serves** a palace over HTTP so several machines can share it (RFC-001). **This unit currently runs
|
||||
the fleet primary on `synlig`** — serving since 2026-08-12, seeded 2026-08-14, and the palace behind
|
||||
it is the only copy. Read [`docs/rfc-001-global-palace.md`](../docs/rfc-001-global-palace.md) and
|
||||
[`docs/phase-1-exposure-runbook.md`](../docs/phase-1-exposure-runbook.md) before installing a second one.
|
||||
|
||||
It is a **user** unit (`systemctl --user`), so it dies with your login session unless lingering is
|
||||
enabled — that is the one `sudo` this recipe needs:
|
||||
|
||||
```sh
|
||||
# Install
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp contrib/systemd/mempalace-serve.service ~/.config/systemd/user/
|
||||
|
||||
sudo loginctl enable-linger "$USER" # else the server stops when you log out
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now mempalace-serve
|
||||
|
||||
# Verify — both lines matter
|
||||
curl -s 172.17.0.1:8765/healthz # -> ok
|
||||
curl -s localhost:8765/healthz # -> connection refused (exit 7), and that is CORRECT
|
||||
|
||||
# Logs
|
||||
journalctl --user -u mempalace-serve -f
|
||||
```
|
||||
|
||||
The unit refuses to start if `~/.mempalace/palace` does not exist (`ConditionPathExists`) — better a
|
||||
clear failure than a server quietly creating an empty palace somewhere unexpected.
|
||||
|
||||
**Why it binds `172.17.0.1` and not loopback**, since this looks backwards and is the single most
|
||||
load-bearing line in the file: mempalace pins the HTTP `Host` header to loopback literals *only on a
|
||||
loopback bind*, so a `127.0.0.1` server behind a reverse proxy 403s every proxied request — **and**
|
||||
token auto-minting is gated on the bind being non-loopback, so the "safe-looking" loopback bind starts
|
||||
with **no authentication at all and no warning**. `172.17.0.1` is the docker0 gateway: non-loopback
|
||||
(so the pin relaxes and a token is minted), reachable from this host and its containers, not from the
|
||||
LAN. Point the tunnel/proxy at **`http://172.17.0.1:8765`** — not `https://` — because there is no
|
||||
`--tls-cert` here; TLS belongs at the proxy. Targeting `https://` yields 502 from outside while local
|
||||
curl still says `ok`.
|
||||
|
||||
**The token.** There is deliberately no `--token` in the unit (units are world-readable). `serve`
|
||||
mints or reuses a `0600` token at `~/.mempalace/server/<sha256-prefix-of-palace-path>/token`, stable
|
||||
across restarts. Read it from there to configure clients.
|
||||
|
||||
> ⚠️ **Uninstall is where this unit differs from every other template here.** Stopping it is safe;
|
||||
> deleting its data is not.
|
||||
>
|
||||
> ```sh
|
||||
> systemctl --user disable --now mempalace-serve
|
||||
> rm ~/.config/systemd/user/mempalace-serve.service && systemctl --user daemon-reload
|
||||
> ```
|
||||
>
|
||||
> **Do not `rm -rf ~/.mempalace` on a host that has served the fleet.** That tree holds the shared
|
||||
> palace *and* the only copy of the bearer token every client authenticates with. For the same reason,
|
||||
> **never `rsync --delete` into `~/.mempalace`** — the token lives inside the tree you would be
|
||||
> syncing. And note palace directories cannot simply be moved: the directory name is a sha256 prefix
|
||||
> of its own path.
|
||||
>
|
||||
> Clients **fail closed** when this unit is down — they lose their palace tools entirely rather than
|
||||
> falling back to a local palace — so stopping it is visible, reversible, and loses no data.
|
||||
|
||||
Operational note: the server serializes every request behind one lock, so a wedged process is a
|
||||
fleet-wide outage. Hence `Restart=on-failure` with `TimeoutStopSec=30` — fail fast and let systemd
|
||||
recover it.
|
||||
|
||||
## systemd user timer (recommended on modern Linux)
|
||||
|
||||
**Why:** runs without the user logged in (with `loginctl enable-linger`), survives reboots, logs to `journalctl`, Persistent=true catches missed runs after the machine was off. No root required — it's a *user* unit.
|
||||
@@ -156,17 +255,22 @@ rm /tmp/mempalace-session.cron
|
||||
crontab -l | grep mempalace
|
||||
```
|
||||
|
||||
Ensure `~/.cache/mempalace-session/` exists so the log file can be written:
|
||||
Ensure `~/.cache/mempalace-logs/` exists so the log file can be written:
|
||||
|
||||
> This is the **log** directory only. The staging dir — the transcripts the
|
||||
> palace keys its `source_file` dedup on — lives beside the palace
|
||||
> (`<palace-root>/opencode-stage/`), not in `~/.cache`, precisely so it cannot
|
||||
> be cleaned away while the palace survives. Logs here are disposable.
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.cache/mempalace-session
|
||||
mkdir -p ~/.cache/mempalace-logs
|
||||
```
|
||||
|
||||
**Verify a run is happening:**
|
||||
|
||||
```bash
|
||||
# Tail the log the cron entry writes to
|
||||
tail -f ~/.cache/mempalace-session/cron.log
|
||||
tail -f ~/.cache/mempalace-logs/cron.log
|
||||
|
||||
# Or force a run manually to prove the command is well-formed
|
||||
mempalace-session
|
||||
@@ -267,11 +371,11 @@ cat contrib/cron/mempalace-session-devbox.cron
|
||||
(crontab -l 2>/dev/null; cat contrib/cron/mempalace-session-devbox.cron) | crontab -
|
||||
|
||||
# Ensure the log directory exists
|
||||
mkdir -p ~/.cache/mempalace-session
|
||||
mkdir -p ~/.cache/mempalace-logs
|
||||
|
||||
# Verify
|
||||
crontab -l | grep mempalace-session-devbox
|
||||
tail -f ~/.cache/mempalace-session/cron-devbox.log
|
||||
tail -f ~/.cache/mempalace-logs/cron-devbox.log
|
||||
```
|
||||
|
||||
**Uninstall:**
|
||||
@@ -303,7 +407,7 @@ The in-container mempalace sees only the container's opencode.db and palace (via
|
||||
- Dedup is free on unchanged sessions, so there's no cost to running daily other than the ~5 min post-mine repair.
|
||||
- Weekly keeps the palace fresh enough that searches almost always return current context.
|
||||
|
||||
**Daily or more:** edit `OnCalendar=` or the cron DOW field. On a daily schedule, add `--no-repair` to the wrapper invocation and let a separate weekly unit handle repair — otherwise you repair 7× more often than you need.
|
||||
**Scheduling cadence:** edit `OnCalendar=` or the cron DOW field. Post-mine repair is now **opt-in** (`--repair`) and should NOT be added to unattended schedules — the in-place HNSW rebuild has wiped live palaces on past runs. Run `mempalace repair` manually from a quiet interactive session if you ever need it.
|
||||
|
||||
**Monthly:** probably too infrequent. You'll search for "that thing we discussed last Tuesday" and miss it.
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Sample crontab entry for mempalace-pi-session.
|
||||
#
|
||||
# Runs a full pi → MemPalace mine weekly (Tuesdays at 03:00 local).
|
||||
# Staggered from mempalace-session.cron (Mondays) so if both are installed
|
||||
# they don't race the post-mine HNSW repair step.
|
||||
#
|
||||
# To install:
|
||||
# (crontab -l 2>/dev/null; cat contrib/cron/mempalace-pi-session.cron) | crontab -
|
||||
#
|
||||
# To remove, edit your crontab:
|
||||
# crontab -e
|
||||
#
|
||||
# Replace USER with your actual username.
|
||||
|
||||
PATH=/home/USER/.local/bin:/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
# m h dom mon dow command
|
||||
0 3 * * 2 mempalace-pi-session >> /home/USER/.cache/mempalace-pi-session/cron.log 2>&1
|
||||
@@ -35,4 +35,4 @@ CONTAINER_USER=developer
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
# m h dom mon dow command
|
||||
0 3 * * 1 /bin/sh -c 'docker ps --filter "name=^/${CONTAINER}$" --filter "status=running" -q | grep -q . && docker exec -u "${CONTAINER_USER}" "${CONTAINER}" mempalace-session >> "$HOME/.cache/mempalace-session/cron-devbox.log" 2>&1'
|
||||
0 3 * * 1 /bin/sh -c 'docker ps --filter "name=^/${CONTAINER}$" --filter "status=running" -q | grep -q . && docker exec -u "${CONTAINER_USER}" "${CONTAINER}" mempalace-session >> "$HOME/.cache/mempalace-logs/cron-devbox.log" 2>&1'
|
||||
|
||||
@@ -16,4 +16,4 @@
|
||||
PATH=/home/USER/.local/bin:/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
# m h dom mon dow command
|
||||
0 3 * * 1 mempalace-session >> /home/USER/.cache/mempalace-session/cron.log 2>&1
|
||||
0 3 * * 1 mempalace-session >> /home/USER/.cache/mempalace-logs/cron.log 2>&1
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!--
|
||||
se.jordbo.mempalace-pi-session.plist — macOS launchd user agent
|
||||
that mines pi coding-agent session history into MemPalace weekly.
|
||||
|
||||
Template: replace USER with your macOS short username before installing.
|
||||
The install recipe in contrib/README.md does this for you via `sed`.
|
||||
|
||||
Parity with contrib/systemd/mempalace-pi-session.{service,timer}:
|
||||
- Weekly Tue 03:00 local time → StartCalendarInterval below.
|
||||
(Staggered from the opencode one, which runs Mon 03:00.)
|
||||
- Low-priority background I/O → ProcessType=Background + LowPriorityIO.
|
||||
- Single-instance guard → launchd refuses to start a second copy of
|
||||
the same Label while one is running.
|
||||
- "Skip if pi has never been used" → no native equivalent.
|
||||
mempalace-pi-session exits cleanly (zero drawers filed, fast) when
|
||||
~/.pi/agent/sessions is absent, so no guard is strictly needed.
|
||||
-->
|
||||
|
||||
<key>Label</key>
|
||||
<string>se.jordbo.mempalace-pi-session</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Users/USER/.local/bin/mempalace-pi-session</string>
|
||||
</array>
|
||||
|
||||
<!-- launchd gives agents a minimal PATH. mempalace-pi-session invokes
|
||||
`mempalace` and `python3`, both must resolve. -->
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/Users/USER/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
<key>HOME</key>
|
||||
<string>/Users/USER</string>
|
||||
</dict>
|
||||
|
||||
<!-- Weekly, Tuesday 03:00 local time. Staggered from the opencode job
|
||||
(Mon 03:00) so a machine with both installed won't race the HNSW
|
||||
repair step. -->
|
||||
<key>StartCalendarInterval</key>
|
||||
<dict>
|
||||
<key>Weekday</key>
|
||||
<integer>2</integer>
|
||||
<key>Hour</key>
|
||||
<integer>3</integer>
|
||||
<key>Minute</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<false/>
|
||||
|
||||
<key>ProcessType</key>
|
||||
<string>Background</string>
|
||||
<key>LowPriorityIO</key>
|
||||
<true/>
|
||||
<key>Nice</key>
|
||||
<integer>10</integer>
|
||||
|
||||
<!-- Runaway guard. Pi corpora are typically smaller than opencode's
|
||||
(short tactical sessions), so 2h is generous. -->
|
||||
<key>ExitTimeOut</key>
|
||||
<integer>7200</integer>
|
||||
|
||||
<!-- Logs. Tail with:
|
||||
tail -f ~/Library/Logs/mempalace-pi-session.log -->
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/USER/Library/Logs/mempalace-pi-session.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/USER/Library/Logs/mempalace-pi-session.err.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Mine pi coding-agent session history into MemPalace
|
||||
Documentation=https://gitea.jordbo.se/joakimp/mempalace-toolkit
|
||||
# Only run if pi has actually been used (avoids noise on idle machines)
|
||||
ConditionPathExists=%h/.pi/agent/sessions
|
||||
# Don't start if a previous run is still going
|
||||
ConditionPathExists=!%t/mempalace-pi-session.lock
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# The wrapper writes to ~/.cache/mempalace-pi-session/ and the palace.
|
||||
# Keep stdout/stderr in the journal — inspect with:
|
||||
# journalctl --user -u mempalace-pi-session --since today
|
||||
ExecStart=%h/.local/bin/mempalace-pi-session
|
||||
# Belt-and-braces lock so two overlapping runs can't corrupt staging
|
||||
ExecStartPre=/bin/sh -c 'touch %t/mempalace-pi-session.lock'
|
||||
ExecStopPost=/bin/sh -c 'rm -f %t/mempalace-pi-session.lock'
|
||||
# Protect against runaway runs. Pi sessions tend to be short/tactical so the
|
||||
# corpus is much smaller than opencode's; 2h is generous headroom.
|
||||
TimeoutStartSec=7200
|
||||
# Low priority — this is background maintenance
|
||||
Nice=10
|
||||
IOSchedulingClass=idle
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Weekly pi → MemPalace session mine
|
||||
Documentation=https://gitea.jordbo.se/joakimp/mempalace-toolkit
|
||||
|
||||
[Timer]
|
||||
# Every Tuesday at 03:00 local time. Staggered from mempalace-session.timer
|
||||
# (Mon 03:00) so if both are installed, the HNSW repair step in one doesn't
|
||||
# race the repair step in the other.
|
||||
# Use `systemctl --user list-timers mempalace-pi-session.timer` to see next run.
|
||||
OnCalendar=Tue 03:00
|
||||
# If the machine was off at the scheduled time, run at next boot.
|
||||
Persistent=true
|
||||
# Randomize up to 30 minutes to avoid thundering-herd across machines.
|
||||
RandomizedDelaySec=30m
|
||||
AccuracySec=1m
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,67 @@
|
||||
[Unit]
|
||||
Description=MemPalace remote MCP server (the fleet primary — RFC-001)
|
||||
Documentation=https://gitea.jordbo.se/joakimp/mempalace-toolkit
|
||||
Documentation=file:%h/mempalace-toolkit/docs/rfc-001-global-palace.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
# Refuse to start if the palace is missing — better a clear failure than a
|
||||
# server quietly creating an empty palace somewhere unexpected.
|
||||
ConditionPathExists=%h/.mempalace/palace
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# ── Bind address: NOT 127.0.0.1. This is deliberate and load-bearing. ────────
|
||||
# mempalace pins the HTTP Host header to loopback literals *only on a loopback
|
||||
# bind* (mcp_server.py: enforce_host_pin = _http_is_loopback(host)). Behind a
|
||||
# reverse proxy / tunnel that forwards the public hostname, a loopback bind
|
||||
# answers 403 Forbidden. Verified empirically on synlig 2026-08-10:
|
||||
# bind 127.0.0.1 + Host: palace.example.com -> 403
|
||||
# bind 172.17.0.1 + Host: palace.example.com -> 200
|
||||
# 172.17.0.1 is the docker0 gateway: non-loopback (so the pin relaxes), but
|
||||
# reachable only from this host and its containers — so a newt/Pangolin tunnel
|
||||
# container on this box can reach it while the LAN cannot. Use 0.0.0.0 only if
|
||||
# the tunnel does not run in Docker here, and only with the firewall closed.
|
||||
#
|
||||
# The Origin check is NEVER relaxed: a request carrying a non-loopback Origin
|
||||
# is 403 with no override. Fine for MCP clients (they send none); fatal for
|
||||
# browser-based clients.
|
||||
#
|
||||
# No --token here on purpose: for a non-loopback bind, serve reuses or mints a
|
||||
# 0600 token at ~/.mempalace/server/<hash-of-palace-path>/token and keeps it
|
||||
# stable across restarts. Read it from there to configure clients; never paste
|
||||
# it into this unit (units are world-readable).
|
||||
ExecStart=%h/.local/bin/mempalace serve --host 172.17.0.1 --port 8765
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
# The server serializes every request behind one lock, so a wedged process is
|
||||
# a fleet-wide outage. Fail fast and let Restart= recover.
|
||||
TimeoutStopSec=30
|
||||
# Journal: journalctl --user -u mempalace-serve -f
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
# Modest hardening (user units can't do much, but these are free)
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
# ── Enabling (needs one sudo, hence not done by the Phase 0 prep) ───────────
|
||||
# sudo loginctl enable-linger $USER # else the unit dies with your login
|
||||
# systemctl --user daemon-reload
|
||||
# systemctl --user enable --now mempalace-serve
|
||||
# curl -s 172.17.0.1:8765/healthz # -> ok
|
||||
# curl -s localhost:8765/healthz # -> nothing: connection refused, exit 7.
|
||||
# Corrected 2026-08-12 (the earlier "-> 403" here was wrong). With this
|
||||
# docker0-only bind nothing listens on loopback, so the connection is
|
||||
# refused before any header is sent. The 403 above is the *loopback-bind*
|
||||
# case: server on 127.0.0.1 receiving a forwarded foreign Host header.
|
||||
# Refusal is the stronger signal -- it proves loopback/LAN isn't listening.
|
||||
#
|
||||
# ── Reverse-proxy target: use http://, not https:// ──────────────────────────
|
||||
# There is no --tls-cert below, so this server speaks PLAINTEXT HTTP. Point
|
||||
# the tunnel/proxy resource at http://172.17.0.1:8765. Targeting https:// makes
|
||||
# the proxy attempt a TLS handshake against a plaintext listener: 502 from
|
||||
# outside, while curl on 172.17.0.1 still says ok. TLS belongs at the proxy.
|
||||
@@ -8,7 +8,7 @@ ConditionPathExists=!%t/mempalace-session.lock
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# The wrapper writes to ~/.cache/mempalace-session/ and the palace.
|
||||
# The wrapper writes to ~/.cache/mempalace-logs/ and the palace.
|
||||
# Keep stdout/stderr in the journal — inspect with:
|
||||
# journalctl --user -u mempalace-session --since today
|
||||
ExecStart=%h/.local/bin/mempalace-session
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
# Phase 1 exposure — newt on synlig, DNS, and the client auth model
|
||||
|
||||
Companion to [`rfc-001-global-palace.md`](./rfc-001-global-palace.md) (design + decisions) and
|
||||
[`synlig-primary-runbook.md`](./synlig-primary-runbook.md) (what is already installed on the primary).
|
||||
This doc covers only the step the other two leave open: **making the primary reachable** — runbook §4
|
||||
items 2 and 5.
|
||||
|
||||
> **Status 2026-08-14 — DONE. Exposed, seeded, and one client flipped.**
|
||||
> `https://mempalace.jordbo.se/mcp` has been serving since 2026-08-12 (§3.3–§3.6 are all ✅ below).
|
||||
> The palace was seeded 2026-08-14 15:07 from EMB-7KJ4VR4G (14,777 → 14,803 drawers) — whose palace was
|
||||
> itself carried over from the previous work computer **EMB-X1JY06WJ** around 2026-07-06, so the
|
||||
> primary's lineage is EMB-X1JY06WJ → EMB-7KJ4VR4G → synlig by two file-level copies (rfc-001 §4.4
|
||||
> Deviation) — and that machine's pi-devbox container is flipped and **verified end-to-end — see §3.8**, which is the
|
||||
> verification procedure that did not exist when the first flip was performed.
|
||||
> Still outstanding: the transcript feeder is **inert on every deployed image** (§3.7), and §7.6 is
|
||||
> still a hard blocker for the *second* machine to join (§4).
|
||||
|
||||
Original status, kept for the record — it contradicted this file's own ✅ section markers for two days,
|
||||
which is the failure mode a file-top status block invites:
|
||||
|
||||
**Status 2026-08-12 — Pangolin updated on nyvaken (done, yours). newt not yet installed on synlig.
|
||||
Nothing exposed. No client `.env` flipped.**
|
||||
|
||||
Read this before touching Pangolin: three of the four questions this step raises were **already decided**
|
||||
in RFC §6.2 on 2026-08-09, and re-deciding them differently is how the fleet ends up in two states.
|
||||
|
||||
---
|
||||
|
||||
## 1. The four questions, answered
|
||||
|
||||
| Question | Answer | Where it was decided |
|
||||
| --- | --- | --- |
|
||||
| Which port? | **8765**, path **`/mcp`** (liveness: `/healthz`) | `cli.py:2141` default; runbook §2.4 |
|
||||
| What does newt target? | **`172.17.0.1:8765`** (docker0), **never** `127.0.0.1` | RFC §6.2 Transport; runbook §2.4 |
|
||||
| Open, or authenticated? | **Authenticated. The primary is never an open public resource.** | RFC §6.2 Network posture |
|
||||
| Per-device credentials? | **No — Phase 1 ships the single shared bearer token.** Per-device tokens are Phase 4. | RFC §6.2 Authentication |
|
||||
|
||||
### 1.1 Why not per-device users at the proxy
|
||||
|
||||
The instinct — "create a Pangolin user per container, put the credentials in each `.env`, keep the
|
||||
usernames distinct" — is the right *goal* (revocation, attribution) reached through the wrong *layer*,
|
||||
twice over:
|
||||
|
||||
1. **mempalace validates exactly one token.** `hmac.compare_digest(provided, f"Bearer {srv.auth_token}")`
|
||||
(`mcp_server.py:5292-5295`) — there is no user table and no second credential. Per-device HTTP identity
|
||||
is not a configuration you can express today; it is Phase 4 work (a server-side
|
||||
`token → {device_id, scopes}` registry). RFC §6.2 chose the shared token for Phase 1 deliberately:
|
||||
*"iterate more feature rich but more complex solutions over time."*
|
||||
|
||||
2. **Pangolin's HTTP auth is browser-shaped; the clients are not.** SSO login, resource PIN and resource
|
||||
password all assume something that can follow a redirect, render a form and hold a session cookie.
|
||||
Every MemPalace client here is a headless JSON-RPC `POST` with an `Authorization` header — pi's
|
||||
extension, opencode's `type:remote` MCP entry, and `mempalace-pi-session --mode remote`'s
|
||||
`urllib.request.urlopen`. Point those at a user-authenticated resource and they receive a login page
|
||||
where JSON should be. Enabling that protection breaks precisely the clients it is meant to protect.
|
||||
|
||||
So: **Pangolin terminates TLS and nothing more** (RFC §6.2 Transport, decided 2026-08-09). The bearer token
|
||||
is the authentication. This is not "unprotected" — an unauthenticated request to `/mcp` gets a 401 from
|
||||
mempalace itself, verified A4/A5 in runbook §2.4.
|
||||
|
||||
**Consequence to accept consciously** (RFC §6.2, §7.3.2): until Phase 4 the primary **cannot tell devices
|
||||
apart**. `origin_device` is client-asserted and advisory — nothing load-bearing may depend on it, and
|
||||
revoking one laptop means rotating the token everywhere.
|
||||
|
||||
### 1.2 The one place per-device identity *does* exist today
|
||||
|
||||
Remote mode is not only HTTP. `mempalace_mine` expands its source path in the **server** process, so a
|
||||
client's staged transcripts must physically exist on the primary. The feeder therefore ships them over
|
||||
SSH into a **per-device inbox** before asking the server to mine its own copy:
|
||||
|
||||
```sh
|
||||
rsync -a --update -e "$ssh_cmd" "$STAGE/" "${SSH_TARGET%/}/$DEVICE/" # bin/mempalace-pi-session:677-680
|
||||
```
|
||||
|
||||
That SSH key **is** per-device identity, and it is individually revocable (one line out of
|
||||
`authorized_keys`) years before Phase 4 lands. It costs nothing extra, because the mining path needs SSH
|
||||
regardless.
|
||||
|
||||
Two implications people miss:
|
||||
|
||||
- **`mempalace.jordbo.se` alone does not enable mining.** HTTPS covers the read/write tool surface
|
||||
(`search`, `add_drawer`, `diary_write`, `kg_*`) — genuinely useful on its own, and the reason to do this
|
||||
at all. But `--mode remote` also needs `MEMPALACE_PI_SSH_TARGET` reachable. Budget for both paths.
|
||||
- **`DEVICE` defaults to `$(hostname)`** (`bin/mempalace-pi-session:163`). In a container that is the
|
||||
container hostname: either random per recreate (inboxes proliferate; each recreate re-mines into a fresh
|
||||
empty inbox) or identical across sibling devboxes (two containers writing one inbox). **Set
|
||||
`MEMPALACE_PI_DEVICE` explicitly per container.** It is a label, not a secret, so put it somewhere
|
||||
reviewable — a committed compose file — where duplicates are visible. That, not username hygiene in
|
||||
`.env`, is the discipline this design actually asks of you.
|
||||
|
||||
### 1.3 "Then why Pangolin at all, if the feeder uses SSH?"
|
||||
|
||||
Because they are not alternatives — they carry different traffic, and neither substitutes for the other.
|
||||
|
||||
| | Pangolin/newt (HTTPS) | SSH + rsync |
|
||||
| --- | --- | --- |
|
||||
| Carries | the **MCP tool surface**: `search`, `add_drawer`, `diary_write`, `kg_*` — every live tool call | **transcript files only**, once per session or cron run |
|
||||
| Used by | the pi extension, opencode `type:remote`, any MCP client | the feeder, internally (`bin/mempalace-pi-session:677-680`) |
|
||||
| Needed because | clients need one stable URL, reachable from wherever they are | `mempalace_mine` expands its source path **server-side**, so the server can only mine files on its own disk |
|
||||
|
||||
HTTPS alone is a palace you can query but cannot feed. SSH alone is files shipped with no live query API.
|
||||
The rsync is not a transport preference; it is a workaround for *where `mine` resolves paths*.
|
||||
|
||||
**Could SSH replace Pangolin?** Partly, and it is worth being honest about it:
|
||||
`ssh -L 8765:172.17.0.1:8765 synlig` yields a working local MCP endpoint with no public HTTPS at all.
|
||||
Three reasons this runbook does not do that:
|
||||
|
||||
1. **Direction.** synlig dials *out* through newt. That we reached for a dial-out tunnel rather than a
|
||||
port-forward is itself the evidence that inbound was not available — a corporate host does not accept
|
||||
connections from a phone on a foreign network.
|
||||
2. **MCP clients want a durable URL**, not a per-session forwarded port. opencode `type:remote` takes a
|
||||
URL; a forward that drops takes the tools down mid-session.
|
||||
3. The forward must be up on **every device before every session**. Pangolin is up once.
|
||||
|
||||
**The weak point, stated plainly.** The rsync runs *client → synlig*, so it needs synlig's SSH reachable
|
||||
**from the client**. Were that already true everywhere, no tunnel would be needed for MCP either. So the
|
||||
honest expectation after Phase 1 is: **query and write from anywhere, mine only from devices that can
|
||||
reach synlig's SSH** (corporate network / VPN / LAN). See §4 for the change that would remove that limit.
|
||||
|
||||
---
|
||||
|
||||
## 2. The bind trap, in full
|
||||
|
||||
RFC §6.2 and runbook §2.4 already say **do not bind loopback behind the tunnel**, because
|
||||
`enforce_host_pin = _http_is_loopback(host)` (`mcp_server.py:5367`) makes a loopback bind reject the
|
||||
proxy's forwarded `Host:` with a **403** that reads exactly like a Pangolin misconfiguration.
|
||||
|
||||
**Additional finding, 2026-08-12 — the same reflex also silently removes authentication.** Token
|
||||
resolution in `cmd_serve` (`cli.py:1447-1450`) is:
|
||||
|
||||
```python
|
||||
loopback = _server_is_loopback(host)
|
||||
if not token and not loopback and not args.allow_insecure:
|
||||
token, token_created = _load_or_create_server_token(palace_path)
|
||||
```
|
||||
|
||||
Auto-minting is gated on the bind being **non-loopback**. A loopback bind therefore starts with **no token
|
||||
at all** — no error, no warning, `--allow-insecure` not required — because the server has concluded it is
|
||||
only reachable locally, while the tunnel is serving it to the internet. Bind loopback behind newt and you
|
||||
get a 403 wall *and*, the moment anything relaxes the Host pin, an unauthenticated palace.
|
||||
|
||||
Both failure modes have the same cure, already implemented in
|
||||
`contrib/systemd/mempalace-serve.service`: **bind `172.17.0.1`**. Non-loopback, so the Host pin relaxes and
|
||||
the token is mandatory; docker0-only, so newt reaches it and the LAN does not.
|
||||
|
||||
> Belt and braces: set `MEMPALACE_MCP_HTTP_TOKEN` explicitly in the unit rather than relying on
|
||||
> auto-minting. Then no future bind change can quietly drop authentication.
|
||||
|
||||
---
|
||||
|
||||
## 3. Steps
|
||||
|
||||
Ordered so nothing is reachable before it is authenticated.
|
||||
|
||||
### 3.1 Start the primary (runbook §4.3 — one `sudo`, unit already staged)
|
||||
|
||||
```sh
|
||||
sudo loginctl enable-linger ecsjper
|
||||
cd ~/.config/systemd/user && mv mempalace-serve.service.staged mempalace-serve.service
|
||||
systemctl --user daemon-reload && systemctl --user enable --now mempalace-serve
|
||||
|
||||
curl -s 172.17.0.1:8765/healthz # expect ok
|
||||
curl -s 127.0.0.1:8765/healthz # expect NOTHING — connection refused, exit 7 (see below)
|
||||
ss -ltnp | grep 8765 # expect 172.17.0.1:8765 only
|
||||
```
|
||||
|
||||
⚠ **The loopback probe returns empty, not 403** — corrected 2026-08-12 against the real run. Nothing is
|
||||
listening on `127.0.0.1`, so the connection is refused at TCP level and `curl -s` prints nothing; check it
|
||||
with `-w '%{http_code}'` → `000` and `$?` → `7`. The 403 belongs to a *different* configuration: server
|
||||
bound **to loopback**, receiving a proxy-forwarded foreign `Host:` (§2, verified 2026-08-10). With a
|
||||
docker0-only bind you cannot get 403 from loopback, because you never get far enough to send a header.
|
||||
Refusal is the stronger signal of the two: it proves the loopback and LAN surface is not listening at all.
|
||||
If it *hangs* instead, or `ss` shows `0.0.0.0:8765`, stop — that is not this configuration.
|
||||
|
||||
### 3.2 Collect the shared token
|
||||
|
||||
```sh
|
||||
cat ~/.mempalace/server/f5d849287f6d73f0141b29d7/token
|
||||
```
|
||||
|
||||
Directory name is `sha256(realpath(palace))[:24]` — it changes if the palace path ever moves. Store via the
|
||||
`.env.age` flow, 0600 (RFC §6.2).
|
||||
|
||||
### 3.3 newt on synlig — ✅ done 2026-08-12 (installed, connected to Pangolin)
|
||||
|
||||
synlig runs Docker (Gitea Actions runner + digikam) but **no tunnel client** — runbook §4.2. Pangolin on
|
||||
nyvaken cannot dial in; synlig must dial out. Add a `newt` container with the credentials Pangolin issues
|
||||
for a new site.
|
||||
|
||||
Because newt runs in Docker on this box, the docker0 bind is already correct for it: from inside the
|
||||
container the primary is `172.17.0.1:8765`. Verify from *inside* newt's network namespace, not from the
|
||||
host, before touching DNS.
|
||||
|
||||
> synlig has 7.8 GiB shared with a CI runner (runbook §1). newt is small, but do not colocate anything
|
||||
> else here casually.
|
||||
|
||||
**Confirm next, now that newt is up.** "Connected to Pangolin" proves newt reached *nyvaken* — a different
|
||||
claim from newt reaching *the palace*, and the two fail independently:
|
||||
|
||||
```sh
|
||||
# from inside newt's namespace, not from the host
|
||||
docker exec <newt-container> wget -qO- http://172.17.0.1:8765/healthz # expect ok
|
||||
```
|
||||
|
||||
If that hangs or refuses while the Pangolin dashboard shows the site online, the tunnel is fine and the
|
||||
*target* is wrong — look at the resource's upstream address (§3.5), not at newt. Note this check needs
|
||||
§3.1 done first: if `mempalace-serve` is not running yet, it fails for that reason alone.
|
||||
|
||||
### 3.4 DNS at the web hotel — ✅ done 2026-08-12
|
||||
|
||||
One CNAME: `mempalace` → **the same target your existing Pangolin resources use** (nyvaken's public
|
||||
hostname). RFC §6.2 costed this as *"one DNS record per service on the web hotel is the whole setup cost."*
|
||||
Done: `mempalace.jordbo.se` resolves and terminates TLS at Pangolin on nyvaken — the §6.2 estimate held.
|
||||
|
||||
⚠️ Not verified from here: nyvaken's public FQDN, and whether your web hotel permits a CNAME at that label
|
||||
(some require an A record, or forbid CNAME where other records exist). Confirm before assuming a 5-minute job.
|
||||
|
||||
### 3.5 Pangolin resource
|
||||
|
||||
- Target: newt site → **`http://172.17.0.1:8765`** — path `/mcp` (plus `/healthz` for the external probe).
|
||||
- ⚠️ **The scheme is `http`, not `https`.** The primary runs `serve --host 172.17.0.1 --port 8765` with no
|
||||
cert (`contrib/systemd/mempalace-serve.service`): TLS terminates **at Pangolin**, which is the entire
|
||||
point of the §6.2 decision. Point the resource at `https://172.17.0.1:8765` and Pangolin attempts a TLS
|
||||
handshake against a plaintext listener — you get a 502/Bad Gateway from outside while the server itself
|
||||
looks perfectly healthy on `curl 172.17.0.1:8765/healthz`. (Got this wrong on the first attempt
|
||||
2026-08-12, because this line used to omit the scheme.)
|
||||
- **Auth: none at the Pangolin layer** (§1.1). TLS termination only.
|
||||
- ⚠️ **If resource auth is left on, the signature is a `302`, not a 401 or 403** — hit for real 2026-08-12:
|
||||
```
|
||||
HTTP/2 302
|
||||
location: https://pangolin.jordbo.se/auth/resource/<uuid>?redirect=https%3A%2F%2Fmempalace.jordbo.se%2Fhealthz
|
||||
content-length: 0
|
||||
```
|
||||
This is §1.1's "browser-shaped auth" arriving as a concrete symptom: Pangolin sends the login redirect
|
||||
**before** proxying, so the palace never sees the request and its journal stays silent. `curl -s` shows
|
||||
an empty body and an MCP client sees non-JSON. Diagnose with `-D-` or
|
||||
`-w '%{http_code} %{redirect_url}'` — a `location:` pointing at `/auth/resource/…` means the fix is in
|
||||
the Pangolin UI (switch the site's authentication off), not in the palace, the unit, or newt.
|
||||
**A 302 is unambiguously good news:** DNS, TLS and routing all worked — only the auth layer intervened.
|
||||
- Do **not** attach an `Origin`-injecting proxy or browser client: a *present* non-loopback `Origin` is a
|
||||
hard 403 with no override (runbook §2.4 B3).
|
||||
|
||||
**The server's entire HTTP surface is two exact paths**, so path-scoped rules cover it completely
|
||||
(`mcp_server.py:5299-5318`, read 2026-08-12):
|
||||
|
||||
| Method + path | Auth | Notes |
|
||||
| --- | --- | --- |
|
||||
| `GET /healthz` | none (Host/Origin gated only) | the liveness probe; works with no creds by design |
|
||||
| `POST /mcp` | `Authorization: Bearer <token>`, `hmac.compare_digest` on the exact string | the whole tool surface |
|
||||
| anything else | — | `send_error(404)` from the palace itself |
|
||||
|
||||
Three consequences worth having in writing:
|
||||
|
||||
- **Path-scoped Pangolin rules are not a compromise here, they are tighter than a host-wide proxy** and
|
||||
lose nothing — there is no third endpoint to forget.
|
||||
- **`/mcp` is matched exactly** (`if path != "/mcp"`), so a client URL with a trailing slash gets a 404
|
||||
from the palace. Configure clients as `https://mempalace.jordbo.se/mcp` — no trailing slash.
|
||||
- **There is no `GET /mcp`, no SSE, no session id, no `DELETE`.** This is plain JSON-RPC over POST, not MCP
|
||||
streamable-HTTP. A strict client that opens with a `GET` handshake will see 404; pi's extension,
|
||||
opencode `type:remote` and the feeder all POST directly and are fine.
|
||||
|
||||
### 3.6 Verify end-to-end before flipping any client — ✅ passed 2026-08-12
|
||||
|
||||
```sh
|
||||
curl -s https://mempalace.jordbo.se/healthz # ok ✓ from devbox AND synlig
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
|
||||
https://mempalace.jordbo.se/mcp # 401 ✓ the token is doing its job
|
||||
TOKEN=$(cat ~/.mempalace/server/f5d849287f6d73f0141b29d7/token) # on synlig
|
||||
curl -s -X POST https://mempalace.jordbo.se/mcp \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | head -c 300 # 36 tools
|
||||
```
|
||||
|
||||
No `initialize` and no `Accept: text/event-stream` needed — see the surface table above; a bare
|
||||
`tools/list` POST is a complete request. Run the 200 and the 401 from **two different networks** (a client
|
||||
box and the primary itself): passing from only one leaves split-horizon DNS untested.
|
||||
|
||||
The 401 check matters as much as the 200: it is the only evidence that the thing you just published to the
|
||||
internet is not open. Then, and only then, Phase 1 client flip — **one machine first** (RFC §8), and
|
||||
remember opencode containers need the §4.1 sidecar merge before their `.env` takes effect.
|
||||
|
||||
### 3.7 Flipping a client: set three variables, or none
|
||||
|
||||
⚠ **`MEMPALACE_REMOTE_URL` on its own does not degrade to local feeding — it stops feeding.** `auto` mode
|
||||
switches to `remote` the moment the URL is set, and remote mode then refuses to run without an SSH target:
|
||||
|
||||
```sh
|
||||
auto) if [[ -n "$REMOTE_URL" ]]; then MODE="remote"; else MODE="local"; fi ;; # :286
|
||||
...
|
||||
command -v rsync >/dev/null 2>&1 || { echo "error: rsync not found ..."; exit 3; } # :297
|
||||
if [[ -z "$SSH_TARGET" ]]; then
|
||||
echo "error: MEMPALACE_PI_SSH_TARGET unset (needed for --mode remote)" >&2; exit 1 # :298-300
|
||||
fi
|
||||
```
|
||||
|
||||
That exit happens **before anything is staged or filed**, and a cron-driven feeder will simply start
|
||||
failing — the loudest symptom is silence, which is the hardest kind to notice. Two safe orders:
|
||||
|
||||
- **Both paths at once:** set `MEMPALACE_REMOTE_URL`, `MEMPALACE_REMOTE_TOKEN` **and**
|
||||
`MEMPALACE_PI_SSH_TARGET` (plus `MEMPALACE_PI_DEVICE`, §1.2) in the same edit.
|
||||
- **HTTPS first, mining later:** set the URL and token, and pin the feeder to `--mode local` until the SSH
|
||||
target exists. Tools then read/write the shared palace while transcripts keep landing in the local one.
|
||||
|
||||
`MEMPALACE_REMOTE_URL` is the **full endpoint including `/mcp`, with no trailing slash** — the feeder POSTs
|
||||
to it verbatim (`urllib.request.Request(url, data=payload, …)`, `:718`), and §3.5 shows the server matches
|
||||
`/mcp` exactly. Matches the existing examples (`docker-compose.mempalace.yml:7`,
|
||||
`MEMPALACE_REMOTE_URL=http://<reachable-host>:8765/mcp`). For this fleet:
|
||||
`MEMPALACE_REMOTE_URL=https://mempalace.jordbo.se/mcp`
|
||||
|
||||
Either way, run the feeder once by hand and read its exit code before trusting the timer. This is
|
||||
precisely the failure "one machine first" is meant to contain.
|
||||
|
||||
**The same misconfiguration has two different symptoms depending on who invokes the feeder** — checked in
|
||||
the code 2026-08-12, and the quieter one is the trap:
|
||||
|
||||
| Caller | Behaviour with `REMOTE_URL` set and `SSH_TARGET` unset |
|
||||
| --- | --- |
|
||||
| direct run, session-end hook, cron | `exit 1` with `error: MEMPALACE_PI_SSH_TARGET unset` (`:298-300`) |
|
||||
| **pi-devbox container start, images ≤ v1.7.0** | **silently skips — no error, no log file** (`entrypoint-user.sh:134`: `: # remote palace but no inbox configured — nothing we can ship to; skip quietly`) |
|
||||
| pi-devbox container start, images after `cbd7cf5` | prints `MemPalace catch-up skipped: remote palace with no transcript inbox` to the start output *and* to `mempalace-catchup.log`, naming both variables |
|
||||
|
||||
The silent skip is fixed in pi-devbox (`cbd7cf5`), but the fix is in
|
||||
`entrypoint-user.sh`, which is `COPY`d in `Dockerfile.base` — so **every container running an image built
|
||||
before that base rebuild still skips silently.** That is the whole fleet today. Until the rebuild lands,
|
||||
assume silence and check by hand.
|
||||
|
||||
The old skip happened *before* the subshell that writes `~/.pi/agent/mempalace-catchup.log`, so there was
|
||||
not even an empty log to notice. Someone asking "why is nothing from this container in the palace?" found
|
||||
no artifact at all. The skip itself is correct — there is genuinely nothing to ship to — but it was
|
||||
indistinguishable from a healthy run that had nothing to do, which is the worst property a memory system
|
||||
can have: **the failure looks exactly like success.**
|
||||
|
||||
→ On a pi-devbox container, confirm the feeder is actually alive after a flip rather than assuming:
|
||||
|
||||
```sh
|
||||
mempalace-pi-session --reason manual-check; echo "exit=$?" # exit=0 and a filed count, not silence
|
||||
cat ~/.pi/agent/mempalace-catchup.log # missing file = the entrypoint skipped
|
||||
```
|
||||
|
||||
### 3.8 Verify the flip actually took — ✅ done 2026-08-14 on EMB-7KJ4VR4G
|
||||
|
||||
§3.6 verifies the *endpoint* before you flip. §3.7 tells you *how* to flip. Neither verifies the thing
|
||||
you actually care about afterwards: **that the agent's palace tools are now talking to synlig.** That
|
||||
gap is why the first flip was "done" for an hour before anyone could say whether it had worked.
|
||||
|
||||
There are **three separate claims** here and they fail independently. Check them in order; each one is
|
||||
cheap and rules out a different fault.
|
||||
|
||||
**(a) Did the container receive the variable?** In a shell *inside* the container:
|
||||
|
||||
```sh
|
||||
env | grep MEMPALACE_REMOTE_URL # -> https://mempalace.jordbo.se/mcp
|
||||
```
|
||||
|
||||
⚠ Do **not** run a bare `env | grep MEMPALACE` — that prints the bearer token into your scrollback.
|
||||
If the variable is absent, the cause is almost always §3.7's edit not having been applied: `env_file`
|
||||
is read at container **create** time and baked into the container config, so **`docker compose up -d`
|
||||
is required; `docker compose restart` silently reuses the old config.** Evidence from 2026-08-14 —
|
||||
running container config-hash `3a55e09ac19e0118` vs compose-computed `8a7e0cd4a677a9c1`; a differing
|
||||
hash is what makes `up -d` recreate. `--force-recreate` is not needed.
|
||||
**Never `docker compose down -v`** to "pick up" a change: on pi-devbox that destroys seven named
|
||||
volumes, including `devbox-pi-config` (pi's config **and every session transcript**), `devbox-uv`, and
|
||||
`devbox-chroma-cache` (a large embedding-model re-download).
|
||||
|
||||
**(b) Is the server reachable and the token accepted?** Still inside the container — this proves the
|
||||
network path and the credential, independently of any agent:
|
||||
|
||||
```sh
|
||||
curl -s -X POST "$MEMPALACE_REMOTE_URL" \
|
||||
-H "Authorization: Bearer $MEMPALACE_REMOTE_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | head -c 200
|
||||
```
|
||||
|
||||
`401` = token problem (compare `md5sum` of the client value against synlig's token file — trailing
|
||||
whitespace from an editor is the classic cause). Connection failure = DNS/tunnel, not auth.
|
||||
|
||||
**(c) Did the agent's bridge actually switch?** This is the claim that matters and the one that cannot
|
||||
be checked from bash — there is no log file. Ask the agent running in the container for
|
||||
`mempalace_status` and read the palace path it reports:
|
||||
|
||||
```
|
||||
sqlite_integrity.palace = /home/ecsjper/.mempalace/palace # ← proves remote
|
||||
```
|
||||
|
||||
**This is the cheapest and strongest discriminator: that path cannot exist inside the container**,
|
||||
whose user is `developer` with `HOME=/home/developer` and whose local palace is
|
||||
`/home/developer/.mempalace/palace`. One call, no token in scrollback, no writes. If instead it
|
||||
reports the local path, the bridge fell back: `createClient()` reads `MEMPALACE_REMOTE_URL` **once at
|
||||
load**, so re-check (a) and restart the agent, not just the container.
|
||||
|
||||
#### What does *not* verify the flip — read this before inventing your own check
|
||||
|
||||
- **❌ A drawer count.** Central was seeded *from* the client's own palace, so both report ~14,777.
|
||||
Counts cannot tell the two apart. Worse, `mempalace status` counts **chunk rows, not logical
|
||||
drawers** (three drawers plus a 2-chunk diary presented as +9), so a delta does not even mean what
|
||||
it looks like. Never reason about palace identity or contents from a count.
|
||||
- **❌ Writing a drawer through the palace tools and reading it back through the same tools.** This
|
||||
succeeds *identically whether or not the flip worked* — both palaces are healthy and were seeded
|
||||
from the same source, so a write-then-read round-trips either way. It only becomes evidence if the
|
||||
drawer is read back over a **different transport** (the `curl` in (b), by drawer id) or is proven
|
||||
**absent** from the local sqlite. This is the same false-positive class as the CLI below, one layer
|
||||
up, and it is an easy trap to fall into precisely because it feels like an end-to-end test.
|
||||
- **❌ The `mempalace` CLI, in any form.** The CLI has **no remote support whatsoever** — its only
|
||||
selector is `--palace <path>` — so it reads and writes the LOCAL on-disk archive via
|
||||
`config.json`. After a flip that archive is dead, yet `mempalace status` / `mempalace search` will
|
||||
cheerfully report ~14,777 drawers and look exactly like success. It is a false-positive machine.
|
||||
**Corollary that bites later:** memories filed with the CLI after a flip land in the dead archive,
|
||||
not in central, and a transcript backfill must therefore be mined **on synlig**, where the CLI's
|
||||
local palace *is* the central one.
|
||||
|
||||
#### Expected failure behaviour, so you can recognise it
|
||||
|
||||
The bridge is **fail-closed, not fail-local.** If synlig is unreachable, DNS fails, or the token is
|
||||
rejected, the extension retries a bounded number of times, prints `mempalace-mcp unavailable after
|
||||
retries; continuing without palace tools`, and **does not register the palace tools at all**. It does
|
||||
not silently write to the local palace; there is no dual-write and no local mirror, and in remote mode
|
||||
no local `mempalace-mcp` process is spawned (verified: zero mempalace processes in the flipped
|
||||
container). So **"the agent has no `mempalace_*` tools" is the expected symptom of a server, token, or
|
||||
DNS fault** — not of a broken container. Diagnose with (b).
|
||||
|
||||
#### Rollback — ~30 seconds, loses nothing
|
||||
|
||||
Comment out `MEMPALACE_REMOTE_URL` in `.env`, then `docker compose up -d`. The bridge falls back to the
|
||||
local stdio palace, which is intact. Note the local archive is **frozen, not empty**: it stops at the
|
||||
moment of the flip, so anything the agent filed into central since then will not be there.
|
||||
|
||||
---
|
||||
|
||||
## 4. Still open
|
||||
|
||||
- **Feeding without SSH — the upstream ask that would close §1.3's gap.** Have the feeder send *content*
|
||||
over MCP (`add_drawer` / `diary_write`, which it already calls) instead of asking the server to mine a
|
||||
path it must first rsync there. HTTPS would then be genuinely sufficient and mining would work from any
|
||||
network. Until then, mining is limited to devices that can reach synlig's SSH.
|
||||
- **Per-device tokens** — Phase 4. Until then `origin_device` is advisory (§1.1).
|
||||
- **§7.6 diary dedup** must be settled *before* the **next** §4.4 join; replay duplicates every entry.
|
||||
⚠ **2026-08-14 — the first join did not resolve this, it SIDESTEPPED it.** The seed was a file-level
|
||||
copy of one palace, which replays no diaries and therefore cannot duplicate them. The success of
|
||||
that join is *not* evidence the replay path is safe — it never exercised it. §7.6 remains a hard
|
||||
blocker for the second machine, which is the one that will actually need merge semantics.
|
||||
- **The transcript feeder is inert on every deployed image**, so nothing is being mined automatically
|
||||
anywhere in the fleet (§3.7). Regaining it needs a **new tagged pi-devbox release** — its CI
|
||||
publishes only on `v*` tags and the latest tag *is* the currently deployed image — built on
|
||||
pi-devbox ≥ `7c00dd6` **and** mempalace-toolkit ≥ `29e660e`. Both are required: the first restores
|
||||
the container-start catch-up, the second is where extension-side feeding was implemented at all.
|
||||
- **Whether *native* pi on a flipped machine was also flipped** is a per-machine question. Answered for
|
||||
**EMB-7KJ4VR4G (2026-08-14): native pi is not installed there *yet*** — no `pi`/`mempalace` on the host
|
||||
PATH, no `~/.config/pi/`, no `~/.pi/agent/extensions/`; it is a replacement machine that has not had
|
||||
native pi set up. So nothing further needs flipping there **today** — but this is a deferred hazard,
|
||||
not a closed one: a native install resolves its palace to `~/.mempalace`, which on that host is the
|
||||
bind-mounted **frozen archive**, so it would start writing there and split the machine's memory from
|
||||
central silently. **Flip native pi at install time, not after.** **Still open for every other host**
|
||||
running native pi beside a flipped container. Verify with the §3.8(c) path check, per machine.
|
||||
Note native pi has **no `.env` to edit** — pi loads no dotenv file and has no `env` block in
|
||||
`settings.json`, so the variables must come from the shell that launches it; recipe in
|
||||
[`extensions/pi/README.md`](../extensions/pi/README.md#transport-local-vs-external) § Transport. On
|
||||
EMB-7KJ4VR4G specifically, that tree's `config.json` also points at
|
||||
`palace_path=/home/developer/.mempalace/palace` — a *container* path that does not exist on macOS — so
|
||||
a native install must not inherit it unexamined.
|
||||
- **§7.2**: never run `mempalace sync` against the shared palace. Doubly true now that the pi/opencode
|
||||
feeders stage *inside* the palace root, which puts staged sources in scope for a sync of the palace dir.
|
||||
- **nyvaken's public FQDN and the web hotel's CNAME rules** — unverified (§3.4).
|
||||
@@ -0,0 +1,950 @@
|
||||
# RFC 001 — Global palace with local fallback (`mempalace-edge`)
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Status** | **Phases 0–1 implemented and verified — 2026-08-10 / 08-12 / 08-14.** Primary live + seeded, first client flipped. Phases 1.5, 2 and 4 outstanding; Phase 3 deferred by decision. |
|
||||
| **Created** | 2026-08-08 |
|
||||
| **Applies to** | mempalace 3.6.0, mempalace-toolkit @ `96699f2`, pi-devbox ≥ v1.3.0 |
|
||||
| **Decision** | Phases 0–2 + 4 in scope. **Phase 3 (full pull replication) explicitly deferred** — "a laptop that can reach its own stuff plus whatever it can reach" is good enough. **Centralization is strictly opt-in — solitary devbox operation remains the default and must not change (§1.1).** |
|
||||
| **Recon update** | **2026-08-09 — §9 Q1 and Q6 are RESOLVED, both in the permissive direction** (opencode supports remote MCP; opencode-devbox already templates the mempalace entry). Neither is a blocker. See those entries for evidence; §2, §4.1 and R5 were corrected accordingly. |
|
||||
| **Rollout update** | **2026-08-14 — Phase 1 is live.** Primary serving at `https://mempalace.jordbo.se/mcp` since 2026-08-12 (synlig, `mempalace-serve.service` under `systemctl --user`, palace `/home/ecsjper/.mempalace/palace`); **seeded 2026-08-14 15:07** from EMB-7KJ4VR4G's palace — which is itself a carry-over copy from the
|
||||
operator's previous work computer **EMB-X1JY06WJ** (lineage and evidence in the §4.4 Deviation note) —
|
||||
14,777 drawers / 9 wings / 16,337 embeddings / KG 46 entities, 34 triples (14,803 drawers by 17:00); **first client flipped and verified end-to-end** the same afternoon. ⚠️ Two things not to misread: the seed was a **file-level copy of one palace**, *not* the §4.4 MCP replay (see the Deviation note in §4.4), and it therefore **sidestepped §7.6 rather than resolving it — §7.6 remains a hard blocker for the *second* joiner (§8 Phase 0).** §2's and §4.1's predictions about the pi client were confirmed in production; §4.4's count-based verification advice was **wrong** and has been corrected. |
|
||||
|
||||
**Read this first if you are asked to "centralize MemPalace" / "sync palaces between machines".** Most of the
|
||||
hard-won facts below are non-obvious and two of them are actively destructive if you guess wrong
|
||||
(§7.1, §7.2). The evidence index in §10 lets you re-verify any claim without re-reading 45k lines.
|
||||
|
||||
---
|
||||
|
||||
## 1. The problem
|
||||
|
||||
One palace per machine per harness. Today: a pi-devbox container on `EMB-7KJ4VR4G`, another on
|
||||
`tor-ms22`, opencode-devbox containers, `MBP-M1-2020`, plus native hosts — each with a private
|
||||
`~/.mempalace`. Consequences:
|
||||
|
||||
1. **Memory is sharded by accident of where you happened to be working.** A decision recorded on the
|
||||
laptop is invisible to the agent on the desktop.
|
||||
2. **Container recreates are amnesia events** unless the palace happens to be host-bind-mounted.
|
||||
3. **The KG is the worst hit** — `kg_query`/`kg_timeline` answers depend on which machine you ask.
|
||||
|
||||
**Target state:** one primary palace holds the fleet's memory. Every client keeps working when the
|
||||
primary is unreachable (writes buffer locally, reads degrade to local), and reconciles when it
|
||||
returns. Access is authenticated per device, with a sane authorization policy.
|
||||
|
||||
### 1.1 Hard requirement: solitary-first, centralization strictly opt-in
|
||||
|
||||
**This is a requirement, not a preference, and it constrains every choice below.**
|
||||
|
||||
Centralization solves a problem specific to *this* usage pattern: several machines and containers
|
||||
(pi-devbox, opencode-devbox, native hosts) doing development and other work against a **common set of
|
||||
artifacts**. For most users of the published `joakimp/pi-devbox` and `joakimp/opencode-devbox` images
|
||||
it is **useless** — one machine, one palace, done. Evidence that this is already the norm: all three
|
||||
sampled `opencode-devbox` deployments (`docker-compose-repo/{synlig,nyvaken,devbox-affection}/`)
|
||||
contain **zero** mempalace references.
|
||||
|
||||
| ID | Requirement |
|
||||
| --- | --- |
|
||||
| **R1** | **Solitary operation stays the default and stays unchanged.** With no `MEMPALACE_*` variables set, a devbox must behave exactly as it does today: harness → local `mempalace-mcp` over stdio, palace at `~/.mempalace`. No extra process, no outbox, no network calls, no new failure mode, no measurable startup cost. |
|
||||
| **R2** | **Opt-in lives in `docker-compose.yml` + `.env`** — the mechanism users already know. No opt-in via image rebuild, no baked-in defaults, no `latest-central` variant. |
|
||||
| **R3** | **Credentials only in `.env`** (`chmod 600`, gitignored). Never in `docker-compose.yml`, never in the image, never on a command line (visible in `ps`), never logged. Compose passes them through with a `${VAR:-}` empty default so solitary users never define them. |
|
||||
| **R4** | **No new required services.** The primary stays in the separate standalone `docker-compose.mempalace.yml` project; it is never merged into the main compose file. A solitary `docker compose up` starts exactly what it starts today. |
|
||||
| **R5** | **Degrade, never fail, when mempalace is absent.** The published `opencode-devbox` image *does* ship mempalace by default (`Dockerfile.base:380,402` — `ARG INSTALL_MEMPALACE=true`, `MEMPALACE_VERSION=3.6.0`, installed via `uv tool install`), but it is a build arg precisely so it can be omitted to save ~300 MB — and a bind-mounted host config or a non-devbox client may have no mempalace either. So any wiring must stay additive and skip when the binary is missing — the probe-and-warn idiom `install.sh` already uses (`warn` + `return 0`, never halt). |
|
||||
| **R6** | **Reversible.** Commenting the `.env` lines out returns the container to pure solitary operation, with the local palace intact and readable. |
|
||||
|
||||
**Acceptance test for R1** (must pass before Phase 2 ships): bring up a devbox with no `MEMPALACE_*`
|
||||
variables; assert the palace tool list, drawer counts and diary writes are identical to the previous
|
||||
image, and that no edge/outbox process exists (`pgrep -f mempalace-edge` → empty).
|
||||
|
||||
### 1.2 The opt-in surface
|
||||
|
||||
The existing convention is already the right one — **extend it, do not invent a new one.** pi-devbox's
|
||||
`.env.example` lines 12–23 already ship a commented `MEMPALACE_REMOTE_URL` / `MEMPALACE_REMOTE_TOKEN`
|
||||
pair under the heading "MemPalace memory (local by default)", and `docker-compose.yml:79-83` already
|
||||
ships the `devbox-palace` volume commented out. So the opt-in ladder becomes three states derived
|
||||
from two variables — **both existing behaviours are preserved, the third is new**:
|
||||
|
||||
| `MEMPALACE_REMOTE_URL` | `MEMPALACE_EDGE` | Behaviour | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| unset | — | local stdio `mempalace-mcp`, palace at `~/.mempalace` | **default, today, unchanged (R1)** |
|
||||
| set | unset/`0` | direct remote HTTP; no local palace, no offline | today (`96699f2`), unchanged |
|
||||
| set | `1` | `mempalace-edge`: local-first writes + outbox → primary + merged reads | **new (Phase 2)** |
|
||||
|
||||
> **Volume coupling reverses under edge mode — document it prominently.** Today `.env.example` correctly
|
||||
> says that with `MEMPALACE_REMOTE_URL` set "*the devbox-palace volume is then irrelevant*", because the
|
||||
> remote owns all state. Under **edge** mode that flips: the local palace holds the outbox and all
|
||||
> local-first writes, so an un-persisted palace means **losing un-flushed writes on container
|
||||
> recreate**. Opting into `MEMPALACE_EDGE=1` therefore *requires* uncommenting
|
||||
> `devbox-palace:/home/developer/.mempalace`.
|
||||
|
||||
Corollary for §4.1: **`mempalace-edge` must not be in the path unless opted in.** Registering it
|
||||
unconditionally (letting it decide by env at runtime) is tempting — one static config for every
|
||||
harness — but it violates R1 by inserting a process and a failure mode into every solitary user's
|
||||
setup. Selection must happen at registration time. See open question §9.6.
|
||||
|
||||
---
|
||||
|
||||
## 2. Verified starting point (as of 2026-08-08)
|
||||
|
||||
Two of the three pieces already exist. This is not greenfield.
|
||||
|
||||
| Piece | State | Evidence |
|
||||
| --- | --- | --- |
|
||||
| **Primary server** | ✅ **Exists.** `mempalace serve --host --port --token --tls-cert --tls-key --read-only --allow-insecure`. Bearer token compared with `hmac.compare_digest`, **mandatory** on non-loopback binds (unless `--allow-insecure`), TLS 1.2+ resolved *before* bind, Host-header pinning + `Origin` allowlist (anti-DNS-rebinding), 16 MiB body cap, token-free `/healthz`. | `cli.py:cmd_serve` (~1448); `mcp_server.py:5205-5215`, `5284-5289` |
|
||||
| **Remote client** | ⚠️ **Exists for pi only, and it is either/or.** `createClient()` picks stdio *or* HTTP once at process start. ✅ **Confirmed in production 2026-08-14** on the first flipped client: the branch is a pure transport swap, and in remote mode **no local `mempalace-mcp` child is spawned at all** (zero mempalace processes in the flipped container). So **writes go only to the primary — no dual-write, no local mirror.** | `extensions/pi/mempalace.ts:629-641` |
|
||||
| **Fallback + resync** | ❌ **Absent everywhere.** On remote failure the pi bridge re-handshakes the same URL, then **de-registers all palace tools** and runs blind. ✅ **Confirmed 2026-08-14 — and the precise word is fail-*closed*, not fail-local.** After bounded retries it prints `mempalace-mcp unavailable after retries; continuing without palace tools` and registers nothing; it never silently falls back to the local palace, so a write cannot land in the wrong store. Operational corollary worth stating once: **"the palace tools vanished" is the expected symptom of a server / token / DNS fault**, not of a broken client. | `extensions/pi/mempalace.ts:665-673` |
|
||||
| **`mempalace` CLI** | ❌ **No remote support whatsoever.** Its only palace selector is `--palace <path>`; otherwise it resolves `palace_path` from the local `config.json`. **Verified 2026-08-14: on a flipped client the CLI still reads and writes the now-dead local archive — and cheerfully reports ~14,777 drawers while doing so.** It is a false-positive machine: never verify a flip with `mempalace status`/`search`, and never file memories with the CLI post-flip (they land in the archive, not the primary). Corollary: a **transcript backfill must be mined on the primary host**, where the CLI's local palace *is* the primary. | `cli.py` argument surface; verified on EMB-7KJ4VR4G |
|
||||
| **opencode client** | ✅ **Remote is supported and already wired.** opencode's published schema (`https://opencode.ai/config.json`, `$defs.McpRemoteConfig`) makes `{"type":"remote","url","headers","oauth"}` a first-class sibling of `McpLocalConfig`, `headers` being a free string→string map (so bearer is a convention, not a constraint). `opencode-devbox` already emits exactly that entry when `MEMPALACE_REMOTE_URL` is set. The all-`type:local` configs in `myconfigs` are a *deployment* fact, not a capability limit. | `generate-config.py:107-118`; hook at `entrypoint-user.sh:117`; schema `$defs.McpRemoteConfig` |
|
||||
| **Server compose** | ✅ Exists: `pi-devbox/docker-compose.mempalace.yml` (canonical) + a tor-ms22 derivative (`docker-compose-repo/tor-ms22/pi-devbox/`, `df2c2ae`, port 8766, uid 1000, binds the real `~/.mempalace`). Not enabled. | pi-devbox CHANGELOG v1.3.0 (2026-07-02) |
|
||||
|
||||
> **Stale docs warning.** `docker-compose.mempalace.yml`, `.env.example` and CHANGELOG v1.3.0 all say the
|
||||
> HTTP transport is unauthenticated and should be fronted by a reverse proxy. That was true for
|
||||
> `mempalace-mcp --transport http` in the v1.3.0 era. **mempalace 3.6.0's `serve` has token + TLS
|
||||
> built in** (upstream #1877). Fix those comments during Phase 1. Specifically:
|
||||
> `pi-devbox/.env.example:21` still advertises `mempalace-mcp --transport http --host 0.0.0.0 --port
|
||||
> 8765` as the way to serve a shared palace — replace with `mempalace serve --token … --tls-cert …`,
|
||||
> and change the example URL from `http://mempalace.lan:8765/mcp` to `https://`.
|
||||
>
|
||||
> **✅ Done 2026-08-12** — `pi-devbox/.env.example` and
|
||||
> `mempalace-toolkit/extensions/pi/README.md` both now recommend `mempalace serve`, bind docker0 rather
|
||||
> than `0.0.0.0`/loopback, and state that the transport *is*
|
||||
> authenticated. `docker-compose.mempalace.yml` audited too, and it was worse than stale — it was
|
||||
> **broken on 3.6.0 in both directions**: `--host 0.0.0.0` with no token in the environment makes the
|
||||
> server refuse to start (crash-looping under `restart: unless-stopped`), and once a token *is* supplied
|
||||
> the healthcheck's unauthenticated `tools/list` POST 401s, marking a healthy server unhealthy forever.
|
||||
> Fixed: token now required via `${MEMPALACE_REMOTE_TOKEN:?}` (fails fast at `up`), healthcheck switched
|
||||
> to the token-free `/healthz`.
|
||||
>
|
||||
> ⚠️ **Corrected 2026-08-14 — one sub-claim above was wrong for two days.** The `https://` example URL
|
||||
> landed in `pi-devbox/.env.example` only; `extensions/pi/README.md` still carried
|
||||
> `http://mempalace.lan:8765/mcp` until it was fixed on **2026-08-14**. Everything else in this block
|
||||
> checked out. Worth naming as a pattern, because it is cheap to repeat: **a doc's own ✅ is not evidence
|
||||
> the work landed — verify it per sub-claim and per file.**
|
||||
|
||||
### Two things that sound like the feature and are not
|
||||
|
||||
- **`sync.py` is not replication.** It is gitignore-aware drawer *deletion*: "*Removes drawers whose
|
||||
source files are now gitignored, deleted, or moved out of the project*" (`sync.py:1-12`). See §7.2 —
|
||||
running it against a shared palace is a fleet-wide memory wipe.
|
||||
- **`wal/write_log.jsonl` is not a replayable WAL.** `_WAL_REDACT_KEYS` strips
|
||||
`content`/`content_preview`/`document`/`entry`/`entry_preview`/`query`/`text` and replaces them with
|
||||
`"[REDACTED N chars]"`; all 12 references are writers, there is **no reader** and no `replay`
|
||||
function (`wal.py:74`). Reconstructing memory from it is information-theoretically impossible.
|
||||
|
||||
---
|
||||
|
||||
## 3. Why we sync operations, not databases
|
||||
|
||||
Row-level / file-level replication of a palace is a trap in this codebase. Four findings, each
|
||||
independently disqualifying:
|
||||
|
||||
1. **Vectors are not portable.** Embeddings are computed **client-side** — `backends/pgvector.py:6-8`:
|
||||
"*Embeddings are still produced locally by MemPalace through the core embedding wrapper before
|
||||
vectors are written to Postgres.*" Cross-architecture ONNX determinism (arm64 macOS vs x86-64
|
||||
Linux, different execution providers) is not guaranteed. → **Ship text + metadata, always re-embed
|
||||
on receipt.** Cheap and safe: the raw text is always stored as the chroma document.
|
||||
|
||||
⚠️ **Not absolute — see the 2026-08-14 deviation in §4.4.** A whole-palace file-level copy arm64 macOS →
|
||||
x86-64 Linux *did* preserve working search and an intact HNSW index. Re-embedding on receipt remains the
|
||||
rule for **merging** operations into an existing store; it is not a prohibition on cloning one palace
|
||||
wholesale, provided the embedder sidecar travels with it (§7.4).
|
||||
2. **KG rows cannot be copied.** `triples` has **no** `UNIQUE(subject,predicate,object,valid_from)` —
|
||||
only `id` is unique, and `make_triple_id` embeds `datetime.now()`. A row copy therefore duplicates
|
||||
every fact. But `add_triple()` guards at the application level (`SELECT id … WHERE subject=? AND
|
||||
predicate=? AND object=? AND valid_to IS NULL` → returns the existing id), so **replaying `kg_add`
|
||||
is idempotent for open facts** (`knowledge_graph.py:163-178`, `305-313`).
|
||||
⚠️ **Scoped to open facts only** (verified 2026-08-09). The guard's `WHERE … valid_to IS NULL` means an
|
||||
already-**closed** historical fact — one written with `valid_to`, or closed later by
|
||||
`kg_invalidate`/`kg_supersede` — has no guard at all, so replaying it inserts a duplicate row every
|
||||
time. A bootstrap that replays full KG *history* rather than just currently-open facts must dedupe
|
||||
closed facts client-side on `(s,p,o,valid_from,valid_to)` (§4.4).
|
||||
3. **Only one write path has deterministic IDs.**
|
||||
|
||||
| Write path | ID recipe | Same content on 2 hosts → same ID? |
|
||||
| --- | --- | --- |
|
||||
| `add_drawer` / `checkpoint` | `drawer_{wing}_{room}_{sha256(wing\|room\|content)[:24]}` | **YES** — content-addressed, merges for free |
|
||||
| project/format miner | `…sha256(source_file\|chunk_index)` | **NO** — absolute path (`/Users/joakim/x` vs `/workspace/x`) |
|
||||
| convo miner | `…sha256(source_file\|extract_mode\|chunk_index)` | **NO** — same reason |
|
||||
| diary | `diary_{wing}_{YYYYmmdd_HHMMSSffffff}_{sha256(entry)[:12]}` | **NO** (µs timestamp) — and the write is a bare `col.add` with **no pre-write probe at all** (`mcp_server.py:3546`), unlike `add_drawer`. The 12-hex suffix is the only usable content dedup key, and it must be applied client-side (§7.6) |
|
||||
| KG triple | `t_{s}_{p}_{o}_{sha256(valid_from\|recorded_at)[:12]}` | **NO** (`recorded_at = now()`) |
|
||||
|
||||
(`ids.py:56,71` — `ID_RECIPE = "v3"`, length-prefixed delimited hashing.)
|
||||
4. **Batch dedup will not save a naive merge.** `dedup.py` groups by `source_file` and only compares
|
||||
*within* a group (cosine < 0.15) → cross-host duplicates with differing paths are never compared.
|
||||
The mechanism that *does* work is write-time: `tool_check_duplicate(content, threshold=0.9)` queries
|
||||
the whole collection, and `mempalace_checkpoint` already runs it per item.
|
||||
|
||||
**Conclusion:** the MCP tool surface is already a small, coarse-grained, mostly-idempotent operation
|
||||
vocabulary. Log the *intent*, replay the *intent*. That sidesteps chroma internals, chunking,
|
||||
embedder drift and vector portability in one move.
|
||||
|
||||
**One exception, and it is not cosmetic:** `diary_write` has no idempotency guard whatsoever, so for
|
||||
diaries "replay the intent" *duplicates* rather than merges (§7.6). Every other write path either
|
||||
content-addresses or guards. §4.4 carries the per-record-type dedup keys a joining client must
|
||||
therefore bring with it.
|
||||
|
||||
---
|
||||
|
||||
## 4. Design: `mempalace-edge`, a local MCP proxy
|
||||
|
||||
### 4.1 Shape
|
||||
|
||||
Do **not** put fallback logic in `extensions/pi/mempalace.ts` — opencode would need it again — not
|
||||
because opencode lacks remote MCP (it has it, §9.1), but because *offline-first writes* are a client
|
||||
concern that every harness would otherwise reimplement. Instead: a sidecar that *is* an MCP server.
|
||||
|
||||
```
|
||||
pi (mempalace.ts, stdio) ─┐
|
||||
opencode (mcp: type=local) ─┼──► mempalace-edge ──HTTPS MCP──► PRIMARY
|
||||
mempalace CLI (local palace) ─┘ │ (stdio MCP server) mempalace serve
|
||||
├─ child: mempalace-mcp --token --tls-cert
|
||||
│ (local palace, always writable)
|
||||
└─ outbox.sqlite (durable op queue)
|
||||
```
|
||||
|
||||
**`mempalace-edge` needs zero mempalace internals** — it is an MCP-to-MCP proxy. It speaks stdio down
|
||||
to a child `mempalace-mcp` and HTTPS up to the primary, reusing the `RemoteMcpClient` already written
|
||||
in `96699f2` (`extensions/pi/mempalace.ts:390`, vendored from `pi-extensions/mcp-loader.ts` — mind the
|
||||
`MCP-STREAMABLE-HTTP-CLIENT-SYNC: v1` drift token if you copy it again).
|
||||
|
||||
Why this shape wins:
|
||||
|
||||
- **One implementation for every harness.** opencode's change is one line:
|
||||
`"command": ["mempalace-edge"]`. pi's is one line. The CLI is untouched.
|
||||
- **Tools never vanish** from the tool list, so wake-up context injection keeps working offline —
|
||||
unlike today's fail-soft de-registration.
|
||||
- **Survives mempalace upgrades**: coupled to the tool schema, not to chroma/HNSW.
|
||||
- The remote transport is **sessionless JSON-RPC** today (per `96699f2`'s own note), so reconnect
|
||||
after an outage is cheap — there is no session to re-establish.
|
||||
|
||||
**But per R1, that one-line change is conditional, not baked in.** The edge binary is present in the
|
||||
image (it costs nothing unused) and inserted into the path only when `MEMPALACE_EDGE=1`:
|
||||
|
||||
- **pi**: `createClient()` (`extensions/pi/mempalace.ts:629`) already branches on env at startup — add a
|
||||
third branch. Zero change to the default path.
|
||||
- **opencode**: the MCP server entry is static JSON — but **the templating step already exists and
|
||||
already implements the first two rungs of the §1.2 ladder.**
|
||||
`rootfs/usr/local/lib/opencode-devbox/generate-config.py` (run unconditionally from
|
||||
`entrypoint-user.sh:117`) registers `mempalace` as `{"type":"remote", url, headers:{Authorization:
|
||||
Bearer …}}` when `MEMPALACE_REMOTE_URL` is set, else `{"type":"local","command":["mempalace-mcp"]}`
|
||||
when the binary is on PATH, else nothing — R5-compliant already. Its own comment states it uses the
|
||||
"*same env contract as the mempalace.ts pi extension … so one shared MemPalace can serve pi +
|
||||
opencode + native*", i.e. the two images are deliberately kept in step. **Phase 2's opencode work is
|
||||
therefore a third branch in an existing script, not a new mechanism.**
|
||||
|
||||
> ⚠️ **But the opt-in does not propagate to an existing container.** `generate-config.py` *never*
|
||||
> overwrites an existing config, and `~/.config/opencode` is the named volume
|
||||
> `devbox-opencode-config` (`docker-compose.yml:64,153`) — so a config generated during solitary use
|
||||
> survives recreate, and later setting `MEMPALACE_REMOTE_URL`/`MEMPALACE_EDGE` only produces a
|
||||
> non-loaded `opencode.jsonc.proposed` sidecar for manual merge (`generate-config.py:203-244`).
|
||||
> Flipping the `.env` alone is a no-op there. Phase 1/2 docs must say: merge the sidecar, or
|
||||
> `docker volume rm` the config volume, to adopt the change — and the same applies in reverse for R6
|
||||
> (reversibility). This asymmetry with pi — whose `createClient()` re-reads env every start — is the
|
||||
> single biggest behavioural difference between the two harnesses under this RFC.
|
||||
|
||||
> ✅ **Fix shape (decided 2026-08-09): make the `mcp.mempalace` subtree env-authoritative.** Four
|
||||
> verified inputs:
|
||||
> 1. **pi is immune for a *structural* reason worth naming:** its MemPalace wiring is **code, not
|
||||
> config** — `createClient()` reads env at every startup, with no generated file in the path. pi's
|
||||
> own `settings.json` *does* sit on a preserved volume and *does* go stale, and pi-devbox already
|
||||
> solved that properly at `pi-devbox/entrypoint-user.sh:131-162`: `jq -s '.[0] * .[1]'` deep-merge
|
||||
> with **template first, live second** so the user's values always win and only *missing* keys are
|
||||
> filled; arrays treated as leaves (a deliberately removed model is not re-added); rewrite only when
|
||||
> the merge changes something; timestamped `.bak` first; `PI_SETTINGS_MERGE=0` to disable; invalid
|
||||
> JSON on either side → skip, never clobber. **That is the pattern to port.**
|
||||
> 2. **The precedence must be inverted for this one subtree.** pi's "live wins" is right for *adding*
|
||||
> new keys and wrong for a *changed* env value — and a changed `MEMPALACE_REMOTE_URL` is the whole
|
||||
> problem. So `mcp.mempalace` needs env-wins, which is only safe with a **fingerprint**: store a
|
||||
> hash of what was last auto-generated and refresh only while the live value still matches it;
|
||||
> otherwise fall back to the `.proposed` sidecar for that key. `write_proposed`
|
||||
> (`generate-config.py:203-244`) already diffs rendered config against the live file, so this is an
|
||||
> extension of existing logic at narrower granularity, not new machinery.
|
||||
> 3. **No higher-precedence layer exists to hide in.** There is no `OPENCODE_CONFIG*` env override in
|
||||
> opencode's published schema (or anywhere in `opencode-devbox`/`myconfigs`), and MCP registration
|
||||
> is global rather than project-scoped, so the real file must be written.
|
||||
> 4. **The fingerprint is the one genuinely new mechanism** — neither repo has a managed-marker
|
||||
> convention for JSON; the only prior art is the markdown `<!-- pi-devbox:managed-block -->` idiom.
|
||||
>
|
||||
> This is a **self-contained opencode-devbox change, independent of the rest of this RFC**, and it is
|
||||
> what turns Phase 1 into "flip `.env`, restart" for *both* harnesses — hence Phase 1.5 in §8.
|
||||
|
||||
### 4.2 Routing policy
|
||||
|
||||
`service.py:29,54,72` already ships the exact three-way split we need — `READ_TOOLS`,
|
||||
`WRITE_TOOLS`, `MAINTENANCE_TOOLS` (`{mine, sync, reconnect}`) — plus `classify_tool()`.
|
||||
|
||||
| Class | Primary up | Primary down |
|
||||
| --- | --- | --- |
|
||||
| **read** (`search`, `kg_query`, `diary_read`, `list_*`, `traverse`…) | query **both**, merge ranked lists, dedupe by drawer id | local only, response flagged `degraded: true` |
|
||||
| **write** (`add_drawer`, `checkpoint`, `diary_write`, `kg_add`…) | apply local **and** enqueue → primary | apply local, enqueue, keep working |
|
||||
| **maintenance** (`mine`, `sync`, `reconnect`) | **never proxied** — local only (§5, §7.2) | local only |
|
||||
| **destructive** (`delete_drawer`, `delete_by_source`, `delete_tunnel`…) | admin-scoped only; tombstone, never hard-delete remotely | local only |
|
||||
|
||||
> ⚠️ **`classify_tool()` must be treated as fail-closed.** `mempalace_kg_supersede` is a real MCP tool
|
||||
> (3 references in `mcp_server.py`) but is **absent from `WRITE_TOOLS`** → `classify_tool()` returns
|
||||
> `"unknown"` for it. An edge proxy that routed "unknown" as read would silently drop supersede
|
||||
> operations. Maintain our own tool→class table, default unknown ⇒ **write**, and file the upstream fix.
|
||||
|
||||
**The merged-read trick is what makes Phase 3 optional.** Both palaces return ranked results with
|
||||
distances; if the embedder identity matches (§7.4) the distances are comparable, so a union +
|
||||
re-sort + dedupe-by-id gives a correct combined result set. You get fleet-wide recall *without*
|
||||
replicating anything. Offline you simply see less.
|
||||
|
||||
KG reads are the exception: unioning triples is unsafe because `invalidate`/`supersede` are
|
||||
order-dependent. Use **primary-first, local-fallback** (no union) for `kg_*` reads, and accept that an
|
||||
offline KG answer is incomplete.
|
||||
|
||||
### 4.3 Outbox
|
||||
|
||||
Model it on `daemon.py`, which already implements a durable local job queue and is the closest
|
||||
existing prior art (token auth via `ensure_token`, 0600, per-palace state dir, `MAX_ATTEMPTS = 3`,
|
||||
`recover_running`, `JOB_RETENTION_DAYS = 7`). Its key primitive is worth copying verbatim:
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_dedupe_active
|
||||
ON jobs(dedupe_key) WHERE state IN ('queued', 'running');
|
||||
```
|
||||
|
||||
A TOCTOU-safe, cross-process "at most one active job per key". Our outbox rows:
|
||||
|
||||
| Column | Purpose |
|
||||
| --- | --- |
|
||||
| `op_id` | `sha256(origin_device, local_seq, tool_name, canonical_payload)` — stable across retries, the primary's idempotency key |
|
||||
| `local_seq` | Per-device monotonic counter → **causal order** for `kg_invalidate`/`supersede` replay |
|
||||
| `origin_device`, `origin_label` | Provenance, stamped by edge (interim) or the primary (authoritative) — never by the agent (§7.3) |
|
||||
| `tool_name`, `payload_json` | The MCP call to replay |
|
||||
| `state`, `attempts`, `created_at`, `flushed_at`, `error_json` | Retry/audit |
|
||||
|
||||
Flush = drain in `local_seq` order, stop on first hard failure (preserve ordering), exponential
|
||||
backoff, resume on reconnect. The primary de-dupes on `op_id`; `add_drawer`'s own pre-write probe and
|
||||
`add_triple`'s open-fact guard make replay safe even if `op_id` tracking is lost.
|
||||
|
||||
`write_routing.py` is the upstream-shaped seam for this if we ever want it in core: its
|
||||
`WriteRoutingPolicy{DIRECT,PREFER,REQUIRE}` → `WriteRoutingTarget{DIRECT,DAEMON,BLOCKED}` enums would
|
||||
gain a `REMOTE` target. The module says it "*changes no caller defaults by itself*" — i.e. it exists
|
||||
precisely to be extended.
|
||||
|
||||
### 4.4 Joining an existing primary (bootstrap)
|
||||
|
||||
**Framing correction (2026-08-09).** This is *not* "seed the primary from one chosen palace". Every
|
||||
container joins the same way, at any time, repeatedly — so a join is **idempotent replay of local
|
||||
history**, and the only real question per record type is *what dedupes it*.
|
||||
|
||||
> ⚠️ **Deviation (2026-08-14) — the first join did not follow this section.** It was precisely the thing
|
||||
> the framing correction says this is *not*: a **file-level copy of one chosen palace**, arm64 macOS →
|
||||
> x86-64 Linux, bypassing MCP replay entirely. It worked — search verified live on the primary, HNSW
|
||||
> index intact, KG intact (46 entities / 34 triples). Recorded here so the next operator neither repeats
|
||||
> it blind nor believes it is forbidden. **Four conditions made it safe, and all four must hold:**
|
||||
>
|
||||
> 1. **Same mempalace version (3.6.0) at both ends**, so the on-disk chroma/HNSW layout matched.
|
||||
> 2. **`mempalace_embedder.json` travelled with the palace**, so embedder identity matched. This is the
|
||||
> load-bearing one: a mismatched embedding model does **not** raise — it silently returns garbage
|
||||
> search results, and §7.4 explains why the guard cannot catch it (only the model *name* is compared,
|
||||
> and `dimension: 0` is skipped as unknown).
|
||||
> 3. **A single source palace, so no merge semantics were exercised at all.** This is exactly what makes
|
||||
> the method inapplicable to the second joiner.
|
||||
> 4. **Python's `sqlite3` online-backup API** (`src.backup(dst)`) for the two sqlite DBs — WAL-safe with
|
||||
> a live writer, and the only option available because **neither host has the `sqlite3` CLI**. The
|
||||
> rest of `palace/` was `rsync`ed with `--exclude 'chroma.sqlite3*'`.
|
||||
>
|
||||
> **This sidesteps the merge problem; it does not solve it.** The second machine to join still needs the
|
||||
> replay path and the dedupe keys tabulated below, and still needs §7.6 settled. Three traps found while
|
||||
> doing it: **never `rsync --delete` into `~/.mempalace`** — the server's bearer token lives *inside* that
|
||||
> tree at `~/.mempalace/server/<hash>/token` (§10) and there is no second copy; **palace paths cannot
|
||||
> move**, because the palace directory name is a sha256 prefix of its own path; and compare sizes with
|
||||
> **`stat -c%s`, not `du`** (APFS and ext4 disagree on block accounting, so `du` shows a spurious delta).
|
||||
> Quiesce the writer first — the source palace's own MCP server was live throughout, which is how a write
|
||||
> landed on the far side of the snapshot boundary and produced the false alarm described below.
|
||||
>
|
||||
> **Lineage — this was the *second* file-level copy, not the first (established 2026-08-15).** The
|
||||
> "chosen palace" on EMB-7KJ4VR4G was itself carried over from **EMB-X1JY06WJ**, the operator's previous
|
||||
> work computer, around 2026-07-06 when that machine was replaced. Evidence in the archive: **76 drawers
|
||||
> tagged `source_machine=EMB-X1JY06WJ`**, and they are the oldest in it — earliest `filed_at`
|
||||
> 2026-05-04, two months before the EMB-7KJ4VR4G stack existed — with no other `source_machine` value
|
||||
> present; the remaining ~16k were filed locally afterwards (15,189 in July, 1,073 in August). So the
|
||||
> primary's full provenance is **EMB-X1JY06WJ → EMB-7KJ4VR4G → synlig, by two successive whole-palace
|
||||
> file copies and zero merges.**
|
||||
>
|
||||
> Two consequences worth stating, because this is easy to read as reassuring. First, it **strengthens**
|
||||
> condition 3 rather than weakening it: the method now has two successes behind it, both of them
|
||||
> single-source clones, and still not one exercise of merge semantics — so §7.6 is *less* tested than a
|
||||
> "we've done this twice" reading would suggest. Second, the primary's history contains records from a
|
||||
> machine that no longer exists, so `source_machine` is the only thing distinguishing them; do not treat
|
||||
> it as noise when pruning or re-mining.
|
||||
|
||||
| Record type | Dedupe on replay | Client work needed |
|
||||
| --- | --- | --- |
|
||||
| `add_drawer` / `checkpoint` drawers | **Server-side**: content-addressed id + pre-write `col.get` probe → `{"reason":"already_exists"}`, no write (`mcp_server.py:2593-2600`) | **None.** Just replay |
|
||||
| KG **open** facts | **Server-side**: `add_triple` guard on `(s,p,o) WHERE valid_to IS NULL` | **None.** Just replay |
|
||||
| KG **closed** facts | **None** — the guard is scoped to open facts (§3.2) | Dedupe on `(s,p,o,valid_from,valid_to)` before sending |
|
||||
| **Diary entries** | **None whatsoever** (§7.6) | Skip any local entry whose `sha256(entry)[:12]` suffix already exists remotely |
|
||||
| Mined drawers | id is path-dependent → same content from two hosts = two rows | Out of scope: mined wings are `local` (§5) |
|
||||
|
||||
Existence checks available today, with **no new server code**:
|
||||
|
||||
| Tool | Kind | Fit |
|
||||
| --- | --- | --- |
|
||||
| `get_drawer(id)` | exact id, clean not-found (`mcp_server.py:3103-3114`) | The right check wherever ids are deterministic |
|
||||
| `list_drawers(wing, room, since, before)` | metadata page | Bulk "what does this wing already hold" — the cheap way to collect existing diary id suffixes. Note `since`/`before` filter in **Python**, not in the backend `where` (chroma 1.5.7 rejects string `$gte`/`$lt`) |
|
||||
| `check_duplicate(content, threshold)` | semantic, **whole collection, no wing/room scoping** | One embed + one HNSW query *per call* → the cost driver at thousands-of-records scale. Reserve it for diaries, where nothing cheaper works |
|
||||
| `kg_query` | fact lookup | Redundant for open facts (the guard covers them); useful for closed ones |
|
||||
|
||||
**Two containers on one host are the *easy* case, not the hard one.** They share a bind-mounted palace,
|
||||
so it is *one* palace joining once, and content-addressing makes even a concurrent double-join harmless
|
||||
for everything except diaries. Belt and braces:
|
||||
|
||||
- **Keep join state in the shared palace, not in the container** — e.g. `<palace>/edge/bootstrap.json`
|
||||
holding `{target_url: {joined_at, high_water_local_seq}}`. Both containers then see "this palace has
|
||||
already joined", a recreate does not repeat the work, and it is the same durable-state-next-to-the-data
|
||||
pattern as the outbox (§4.3). It must **not** live in a container-only path: `~/.mempalace` is not
|
||||
preserved by default for solitary users (§1.2), which is exactly why the file belongs to the palace
|
||||
directory.
|
||||
- **First join is a dry run.** Bootstrap one palace, verify it, *then* let the rest join. Ordering matters
|
||||
only because of diaries and closed facts; everything else is order-free.
|
||||
|
||||
> ⚠️ **Corrected 2026-08-14. This bullet used to say "verify counts (`status`, `kg_stats`, per-wing
|
||||
> `list_drawers`) against expectations". Do not verify a join by counts.** Two independent reasons,
|
||||
> both learned on the first real seed:
|
||||
>
|
||||
> 1. **`mempalace status` counts chunk rows, not logical drawers.** Three drawers plus one 2-chunk diary
|
||||
> presented as **+9**. A count delta cannot even tell you how many *records* moved.
|
||||
> 2. **Chunk counts and chunk-id sets legitimately differ between two palaces** whenever a drawer was
|
||||
> updated on either side: an update preserves `drawer_id`, **re-chunks to the new length, and deletes
|
||||
> the surplus chunk rows** (§9.3). A `…_chunk_000007` present on one side and absent on the other is
|
||||
> therefore the ordinary signature of an **edit**, not of loss.
|
||||
>
|
||||
> On 2026-08-14 that second mechanism produced a confident "the seed lost a chunk" conclusion that was
|
||||
> filed as a finding before being retracted. Counts **hid** the difference (14,777 vs 14,778 looked like
|
||||
> one lost row); an id-set diff **over-reported** it. The two methods fail in *opposite* directions, so
|
||||
> agreeing with either one alone proves nothing.
|
||||
>
|
||||
> **Verify by content.** For a sample of drawers, `get_drawer(<parent id>)` on *both* palaces and compare
|
||||
> the **reassembled `content`**. An id-set diff is a fine first pass to *find* candidates, but it must be
|
||||
> adjudicated by a content comparison before anyone concludes loss. Two cheap decisive checks once a
|
||||
> difference is real: **is one side a prefix of the other** (that, and only that, is truncation), and
|
||||
> **does the final chunk's length equal `len(content) - chunk_size * (n_chunks - 1)`** (800 in 3.6.0)? If
|
||||
> both sides satisfy the arithmetic for their own content, both are complete and you are looking at two
|
||||
> revisions, not damage.
|
||||
|
||||
The genuinely hard case is **two different palaces holding overlapping mined content** — the same repo
|
||||
mined on a laptop and a workstation under different absolute paths. §5 excludes it by keeping mined wings
|
||||
`local`; that exclusion is load-bearing, not tidiness.
|
||||
|
||||
---
|
||||
|
||||
## 5. Not everything should be global
|
||||
|
||||
Per-wing replication policy, declared in edge config:
|
||||
|
||||
| Policy | Wings | Rationale |
|
||||
| --- | --- | --- |
|
||||
| `replicated` | diaries, `wing_pi`, checkpoints, KG, hand-authored notes | Small, curated, content-addressed → **already merge-safe** |
|
||||
| `local` | mined code/docs (e.g. the 13,597-drawer `workspace` wing on this host) | Derived data, re-mineable from git, and carries exactly the path-dependent IDs that break merging (§3.3) |
|
||||
|
||||
This shrinks the hard problem to the layer that is already safe, and it is why **`mine` must never be
|
||||
remote**: the primary serializes *every* request behind one lock (§7.5), so a bulk remote mine would
|
||||
stall every other agent in the fleet.
|
||||
|
||||
**Decided 2026-08-09: diaries are `replicated`.** Cross-machine continuity is the entire point, and
|
||||
diaries are the highest-value content in the palace to share ("*given their value, I say go with (a)*").
|
||||
Accepted consequence, stated plainly because it follows from the primary being **synlig, a work VM**
|
||||
(§8.1): personal diaries will live on employer infrastructure. They already quote internal hostnames,
|
||||
paths, moods and the occasional token prefix (§6.1 threat 2) — so "don't put secrets in memory" stops
|
||||
being advice and becomes a precondition.
|
||||
|
||||
**The work/personal boundary is a property of the wing, not of the device.** Rejected design
|
||||
(2026-08-09): splitting the fleet into a work primary and a personal primary along machine lines. The
|
||||
reasoning is decisive — pi-devbox/opencode-devbox are *simultaneously* work and home projects, and
|
||||
personal machines get used for work-adjacent work, so **the device where the work happened cannot
|
||||
classify the project**. That is precisely the axis this table already encodes, which is why
|
||||
`replicated`/`local` per wing is the right knob and a second primary is not needed to express it.
|
||||
|
||||
**Shape the config so multiple stores stay possible without paying for them now.** Phase 1 keeps
|
||||
`MEMPALACE_REMOTE_URL` a **scalar** (one primary). Per-wing *targets* — this same policy column, plus a
|
||||
destination — belong to the edge config in Phase 2+, so nothing in the `.env` contract has to be
|
||||
un-designed later.
|
||||
|
||||
---
|
||||
|
||||
## 6. Security model
|
||||
|
||||
The transport is largely solved; **the gap is authorization, not cryptography.**
|
||||
|
||||
### 6.1 Threats, in priority order
|
||||
|
||||
1. **Memory poisoning / persistent cross-machine prompt injection — the underrated one.** Palace
|
||||
content is injected into agent context at wake-up. A shared palace means one careless or
|
||||
compromised container can plant instructions that *every other agent in the fleet reads as trusted
|
||||
memory*. Mitigation: server-stamped provenance on every synced record (§7.3.2), don't auto-inject wake-up content authored
|
||||
by devices outside a trusted set, keep an admin-only wing for anything instruction-shaped.
|
||||
2. **Aggregation raises exfiltration impact.** One primary holds every machine's diaries — which
|
||||
already quote internal hostnames, paths and token prefixes. Mitigation: per-wing ACL; don't put
|
||||
secrets in memory (the WAL redaction list exists for a reason); private-network-only exposure.
|
||||
3. **Accidental mass deletion** by a client (`sync`, `delete_by_source`) — see §7.2.
|
||||
4. **Availability**: the server is single-writer by design; one long operation blocks everyone.
|
||||
|
||||
### 6.2 Policy
|
||||
|
||||
| Control | Decision |
|
||||
| --- | --- |
|
||||
| **Network posture** | Primary **never** internet-exposed. Publish only onto the private overlay / existing tunnel (Pangolin/newt). ⚠️ **The reflexive "bind loopback in the container" is the failure mode here — see Transport.** |
|
||||
| **Transport** | **Decided 2026-08-09: terminate TLS in the existing Pangolin/newt tunnel**, not in `serve` (cheaper than patching Python's TLS surface; one DNS record per service on the web hotel is the whole setup cost). ✅ **Host/Origin policy verified by experiment on synlig 2026-08-10, 11/11 as predicted** (runbook §2.4) — the summary: **do not bind loopback behind the tunnel.** `enforce_host_pin = _http_is_loopback(host)` (`mcp_server.py:5367`), so a loopback bind + a proxy forwarding `Host: palace.example.com` → **403**, while a non-loopback bind → **200** (pin deliberately relaxed, "*may sit behind a proxy that rewrites Host*", `:5362-5365`); tokenless non-loopback binds require `--allow-insecure` (`cli.py:1450`). **Prefer binding the docker0 gateway (e.g. `172.17.0.1`) over `0.0.0.0`:** non-loopback, so the pin relaxes, yet reachable only from the host and its containers — so a newt container on the box reaches it and the LAN cannot. **The `Origin` check is never relaxed:** absent `Origin` is fine (every non-browser MCP client, incl. pi and opencode), a *present* non-loopback `Origin` is 403 with no override — so keep browser-based clients and `Origin`-injecting proxies out of the path. `/healthz` is Host/Origin-gated but token-free, so it works as the tunnel's liveness probe. |
|
||||
| **Authentication** | Target: **per-device bearer tokens** with a server-side `token → {device_id, scopes}` registry — revoke one laptop, rotate without a fleet outage. **Decided 2026-08-09: Phase 1 ships the single shared token** ("*iterate more feature rich but more complex solutions over time*"), so per-device lands with Phase 4. Store in the existing `.env.age` flow, 0600 on disk. **Consequence: until then the primary cannot tell devices apart, so `origin_device` stays client-asserted and advisory — nothing load-bearing may depend on it (§7.3.2).** |
|
||||
| **Authorization** | Per-device read/write **wing globs**. Server-side refusal of `mine`/`sync`/`delete_*` except for an admin device. `--read-only` gives a free observer tier. |
|
||||
| **Provenance/audit** | `origin_device` (+ optional `origin_label`) + `op_id` on every record, stamped **server-side from the authenticated credential** — a client-asserted origin is a hint, not a fact (§7.3.2); server-side append-only op log. |
|
||||
| **Recovery** | Deletes as tombstones; server-side backups with `backups.py` retention (`MEMPALACE_MAX_BACKUPS`). |
|
||||
| **DoS** | Keep the 16 MiB cap; add rate limiting; no remote `mine`. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Landmines — Phase 0 runbook
|
||||
|
||||
Do these **before** any cutover. 7.1 and 7.2 cause silent data loss; 7.6 causes silent *duplication*
|
||||
the first time a palace joins (§4.4).
|
||||
|
||||
### 7.1 `MEMPALACE_PALACE_PATH` ≠ `--palace` (silent empty KG)
|
||||
|
||||
```python
|
||||
# mcp_server.py:708-711 (_palace_flag_given = bool(_args.palace), line 325)
|
||||
def _resolve_kg_path() -> str:
|
||||
if _palace_flag_given:
|
||||
return os.path.join(_config.palace_path, "knowledge_graph.sqlite3")
|
||||
return DEFAULT_KG_PATH # knowledge_graph.py:49 → ~/.mempalace/knowledge_graph.sqlite3
|
||||
```
|
||||
|
||||
`cmd_serve` **always** passes `--palace`. So the moment you start the server, the KG becomes
|
||||
`<palace>/knowledge_graph.sqlite3` — a *different file* from the live `~/.mempalace/knowledge_graph.sqlite3`.
|
||||
The vector store looks fine and the knowledge graph is silently empty.
|
||||
|
||||
Four stores, **three** location rules:
|
||||
|
||||
| Store | Location rule |
|
||||
| --- | --- |
|
||||
| drawers | backend-abstracted (`BaseCollection`) |
|
||||
| `knowledge_graph.sqlite3` | HOME-anchored **unless `--palace` flag** (`knowledge_graph.py:49`) |
|
||||
| `hallways.json` | derived from `palace_path`, with legacy HOME fallback (`hallways.py:73,83`) |
|
||||
| `known_entities.json` | HOME-anchored (`miner.py:701`) |
|
||||
|
||||
**Action:** `mv` all three files into the palace directory before first `serve`, and verify
|
||||
`kg_stats` is non-empty afterwards. Corollary: **a shared pgvector/qdrant backend shares drawers and
|
||||
nothing else** — it would leave the KG as fragmented as it is today. That is why the shared-backend
|
||||
option is *not* the answer.
|
||||
|
||||
⚠️ **Understated above, corrected 2026-08-10 while provisioning synlig: this is not a migration hazard,
|
||||
it is the stock-default behaviour, and it is permanent.** `DEFAULT_PALACE_PATH` is
|
||||
`~/.mempalace/palace` (`config.py:221`) while `DEFAULT_KG_PATH` is
|
||||
`~/.mempalace/knowledge_graph.sqlite3` (`knowledge_graph.py:49`) — **those differ out of the box**, with no
|
||||
custom path involved. So on *every* host, forever: `serve` (always passes `--palace`) uses the KG inside
|
||||
the palace, while any CLI command run without `--palace` uses the HOME one. A one-time `mv` does not fix
|
||||
that; it just relocates which of the two files is populated.
|
||||
|
||||
**Better action — converge the two resolution rules onto one inode** (and the only option on a greenfield
|
||||
primary, where there is nothing to move):
|
||||
|
||||
```sh
|
||||
ln -sfn palace/knowledge_graph.sqlite3 ~/.mempalace/knowledge_graph.sqlite3
|
||||
ln -sfn palace/known_entities.json ~/.mempalace/known_entities.json
|
||||
ln -sfn palace/hallways.json ~/.mempalace/hallways.json # added 2026-08-14, see note
|
||||
```
|
||||
|
||||
> **Updated 2026-08-14 — `hallways.json` is symlinked too now.** This block previously said it was
|
||||
> *deliberately* not symlinked, on the narrow grounds that it is already palace-derived and its HOME path
|
||||
> is a warning-only legacy probe that never auto-migrates (`hallways.py:73-95`). Both facts still hold —
|
||||
> but the goal changed from "symlink only what the code demands" to **"all real state lives under
|
||||
> `palace/`, so one copy of `palace/` is a complete copy"**, which is what let the 2026-08-14 file-level
|
||||
> seed (§4.4) treat the palace as a single self-contained unit. With all three links in place every
|
||||
> parent-level path resolves, and 3.6.0's *three different* resolution rules — palace-relative for the
|
||||
> served KG, HOME for the CLI KG, `dirname(palace_path)` for hallways, hardcoded HOME for
|
||||
> `known_entities.json` — converge on one set of files. Revert by deleting the symlink if it ever causes
|
||||
> trouble.
|
||||
|
||||
Verified on synlig 2026-08-10, because the WAL behaviour was the load-bearing assumption: a **dangling**
|
||||
symlink is created on first `sqlite3.connect`; `-wal`/`-shm` land next to the **target** (inside the palace
|
||||
dir, so the palace stays a self-contained backup/bind-mount unit) and *not* beside the symlink; a write
|
||||
through one path reads back through the other; **same inode**. Full record in
|
||||
[`synlig-primary-runbook.md`](./synlig-primary-runbook.md) §2.3.
|
||||
|
||||
### 7.2 Never run `mempalace sync` against a shared palace
|
||||
|
||||
It classifies drawers whose source files are absent **on the running host** as orphans and deletes
|
||||
them; `_auto_detect_project_roots` guesses roots from drawer metadata, so the blast radius is
|
||||
data-dependent rather than obvious. From a laptop that lacks the repos, it is a fleet-wide wipe.
|
||||
**Action:** edge blocks `mempalace_sync` from ever reaching the primary; document it; consider an
|
||||
upstream `--refuse-shared` guard.
|
||||
|
||||
> **Measured on the live primary, 2026-08-15 — the threat model is not where this section puts it.**
|
||||
> An unscoped `mempalace_sync` dry-run against central (14,829 drawers) reports
|
||||
> `out_of_scope: 14391`, `no_source: 438`, **`missing: 0`, `kept: 0` — so zero drawers are currently
|
||||
> deletable.** The mechanism matters: a source root that is *entirely* absent yields **`out_of_scope`,
|
||||
> not `missing`**, and only `missing`/`gitignored` drawers are removed. A wipe therefore needs the root to
|
||||
> **exist** while the files under it do not — not a host that simply lacks the repos.
|
||||
>
|
||||
> Two corrections follow. **(a) The "laptop" scenario is currently blocked twice over**, and neither
|
||||
> guard was designed for this: the CLI has no remote support at all, so `mempalace sync` on a client
|
||||
> physically cannot reach central; and the MCP `mempalace_sync` tool executes **server-side on synlig**,
|
||||
> where clients' `/workspace/...` roots do not exist — hence `out_of_scope`. Do not mistake this for
|
||||
> safety by design; it is safety by coincidence of layout, and it decays.
|
||||
> **(b) The real hazard arrives with the transcript feeder's remote mode.** That mode rsyncs staged
|
||||
> transcripts into per-device inboxes **on synlig**, so those source files *will* exist on the palace
|
||||
> host — making them in-scope for the first time. Any later stage rotation, cleanup, or per-device inbox
|
||||
> removal then reclassifies that device's conversation drawers as `missing`, i.e. **prunable, and
|
||||
> prunable by a sync triggered from a different device.** `bin/mempalace-pi-session` already documents the
|
||||
> local form of this ("do NOT run `mempalace sync` while the stage is missing, or the drawers mined from
|
||||
> it get pruned"); shared-palace multi-device turns it cross-device. **Settle stage retention on synlig,
|
||||
> and the sync guard, as part of enabling the feeder fleet-wide — not after.**
|
||||
|
||||
### 7.3 Provenance belongs to the sync boundary — not to the agent, and not to a solitary container
|
||||
|
||||
Today every drawer carries exactly `{wing, room, source_file, added_by, filed_at, id_recipe}`
|
||||
(+`chunk_index`, `parent_drawer_id`); diaries add `{hall, topic, type, agent, date}`. `added_by`/`agent`
|
||||
is the *agent* name (`pi`, `mcp`, `checkpoint`) — **never the machine**. So in a merged store you cannot
|
||||
tell which host wrote a record, cannot audit, and cannot compute per-device high-water marks.
|
||||
|
||||
**Verified 2026-08-09 (`mcp_server.py:2578-2585` drawers, `3527-3536` diaries): those lists are
|
||||
exhaustive — there is no session, PID or conversation field either.** Two consequences, because both are
|
||||
natural questions:
|
||||
|
||||
- **Two concurrent pi sessions** (the tmux pattern pi's own docs suggest) writing to one palace are
|
||||
**indistinguishable**. Nothing records which session produced which record.
|
||||
- **pi vs opencode is only *accidentally* distinguishable.** `added_by` is a free-form optional string
|
||||
(default `"mcp"`; `checkpoint` resolves explicit arg → diary `agent_name` → `"checkpoint"`), and
|
||||
`extensions/pi/mempalace.ts` **never sets it** for `add_drawer`/`checkpoint` — it sets identity only for
|
||||
diaries (`agent_name` from `$MEMPALACE_AGENT_NAME`, default `pi`, `:758`). `kg_add` has no attribution
|
||||
field at all. So the *only* real harness attribution today is the diary wing, and for drawers the value
|
||||
is whatever string an LLM happened to pass.
|
||||
|
||||
**Design consequence: device + agent, never session.** Provenance has exactly three consumers — poisoning
|
||||
triage ("which box planted this?"), per-device high-water marks, and revocation — and none of them needs
|
||||
session granularity; adding it would put a field on every record with no reader. Under §6's per-device
|
||||
tokens, **provenance granularity equals token granularity**: one token per host makes two containers on
|
||||
that host a single origin, while a token per container separates them. §7.3.4's `<agent>/<label>/<device>`
|
||||
encoding keeps the two axes orthogonal, so pi-vs-opencode stays recoverable — but only if the **edge**
|
||||
populates the agent segment, never the LLM.
|
||||
|
||||
**Earlier drafts of this section said "stamp it everywhere, now, because it cannot be backfilled." That
|
||||
was wrong on both counts.** See §7.3.3.
|
||||
|
||||
#### 7.3.1 What can actually be stamped (mechanics)
|
||||
|
||||
The metadata schema is **fixed** — there is no free-form field — and `tools/call` **whitelists arguments
|
||||
to declared schema properties** (`mcp_server.py:4777`, *"Prevents callers from spoofing internal params
|
||||
like added_by/source_file"*), so an extra `origin_host=…` is **silently dropped, not rejected**.
|
||||
|
||||
| Surface | Provenance slot | Notes |
|
||||
| --- | --- | --- |
|
||||
| `add_drawer`, `checkpoint` | **`added_by`** | The only one. Free-form (`strip_lone_surrogates` only, *not* `sanitize_name`, so `/` and `@` are legal). |
|
||||
| `diary_write` | **none usable** | ⚠️ **Never** put a device in `agent_name`: `:3504` does `wing = f"wing_{agent_name}"` → a separate wing per host, and `diary_read` filters `{"agent": agent_name}` (`:3636`) → `diary_read("pi")` then **misses** those entries. |
|
||||
| `kg_add` | **none** | Only `source_file`/`source_closet`/`source_drawer_id`. Origin is inferable only via `source_drawer_id` → that drawer's `added_by`. |
|
||||
|
||||
`added_by` is also **write-only today**: absent from `tool_search` results, surfacing only via
|
||||
`get_drawer` → `_drawer_payload` metadata. → Upstream asks: **surface `added_by` in search results**, and
|
||||
**give `kg_add` a provenance field**.
|
||||
|
||||
#### 7.3.2 Who should stamp it — a ladder of trust
|
||||
|
||||
Provenance answers "which device asserted this?" Only something that can *verify* the answer should
|
||||
write it. Ranked by trustworthiness:
|
||||
|
||||
| Stamper | Knows the device? | Verifiable? | Uniform? | Verdict |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Agent (via skill)** | No — must shell out to read env | No | No — per-call boilerplate, forgettable, improvisable | ❌ **Worst possible place.** Rejected. |
|
||||
| **Client / `mempalace-edge`** | Yes, from host-supplied `.env` | No — self-asserted | Yes — one line in a proxy | ⚠️ Acceptable **interim** |
|
||||
| **Primary, from the authenticated credential** | Yes | **Yes** — bound to the token | Yes, for every synced record | ✅ **Correct home** |
|
||||
|
||||
The decisive point: **a client-asserted origin is a hint, not a fact.** The primary is the only party
|
||||
that can bind a write to an identity it verified. And under the §4 design *every* write reaches the
|
||||
primary through an authenticated channel — including offline ones, at outbox-flush time — so the server
|
||||
can stamp the complete set without any client cooperation. **Provenance is a property of the sync
|
||||
channel, not of the record's author.**
|
||||
|
||||
Blocker for the ✅ row, verified in 3.6.0: `serve` takes a **single shared bearer token**
|
||||
(`srv.auth_token`, `hmac.compare_digest`, `mcp_server.py:5291-5293`) and the package contains **zero**
|
||||
occurrences of any device/origin concept. Server-side stamping therefore *requires* the per-device
|
||||
credentials of §6 — i.e. **Phase 4**, not Phase 1. Hence the phasing:
|
||||
|
||||
- **Phase 2 (edge):** edge may stamp `added_by` from its configured env — self-asserted, **advisory
|
||||
only**, never load-bearing for authorization or destructive scoping.
|
||||
- **Phase 4 (authz):** per-device tokens land; the primary stamps authoritatively and the client-supplied
|
||||
value becomes redundant (and must be treated as untrusted input, not merely ignored).
|
||||
|
||||
#### 7.3.3 Solitary containers should stamp nothing — and lose nothing by it
|
||||
|
||||
A pi-devbox or opencode-devbox running solitarily is a **single-origin store by definition**. Origin is
|
||||
therefore a property of the *whole palace*, not of each record — so it can be assigned **wholesale at
|
||||
the moment the store stops being solitary**: one `--origin-device` flag on the import/first-sync path
|
||||
attributes every record from that palace to that device.
|
||||
|
||||
That dissolves the "impossible to backfill" argument. Per-record stamping is only necessary once records
|
||||
from *multiple* origins are interleaved in one store, which is exactly and only the primary. So:
|
||||
|
||||
- **Solitary devboxes: no stamping, no config, no skill instruction, no behaviour change.** This is
|
||||
strictly more compliant with **R1** than the earlier draft, which quietly asked every solitary user to
|
||||
carry metadata for a feature they had opted out of.
|
||||
- **Migration is unaffected**: bulk attribution at import is *more* reliable than per-record stamping,
|
||||
because it cannot be partially applied.
|
||||
- **Multi-harness on one host is solved *in principle*** by `added_by` = agent name (`pi` vs `opencode`) —
|
||||
that is what the field is for, and it needs no device component. ⚠️ **Corrected 2026-08-09: nothing
|
||||
actually sets it.** The pi extension leaves `added_by` at its default for `add_drawer`/`checkpoint`, so
|
||||
in practice the value is whatever an LLM passed. The field is the right home; the client-side write that
|
||||
populates it is missing, and it belongs to the edge (the ⚠️ row of §7.3.2) — not to a skill instruction.
|
||||
- **A palace on a shared host bind-mount** (as tor-ms22's compose does) is still single-*device* under
|
||||
the "host owns the palace" model, so it too imports as one origin.
|
||||
|
||||
The one case bulk attribution cannot fix: a palace that was *already* merged from several devices without
|
||||
stamps. Preventing that is precisely why the **primary** must stamp from day one of Phase 4 — it is the
|
||||
only store where interleaving occurs.
|
||||
|
||||
#### 7.3.4 Identity fields, when a stamper does exist
|
||||
|
||||
For the edge (interim) and the primary (authoritative) — never for agents:
|
||||
|
||||
| Field | Source | Rule |
|
||||
| --- | --- | --- |
|
||||
| `origin_device` | `uuidgen` **once**, stored in the **host's `.env`**; later, issued with the device's token | Opaque, stable, collision-free by construction. Compared, never parsed. |
|
||||
| `origin_label` | hostname, same `.env` | Human readability **only**. Never identity, uniqueness or scoping. Free to change. Optional — a hostname can leak an asset tag or username (`EMB-7KJ4VR4G`, `HOST_SSH_USER=ECSJPER`). |
|
||||
|
||||
Why not the obvious sources: **a container cannot discover its host's identity.** `hostname` returns the
|
||||
*container ID* (`f3bf2a103473`) which changes on **every recreate**; there is no `/etc/machine-id`; and a
|
||||
baked one would be *worse* — identical for every container from the same image, a guaranteed collision.
|
||||
**Hostnames are also neither unique nor stable** (`localhost`, `ubuntu`, golden images, two
|
||||
`MacBook-Pro.local`), and the worse failure is not collision but **rename**, which silently splits one
|
||||
device's history in two. Hence host-supplied via `.env` (matching R2/R3 and the existing `HOST_SSH_USER`
|
||||
/ `DEVBOX_HOST_ALIAS` precedent), *not* container-derived. Do **not** persist the id container-side:
|
||||
`~/.mempalace/device_id` does not survive recreate for solitary users because `devbox-palace` is
|
||||
commented out by default (§1.2). And don't write `${HOSTNAME}` in compose — bash *sets* but does not
|
||||
*export* it, so interpolation sees empty.
|
||||
|
||||
Encoding, always three segments so arity is unambiguous:
|
||||
|
||||
```
|
||||
added_by = "<agent>/<label>/<device>" e.g. pi/tor-ms22/7f3a9c2e1b04
|
||||
pi/-/7f3a9c2e1b04 (label withheld)
|
||||
```
|
||||
|
||||
**The `/` is the discriminator.** A value with no `/` (`mcp`, `pi`, `checkpoint`) means *origin unknown* —
|
||||
correct for the ~13.6k drawers already filed, and for every solitary palace forever.
|
||||
|
||||
#### 7.3.5 Fail-closed rule for any stamper
|
||||
|
||||
If `origin_device` is unset, blank or unreadable: **write no `added_by` override at all** and let the
|
||||
default (`mcp` / `checkpoint` / agent name) stand. Never synthesize one. The tempting substitutes are all
|
||||
actively harmful, because each is *confidently* wrong where absence is honestly unknown:
|
||||
|
||||
| Substitute | Damage |
|
||||
| --- | --- |
|
||||
| container hostname / ID | A **new fake device per recreate** — thousands of singleton identities, each looking legitimate |
|
||||
| `unknown`, `localhost`, `devbox`, `docker`, `$USER` | **Collides across every machine** — indistinguishable from one real shared device |
|
||||
| a guess from context | Unfalsifiable later |
|
||||
|
||||
If `origin_label` alone is missing, use the literal `-`. If `origin_device` is missing, do not stamp even
|
||||
when the label is present — a hostname alone is exactly the colliding, mutable identity rejected above.
|
||||
|
||||
> **Note for future agents reading this RFC out of the palace:** these notes are themselves a poisoning
|
||||
> vector. Do **not** start hand-stamping `added_by` because you read this section. Provenance is client
|
||||
> and server infrastructure; an agent's contribution to it is to leave the field alone. The mempalace
|
||||
> skill carries a one-line guard to that effect.
|
||||
|
||||
### 7.4 Embedder identity is client-local and currently toothless
|
||||
|
||||
`check_embedder_identity()` raises `DimensionMismatchError` / `EmbedderIdentityMismatchError`, but the
|
||||
sidecar on this host reads `{"mempalace_drawers": {"model_name": "minilm", "dimension": 0}}` — and
|
||||
`dimension: 0` means *unknown* and is **skipped**, so only the model *name* is compared. The sidecar
|
||||
lives in the client's palace dir (`_sidecar.py`), so with a remote backend the check is per-client and
|
||||
never centrally enforced. **Action:** the primary must reject writes from a client whose embedder
|
||||
identity does not match, and merged reads (§4.2) must be disabled on mismatch — comparing distances
|
||||
across models silently degrades recall.
|
||||
|
||||
### 7.5 Concurrency expectations
|
||||
|
||||
- `_HTTP_REQUEST_LOCK = threading.Lock()` wraps **every** dispatch (`mcp_server.py:5115`, held at
|
||||
`5346`/`5513`), commented: "*HTTP gives us a safer transport, not concurrent Chroma/HNSW mutation.*"
|
||||
The primary is a **single-writer, one-request-at-a-time** service. Size for a handful of agents.
|
||||
- `mine_palace_lock` uses `fcntl.flock(LOCK_EX|LOCK_NB)` and exists because parallel HNSW inserts
|
||||
"*can corrupt the HNSW graph*" — but the lock **file** is HOME-derived (`~/.mempalace/locks/
|
||||
mine_palace_{sha256(realpath(palace))[:16]}.lock`) while the key is palace-derived. Two containers
|
||||
with different `~/.mempalace` mounts but the same palace path compute the same key on *different
|
||||
files* → **no mutual exclusion**.
|
||||
- **Therefore: reject "put the palace on a NAS/SMB share and point everyone at it."** flock over
|
||||
NFS/SMB is unreliable, and the lock wouldn't be shared anyway. One process owning the files and
|
||||
serving HTTP is the supported model — which is exactly why `serve` exists.
|
||||
|
||||
### 7.6 `diary_write` has no idempotency guard — replay duplicates every entry
|
||||
|
||||
```python
|
||||
# mcp_server.py:3511-3513, then 3546
|
||||
entry_id = f"diary_{wing}_{now:%Y%m%d_%H%M%S%f}_{hashlib.sha256(entry.encode()).hexdigest()[:12]}"
|
||||
...
|
||||
col.add(ids=[entry_id], ...) # not upsert — and no col.get probe anywhere in tool_diary_write
|
||||
```
|
||||
|
||||
Compare `add_drawer` 900 lines earlier, which probes `col.get(ids=idempotency_probe_ids)` and returns
|
||||
`{"reason": "already_exists"}` without writing (`:2593-2600`). The contrast *is* the finding: **the µs
|
||||
timestamp makes every diary id unique by construction, and nothing checks the content.** So §3's
|
||||
conclusion — log the intent, replay the intent — is **false for diaries**; for them, replay is
|
||||
duplication.
|
||||
|
||||
This matters more than it sounds, because diaries are `replicated` (§5) and are precisely the content a
|
||||
join replays (§4.4).
|
||||
|
||||
> ⚠️ **Still open as of 2026-08-14 — and the first join did not test it.** The seed was a file-level copy
|
||||
> (§4.4 Deviation), which replays no diaries and therefore *cannot* duplicate them. **§7.6 was sidestepped
|
||||
> by method choice, not resolved.** Neither action below has been built. The moment a *second* palace
|
||||
> joins — by any replay-based route — this becomes live again, and it is the single most likely thing for a
|
||||
> future operator to get wrong, because the first join appears to have proved the path safe. It did not:
|
||||
> it avoided the path.
|
||||
|
||||
**Actions:**
|
||||
|
||||
1. **Client-side, now:** before replay, list the target's diary drawers for the wing, extract the 12-hex
|
||||
`sha256(entry)[:12]` suffix from each existing id, and skip any local entry whose suffix already
|
||||
appears. The suffix is already content-addressed — no new key needs inventing.
|
||||
2. **Upstream ask:** make `diary_write` probe-and-skip on that suffix the way `add_drawer` does on its
|
||||
content hash. A small, self-contained patch in the sibling function's own idiom, and it closes the gap
|
||||
for *every* future writer instead of for one migration script.
|
||||
3. **Do not "fix" this by making the agent dedupe.** An agent cannot know what the primary holds without a
|
||||
call, and per-call boilerplate is exactly the failure mode §7.3.2 rejects.
|
||||
|
||||
---
|
||||
|
||||
## 8. Phasing
|
||||
|
||||
| Phase | Effort | Deliverable |
|
||||
| --- | --- | --- |
|
||||
| **0 — hygiene** | hours | §7 runbook: converge the KG/entities store paths **on synlig before first `serve`** (§7.1 — **done 2026-08-10**, runbook §2.3), ban `sync` on shared palaces (§7.2), ~~fix stale "unauthenticated" docs (incl. `pi-devbox/.env.example:21`)~~ — **done 2026-08-12**, and `docker-compose.mempalace.yml` turned out to be outright broken on 3.6.0, now fixed. Added 2026-08-09: settle the **diary dedup** approach and file its upstream ask (§7.6), and **dry-run the join from one palace only** (§4.4). ✅ **Both done 2026-08-14, with one asterisk that matters:** the join was dry-run *and* executed from one palace only — but **§7.6 was sidestepped, not settled** (a file-level copy replays no diaries, so it cannot duplicate them). The §7.6 client-side dedupe and its upstream ask are **still unbuilt and are hard blockers for the second joiner.** **No provenance work here** — it is not backfill-critical (§7.3.3) and belongs to the stamper, not the agent |
|
||||
| **1 — primary up** ✅ **done 2026-08-14** | hours, **no code** | `mempalace serve --token --tls-cert` on a private-net host (reuse `docker-compose.mempalace.yml` — keep it a **separate standalone project**, R4 — and mind port 8765 vs pi-studio; tor-ms22 already moved to 8766). Repoint pi clients via `MEMPALACE_REMOTE_URL`/`MEMPALACE_REMOTE_TOKEN`. **opencode clients can be repointed in the same breath** — remote MCP is supported and `generate-config.py` already emits it (§9.1, §9.6), subject to the sidecar caveat in §4.1. **Shared memory today, no offline.** **Decided 2026-08-09: primary = synlig, TLS at Pangolin, single shared token (§8.1)** — mind the loopback Host-pin trap in §6.2. |
|
||||
| **1.5 — opencode env propagation** | hours | Make the `mcp.mempalace` subtree env-authoritative in `generate-config.py`, gated by a generated-value fingerprint (§4.1). Independent of the rest of this RFC. Without it, adopting *or reverting* the opt-in on an existing opencode container needs a manual sidecar merge or a `docker volume rm` — which also blocks R6 reversibility |
|
||||
| **2 — `mempalace-edge`** | ~1 week | The actual ask: local-first writes + outbox flush + merged reads + per-wing policy. **Not** "fixes opencode" — opencode's *transport* is already fine after Phase 1; what edge adds there is offline/local-first, since `generate-config.py`'s switch is remote **or** local with no failover. **Ships with the §1.2 opt-in wiring (compose + `.env.example` + a third branch in the existing `generate-config.py`) and must pass the R1 acceptance test.** |
|
||||
| **3 — pull replication** | ~1 week | **DEFERRED (2026-08-08).** Server-side op-log with monotonic seq → each edge a full offline replica. Only needed if a laptop must hold *everything* offline. Accepted trade-off: offline recall = own writes + last-reachable state. |
|
||||
| **4 — authz** | days | Per-wing ACL, per-device scopes, audit log, token rotation |
|
||||
|
||||
Phase 1 is worth doing on its own — it is pure configuration and immediately ends KG fragmentation
|
||||
for online clients.
|
||||
|
||||
### 8.1 Deployment decisions (2026-08-09)
|
||||
|
||||
| Decision | Value | Note |
|
||||
| --- | --- | --- |
|
||||
| **Primary host** | **synlig** — a VM in Xerces (work OpenStack cloud) | Chosen for always-on + good connectivity, *not* for work/personal reasons. Consequence: replicated content, diaries included, lives on employer infrastructure (§5, §6.1 threat 2) |
|
||||
| **TLS / ingress** | **Pangolin/newt**, one DNS record per service on the web hotel | Not `serve --tls-cert`. See the bind-address/Host-pin coupling in §6.2 |
|
||||
| **Authentication** | **Single shared token** for now | Per-device deferred to Phase 4; `origin_device` stays advisory until then |
|
||||
| **Diaries** | **`replicated`** | Highest-value cross-machine content; accepted placement consequence (§5) |
|
||||
| **Work/personal split** | **per-wing, not per-device** | Rejects two primaries split along machine lines — the device cannot classify the project (§5) |
|
||||
| **Multi-store** | Possible later, not now | `MEMPALACE_REMOTE_URL` stays a scalar in Phase 1; per-wing targets are an edge-config concern (Phase 2+) |
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions
|
||||
|
||||
1. ~~**Does opencode's MCP config support a remote/HTTP transport at all?**~~ **RESOLVED 2026-08-09:
|
||||
YES.** opencode's published JSON Schema (`https://opencode.ai/config.json`) defines
|
||||
`$defs.McpRemoteConfig` = `{type:"remote" (enum), url (required), headers?: {string:string},
|
||||
oauth?, enabled?, timeout?}` as a sibling of `McpLocalConfig` inside
|
||||
`Config.properties.mcp.additionalProperties.anyOf`. So `headers` carries any bearer/API-key scheme,
|
||||
and set `oauth: false` to stop opencode probing the URL for OAuth. The earlier "every sampled entry
|
||||
is `type:local`" observation was **deployment evidence being read as schema evidence** — the schema
|
||||
settles it in one fetch. **Consequence: the edge proxy is *not* the only option for opencode.** A
|
||||
plain Phase 1 `mempalace serve --token` can be consumed directly, so nothing in the phasing depends
|
||||
on this question any more. What opencode still lacks is *local-first writes and offline fallback* —
|
||||
`generate-config.py`'s switch is remote **or** local per container, with no failover — and that is
|
||||
the real Phase 2 justification for it.
|
||||
2. **Upstream or local?** `mempalace-edge` needs no core changes, so it belongs in this repo
|
||||
(`bin/` + `extensions/`). But `origin_device` stamping from the authenticated token, per-wing ACL, the `kg_supersede`
|
||||
classification fix, and a `sync --refuse-shared` guard all want to go **upstream**
|
||||
(`github.com/MemPalace/mempalace`).
|
||||
3. **Chunked drawers under merge.** ~~Oversized content splits into `{drawer_id}_chunk_NNNNNN` with
|
||||
`parent_drawer_id`, and the add-time idempotency probe checks only the *last* chunk id — a
|
||||
partially transferred chunk set may look "already present" and stay truncated.~~ **Largely resolved
|
||||
2026-08-09: the last-chunk-only probe is deliberate, not an oversight.** The code's own comment
|
||||
(`mcp_server.py:2586-2592`) states that the last chunk's presence implies every earlier one landed
|
||||
*because the batched `upsert` is all-or-nothing*, and it additionally probes `drawer_id` alongside so a
|
||||
re-call with identical oversized content cannot duplicate a legacy pre-#1539 single-row write. The
|
||||
normal path is therefore safe by construction. What remains untested is the abnormal one: a chunk set
|
||||
left partial by a crash or kill *mid-upsert*, which would then look present and stay truncated. Worth a
|
||||
single fault-injection test before trusting bulk replay of oversized drawers. **Still untested as of
|
||||
2026-08-14** — the first join did *not* exercise it.
|
||||
|
||||
⚠️ **But 2026-08-14 surfaced a benign mechanism that mimics it, and it will generate false alarms during
|
||||
any join verification (§4.4).** Updating a drawer preserves `drawer_id`, **re-chunks the new content, and
|
||||
deletes the surplus chunk rows**. So two palaces holding two *revisions* of one drawer legitimately
|
||||
differ in chunk count and chunk-id set, with zero data loss — and the shorter side returns a clean
|
||||
"not found" for the chunk ids it no longer needs, which looks exactly like the truncation this entry
|
||||
warns about. **The absence of a derived chunk id is not evidence of loss.** Distinguish them by prefix
|
||||
test and final-chunk arithmetic per §4.4, not by id-set membership.
|
||||
4. **`migrate.py` as a bootstrap tool.** `extract_drawers_from_sqlite()` reads `{id, document,
|
||||
metadata}` straight out of chroma's SQLite (bypassing the chromadb API) and re-`add`s them with ids
|
||||
and metadata preserved and **embeddings recomputed** (`migrate.py:326`) — the right shape for a
|
||||
one-shot "seed the primary from an existing palace". Unverified: `col.add` behaviour on **id
|
||||
collision into a non-empty target**. Test before relying on it. (`exporter.py` is markdown-only,
|
||||
lossy, and has **zero callers** — not a transfer format. `backups.py` is retention pruning only.)
|
||||
**Reframed 2026-08-09:** §4.4 makes **MCP-level replay** the primary join path — it reuses the
|
||||
server's own idempotency guards instead of trusting chroma-level `add` semantics — so `migrate.py` is
|
||||
now a *fallback* for bulk transfer rather than the plan, and its untested collision behaviour only
|
||||
matters if we reach for it.
|
||||
5. **Does `_HTTP_REQUEST_LOCK` stay held for the duration of an MCP-triggered `mine`?** Strongly
|
||||
suggested by the code shape; if yes, a remote mine makes the primary unusable for its duration
|
||||
(another argument for §5).
|
||||
|
||||
6. ~~**How does opencode-devbox learn the opt-in?**~~ **RESOLVED 2026-08-09: it already does.** The
|
||||
image ships `rootfs/usr/local/lib/opencode-devbox/generate-config.py`, invoked from
|
||||
`entrypoint-user.sh:117` on every start, which auto-registers the `mempalace` MCP server —
|
||||
remote+bearer when `MEMPALACE_REMOTE_URL`/`MEMPALACE_REMOTE_TOKEN` are set, local stdio when
|
||||
`mempalace-mcp` is on PATH, nothing otherwise — documented in that repo's `.env.example:38-49` and
|
||||
`.env.shared.example:30-35`. It also ships mempalace by default (see R5). So the config *is*
|
||||
templated at container start, not baked, and Phase 2 only adds a third branch. **Two residual
|
||||
constraints replace the original unknown, and both are documented in §4.1:** (a) the script never
|
||||
overwrites an existing config and `~/.config/opencode` is a *named volume*, so an env flip yields
|
||||
only an `opencode.jsonc.proposed` sidecar — adopting or reverting the opt-in needs a manual merge
|
||||
or a volume removal; (b) it no-ops entirely unless `OPENCODE_PROVIDER` is set. Prefer extending
|
||||
this script over inventing a parallel mechanism — treat it as the reference implementation, and
|
||||
check it before designing any pi-devbox-side mechanism with an opencode counterpart.
|
||||
|
||||
7. **Is a work VM the right long-term home for personal memory?** Accepted with eyes open for Phase 1
|
||||
(§8.1): synlig wins on availability and connectivity, and the alternative — splitting by machine — was
|
||||
rejected for sound reasons (§5). But the placement question survives the routing question: diaries are
|
||||
`replicated` (decided), diaries are personal, and synlig is employer infrastructure. Revisit when
|
||||
per-wing *targets* exist (Phase 2+), at which point a second personal primary becomes an additive
|
||||
config change rather than a re-architecture. Until then, treat §6.1 threat 2 as live.
|
||||
|
||||
---
|
||||
|
||||
## 10. Evidence index
|
||||
|
||||
Re-verify without re-reading the package. Paths relative to
|
||||
`/opt/uv-tools/mempalace/lib/python3.13/site-packages/mempalace/` (mempalace 3.6.0) unless noted.
|
||||
|
||||
| Claim | Where |
|
||||
| --- | --- |
|
||||
| Server auth/TLS/read-only | `cli.py:1448-1503`; `mcp_server.py:282-320`, `5205-5215`, `5284-5289` |
|
||||
| Single global request lock | `mcp_server.py:5115`, `5346`, `5513` |
|
||||
| KG path follows `--palace` flag only | `mcp_server.py:325`, `708-711`; `knowledge_graph.py:49` |
|
||||
| KG DDL, no `(s,p,o)` uniqueness, open-fact guard | `knowledge_graph.py:163-178`, `305-313` |
|
||||
| ID recipes | `ids.py:25`, `56`, `71`; diary at `mcp_server.py:3511-3514` |
|
||||
| **Diary write has no idempotency probe** | `mcp_server.py:3546` (`col.add`, no `col.get`) vs `add_drawer`'s probe at `2593-2600` |
|
||||
| Chunk probe is deliberate (all-or-nothing upsert) | `mcp_server.py:2586-2592` |
|
||||
| Metadata is exhaustive — no session/PID/conversation field | `mcp_server.py:2578-2585` (drawers), `3527-3536` (diaries) |
|
||||
| pi extension sets identity for **diaries only** | `extensions/pi/mempalace.ts:758` (`agent_name`); no `added_by` set anywhere in the file |
|
||||
| Anti-rebinding Host/Origin checks | `mcp_server.py:5160-5194`, `5276-5290`, `5362-5367`; `cli.py:1450`. **Verified empirically** — runbook §2.4 |
|
||||
| Stock defaults already split the KG | `config.py:221` (`~/.mempalace/palace`) vs `knowledge_graph.py:49` (`~/.mempalace/knowledge_graph.sqlite3`) |
|
||||
| Server token path (stable across restarts) | `cli.py:_server_token_path` — `~/.mempalace/server/sha256(realpath(palace))[:24]/token` |
|
||||
| pi-devbox non-destructive settings merge (pattern to port) | `pi-devbox/entrypoint-user.sh:131-162` (`jq -s '.[0] * .[1]'`, `.bak`, `PI_SETTINGS_MERGE=0`) |
|
||||
| No `OPENCODE_CONFIG*` env layer upstream | absent from `https://opencode.ai/config.json`; zero hits in `opencode-devbox`, `myconfigs` |
|
||||
| Tool classification (+ `kg_supersede` gap) | `service.py:29`, `54`, `72-84` |
|
||||
| Write-routing seam | `write_routing.py:1-60` |
|
||||
| Outbox prior art (queue DDL, token, retries) | `daemon.py` (`_init_db`, `ensure_token`, `MAX_ATTEMPTS`) |
|
||||
| `sync` is destructive pruning | `sync.py:1-12` |
|
||||
| WAL redacts + has no reader | `wal.py:74`, `_WAL_REDACT_KEYS` |
|
||||
| Client-side embeddings | `backends/pgvector.py:6-8`; `embedding.py:6-11`; `_sidecar.py` |
|
||||
| Dedup scoped by `source_file` | `dedup.py:get_source_groups` |
|
||||
| Store location rules | `hallways.py:73,83`; `miner.py:701` |
|
||||
| Mine lock (HOME-derived file) | `palace.py:1090`, `1123-1128` |
|
||||
| Migrate as export primitive | `migrate.py:extract_drawers_from_sqlite`, `:326` |
|
||||
| pi remote transport + fail-soft | `mempalace-toolkit/extensions/pi/mempalace.ts:11-12`, `87`, `108`, `390`, `629-641`, `665-673` (commit `96699f2`) |
|
||||
| Server compose + port history | `pi-devbox/docker-compose.mempalace.yml`; `pi-devbox/CHANGELOG.md` v1.3.0; `docker-compose-repo/tor-ms22/pi-devbox/` (`df2c2ae`) |
|
||||
| Existing opt-in convention (§1.2) | `pi-devbox/.env.example:12-23` ("MemPalace memory (local by default)", commented `MEMPALACE_REMOTE_URL`/`_TOKEN`); `pi-devbox/docker-compose.yml:79-83` (`devbox-palace` volume commented out) |
|
||||
| opencode-devbox does not wire mempalace | `docker-compose-repo/{synlig,nyvaken,devbox-affection}/opencode-devbox/docker-compose.yml` — zero mempalace references in any |
|
||||
|
||||
## 11. See also
|
||||
|
||||
- [`ARCHITECTURE.md`](../ARCHITECTURE.md) — producer side (how the palace gets fed); §6 upstream roadmap
|
||||
- [`extensions/pi/README.md`](../extensions/pi/README.md) — pi bridge internals
|
||||
- [`SKILL.md`](../SKILL.md) — consumer-side protocol (search before answering, diary before exit)
|
||||
@@ -0,0 +1,224 @@
|
||||
# RFC 002 — The joiner: replaying a second palace into the shared primary
|
||||
|
||||
**Status:** scoping. No code written yet.
|
||||
**Author:** pi (agent), 2026-08-15.
|
||||
**Context:** RFC 001 §4.4 designs a join as "idempotent replay of local history" but no replay tool
|
||||
exists. The first two joins were whole-palace *file copies* (§4.4 deviation note), which work only for a
|
||||
single source and cannot merge. tor-ms22 and MBP-M1-2020 each hold a substantial local palace whose
|
||||
content should reach the primary. This document scopes the tool that does that.
|
||||
|
||||
> **Every mechanism below was read out of mempalace 3.6.0's own source** at
|
||||
> `/opt/uv-tools/mempalace/lib/python3.13/site-packages/mempalace/`, with file:line in the appendix.
|
||||
> An earlier attempt to gather these facts via a delegated subagent returned confident, fabricated code
|
||||
> for a package path that does not exist on this machine. **Do not trust any claim in this document that
|
||||
> the appendix does not cite.**
|
||||
|
||||
---
|
||||
|
||||
## 1. The finding that drives the design
|
||||
|
||||
**MCP replay cannot preserve `filed_at`.** `add_drawer` stamps
|
||||
`"filed_at": datetime.now().isoformat()` server-side (`mcp_server.py:2580`) and exposes no override
|
||||
parameter. `diary_write` is the same: it builds its own `now`-based id (`mcp_server.py:3510-3513`).
|
||||
|
||||
That is not a detail. This palace's value *is* its chronology — the primary's history runs from
|
||||
2026-05-04, `list_drawers` filters on `since`/`before` against `filed_at`, and the diary is read in
|
||||
order. A pure MCP replay of tor-ms22's palace would stamp **every** record with the join date,
|
||||
collapsing months of history into one instant. RFC 001 §4.4 does not mention this, and it is the single
|
||||
most important thing to decide before writing code.
|
||||
|
||||
`kg_add` is the exception: it accepts `valid_from`/`valid_to`, so **fact validity windows survive** even
|
||||
though a triple's own id embeds `recorded_at`.
|
||||
|
||||
### Two write regimes
|
||||
|
||||
| | **A — direct disk write** (run on synlig) | **B — MCP replay** (run anywhere) |
|
||||
| --- | --- | --- |
|
||||
| Preserves `filed_at` / original ids | **Yes** — `col.add(ids=…, documents=…, metadatas=…)` | **No** — always `now()` |
|
||||
| Server-side dedup guards | **Bypassed** — joiner owns all dedup | **Available** (see §2) |
|
||||
| Requires quiescing `mempalace-serve` | Yes (palace is single-writer) | No |
|
||||
| Requires source palace present on synlig | Yes (rsync it first) | No |
|
||||
| Precedent in-tree | **`migrate.py`** already does exactly this: reads drawers+metadata straight from the palace's sqlite, then re-adds them into a fresh palace preserving ids, documents and metadata (`migrate.py:326-330`) | none |
|
||||
|
||||
**Recommendation:** regime **A** for the historical bulk (it is the only one that keeps the timeline, and
|
||||
`migrate.py` is a working model to copy), regime **B** for small incremental top-ups where flattened
|
||||
timestamps are acceptable. Do not build B first and discover the chronology loss afterwards.
|
||||
|
||||
---
|
||||
|
||||
## 2. What must move, and what dedupes it
|
||||
|
||||
Verified dedup keys — this supersedes nothing in RFC 001 §4.4 but makes each key concrete:
|
||||
|
||||
| Class | How to identify it in the source palace | Dedup key (verified) | Regime A work | Regime B work |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Agent-authored drawers** (`add_drawer`/`checkpoint`) | `source_file` empty — note the writer stores `""` rather than omitting the key, so test truthiness, not presence. `id_recipe` is **not** a discriminator: the server stamps `v3` on content ids too (§2.1) | `drawer_{wing}_{room}_{H(wing,room,content)[:24]}`, where `H` **length-prefixes** each part — *not* `\|`-joined (`ids.py:40-53`; `make_drawer_id_from_content` at `ids.py:80`, used at `mcp_server.py:2560`). Content-deterministic **unless the drawer was later edited** (§2.1) | replay by **stored** id — recompute-and-skip is unsafe (§2.1) | none: server probes `[drawer_id, last_chunk_id]` and returns `{"success": True, "reason": "already_exists"}` (`mcp_server.py:2593-2604`) |
|
||||
| **Mined drawers** | `source_file` non-empty; corroborated by the miner-only keys `source_mtime` / `normalize_version` | `sha256(H(source_file, chunk_index))[:24]` (`ids.py:67`), same length-prefixed helper — **path-dependent**, so the same content from two machines yields two rows | — | **do not replay.** Re-mine on synlig instead (§4) |
|
||||
| **Diary entries** | metadata `type="diary_entry"`; id prefix `diary_` | id is `diary_{wing}_{now:%Y%m%d_%H%M%S%f}_{sha256(entry)[:12]}` (`mcp_server.py:3510-3513`) — a **plain** sha256 of the entry, *not* the length-prefixed helper. Timestamp is wall-clock, so **full ids never repeat** — match on the 12-hex suffix only | copy row verbatim (id and all) | build the target's suffix set, skip matches — this is RFC 001 §7.6 |
|
||||
| **KG open facts** (`valid_to IS NULL`) | `triples.valid_to IS NULL` | server guard `WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL` returns the existing id (`knowledge_graph.py:305-311`) | pre-query same key | none: just replay |
|
||||
| **KG closed facts** (`valid_to` set) | `triples.valid_to IS NOT NULL` | **no server guard at all** — the guard above is scoped to open facts, so every replay inserts a fresh row | pre-query `(s,p,o,valid_from,valid_to)` | same pre-query, client-side |
|
||||
| **Entities** | `entities` table | — | needed as FK targets for triples | `kg_add` creates them implicitly |
|
||||
|
||||
Chunking is deterministic and replay-safe: `DEFAULT_CHUNK_SIZE = 800` (`config.py:274`), chunk ids are
|
||||
`f"{drawer_id}_chunk_{i:06d}"`, so nothing about chunking needs special handling. One structural note the
|
||||
census depends on: an oversized drawer has **no parent row** — only `_chunk_NNNNNN` rows — so the parent
|
||||
id must be recovered by stripping the suffix, and content by concatenating in `chunk_index` order.
|
||||
|
||||
### 2.1 Three corrections this table needed — found by building the census, not by reading
|
||||
|
||||
Every recipe above is now **empirically verified**: `bin/mempalace-census` recomputes each id from
|
||||
reassembled content and compares it to the stored id (176/176 accounted for on the reference palace).
|
||||
Building that check falsified three things an earlier docs-only reading of `ids.py` had asserted.
|
||||
|
||||
**(a) The hash input is length-prefixed, not `\|`-joined.** `ids.py:31` defines `_DELIM = "|"` and the
|
||||
`make_*` docstrings describe the input as `f"{wing}|{room}|{content}"`. Both are misleading: `_DELIM` is
|
||||
**dead code** (defined, never referenced) and `_delimited_sha256()` actually builds
|
||||
`"".join(f"{len(part)}:{part}")`. Length-prefixing is the better scheme — it is unambiguous where a bare
|
||||
delimiter is not — but the docstrings never caught up. Measured on real drawers: length-prefixed
|
||||
reproduces stored ids **5/5**, pipe-joined **0/5**. *Verify id recipes against the implementation and a
|
||||
real row; a docstring is a claim, not evidence.*
|
||||
|
||||
**(b) `id_recipe` is not a mined-only marker.** It looked like a clean discriminator (14,586 rows, the
|
||||
same count as `source_file`), but the server stamps `v3` on **every** v3 id including content-hashed
|
||||
ones, and the sets differ by exactly the 60 agent-authored drawers. Classifying on
|
||||
`source_file OR id_recipe` therefore swallowed all 60 into *mined* — the dangerous direction, since
|
||||
Phase C would try to re-mine drawers that have no source file at all and silently drop them from the
|
||||
join. The discriminator is a **truthy** `source_file`, cross-checked against the miner-only keys
|
||||
`source_mtime` / `normalize_version`. The census now reports any disagreement between the two as a
|
||||
first-class warning rather than trusting one signal.
|
||||
|
||||
**(c) Content ids drift, so "recompute the id and skip if present" is unsafe.** 9 of the 60
|
||||
agent-authored drawers (15%) no longer reproduce their own id: `update_drawer` **preserves the id** while
|
||||
rewriting and re-chunking content, so an edited drawer's content hash stops matching. This is expected
|
||||
behaviour, but it breaks the obvious Regime A strategy — recomputing the content id and probing the
|
||||
target for it **misses every drifted drawer and duplicates it**. Phase C must key on the **stored** id.
|
||||
The census flags these as `edited_since_filing` in the manifest so Phase C can be tested against them.
|
||||
|
||||
### Not carried by either regime
|
||||
|
||||
**Hallways, `known_entities.json`, the palace graph, and closets are built at mine time**, not by
|
||||
`add_drawer`. Replayed drawers therefore arrive with no hallway/co-occurrence edges and no closets, so
|
||||
`list_hallways` and `traverse` will under-report for joined content, and joined drawers get no
|
||||
`closet_boost` in search ranking. All are derived artifacts — rebuilt by re-mining, or acceptably
|
||||
degraded. Decide which; do not discover it later.
|
||||
|
||||
**Closets specifically:** `mempalace_closets` is a second Chroma collection (1,560 rows in the archive,
|
||||
~10% of the palace), and **there is no MCP tool that writes a closet** — `mcp_server.py` only exposes
|
||||
`_purge_source_closets`. `closet_llm.py` states it plainly: *"Regex closets are always created by the
|
||||
miner"*, with the LLM path an opt-in regeneration afterwards. So closets cannot be replayed even in
|
||||
principle; they come back only by re-mining. Since ~99% of a devbox palace is mined content that gets
|
||||
re-mined on synlig anyway (§4), this resolves itself for the bulk.
|
||||
|
||||
> **Census gotcha — filter by collection.** `chroma.sqlite3` holds *both* collections. A naive
|
||||
> `embeddings`-wide query over-counts: the archive yields 16,338 rows total, which is
|
||||
> `mempalace_drawers` 14,778 + `mempalace_closets` 1,560. Join through
|
||||
> `segments`→`collections` and keep `mempalace_drawers`, or the census inflates by ~10%. (14,778 also
|
||||
> reconciles exactly with the 14,777 seeded to the primary plus the one known post-snapshot chunk.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Phases and deliverables
|
||||
|
||||
**Phase A — census (read-only, no writes anywhere).** ✅ **Built: `bin/mempalace-census`.** Point it at a
|
||||
palace on disk; it enumerates every parent drawer from `chroma.sqlite3` (`embeddings ⋈ embedding_metadata`,
|
||||
filtered by collection) plus every row of `knowledge_graph.sqlite3`, classifies each into the §2 rows, and
|
||||
emits `--json` (a manifest that feeds Phases B/C) or a human report. It opens every file `mode=ro` and is
|
||||
safe to run against a live palace. It also **self-verifies** — recomputing each id from reassembled content
|
||||
and comparing to the stored id, which checks the recipe, the chunk reassembly order and the classifier in
|
||||
one pass. That check is what produced the §2.1 corrections. *Deliverable, realised: see §4.1.*
|
||||
|
||||
**Phase B — target index.** Given the primary, build the three lookup sets Phase C needs: content-drawer
|
||||
ids, diary `sha256(entry)[:12]` suffixes, and `(s,p,o,valid_from,valid_to)` tuples. Over MCP this is
|
||||
`list_drawers` pagination + `kg_timeline`; on synlig it is two sqlite queries. Cheap either way.
|
||||
|
||||
**Phase C — writer**, with `--dry-run` as the default and an explicit `--apply`, mirroring `sync`'s
|
||||
contract. Two backends behind one interface:
|
||||
- `--mode direct` (regime A): stop `mempalace-serve`, `col.add()` with original ids/metadata, restart.
|
||||
Model on `migrate.py`. Must refuse to run if the server is up.
|
||||
- `--mode mcp` (regime B): `add_drawer`/`diary_write`/`kg_add` over HTTP, accepting `filed_at` loss.
|
||||
|
||||
**Phase D — verification.** Counts before/after per class, a real `search` against known joined content
|
||||
(proves the HNSW index absorbed it — this is how the first seed was verified), `kg_stats`, and a spot
|
||||
`get_drawer` on a known id. Plus: re-run the Phase A census against the *target* and diff.
|
||||
|
||||
---
|
||||
|
||||
## 4. Explicitly out of scope
|
||||
|
||||
- **Mined wings.** RFC 001 §5 makes them `local`; their ids are path-dependent, so replay produces
|
||||
duplicates rather than dedup. Re-mine on synlig from sources present there.
|
||||
- **Merging two palaces into a third.** Every join targets the existing primary.
|
||||
- **`mempalace sync` interaction.** See RFC 001 §7.2 — settle the guard separately; a joiner must never
|
||||
call it.
|
||||
|
||||
### 4.1 The census, run for real — the replay surface is tiny
|
||||
|
||||
Produced by `bin/mempalace-census` against the container-local palace (2026-08-15). Since the fleet shares
|
||||
one devbox image, this is a reasonable prior for what tor-ms22 and MBP-M1-2020 hold. Counts are **parent
|
||||
drawers in the `mempalace_drawers` collection** — see the correction note below, which is the whole reason
|
||||
to state the unit:
|
||||
|
||||
| Class | Count | Share | Joiner action |
|
||||
| --- | --- | --- | --- |
|
||||
| Mined (`source_file` truthy) | 14,389 | **98.8%** | re-mine on synlig; **never replay** |
|
||||
| Diary entries | 116 | 0.8% | replay + §7.6 suffix skip |
|
||||
| Agent-authored drawers | 60 | 0.4% | replay by stored id (9 are `edited_since_filing`, §2.1c) |
|
||||
| KG open facts | 34 | — | replay, server guard dedupes |
|
||||
| KG **closed** facts | **0** | — | nothing to do — see below |
|
||||
|
||||
**Corrected figure.** An earlier pass reported 15,949 mined / 98.9%. That number was reconstructible
|
||||
exactly as `16,338 (all embeddings rows) − 192 (diary rows) − 197 (agent rows) = 15,949`: it counted
|
||||
**rows, not parent drawers**, and it counted them across **both collections**, so it silently absorbed all
|
||||
1,560 `mempalace_closets` rows into the mined total. Closets are derived at mine time and are not joinable
|
||||
at all. Two lessons, both now enforced in the census: **always filter by collection** (`embeddings ⋈
|
||||
segments ⋈ collections`, since one sqlite file holds both), and **always state whether a count is rows or
|
||||
parent drawers** — a chunked drawer contributes N rows and no parent row.
|
||||
|
||||
**Two consequences that shrink this project sharply.** First, the genuinely replay-only surface is
|
||||
**176 records**, not thousands — so Phase C's writer is a small job, and the §7.6 diary guard that has
|
||||
been treated as the blocker governs *116 records*. Second, **there are zero closed KG facts**, so the
|
||||
unguarded-closed-fact gap (§2) is real in the code but currently empty in the data; it needs handling for
|
||||
correctness, not for this join.
|
||||
|
||||
`filed_at` spread over the same parent drawers — 12 in May, 52 in June, 13,619 in July, 882 in August — is
|
||||
the concrete case for regime A: an MCP replay would restamp all of it to the join date.
|
||||
|
||||
## 5. Open decisions for ALC
|
||||
|
||||
1. **Chronology: keep it or flatten it?** Regime A keeps it and costs a service stop plus an rsync of the
|
||||
source palace onto synlig. Regime B is simpler and loses it. This is the fork in the road.
|
||||
2. **Hallways for joined content:** re-mine to rebuild, or accept degraded `traverse`?
|
||||
3. **Do MBP-M1-2020 and tor-ms22 keep their palaces on persistent storage?** If either is a Docker named
|
||||
volume rather than a bind mount, its un-migrated content dies on the next container recreate — so the
|
||||
census (Phase A) is time-sensitive there, and flipping before censusing is risky.
|
||||
4. **Does §7.6 get fixed client-side (in the joiner) or upstream (probe-and-skip in `diary_write`)?** The
|
||||
joiner needs the suffix skip either way; upstream would fix it for every writer.
|
||||
|
||||
## 6. Effort
|
||||
|
||||
Phase A is the bulk of the value and is small — two sqlite readers and a classifier. Phase B is trivial.
|
||||
Phase C is where the risk lives, and regime A must be written defensively (refuse on a live server,
|
||||
back up first, `--dry-run` default). Phase D is mostly assertions. Nothing here needs new server code,
|
||||
which is the point of RFC 001 §4.4's "existence checks available today".
|
||||
|
||||
---
|
||||
|
||||
## Appendix — verified source references
|
||||
|
||||
mempalace 3.6.0, `/opt/uv-tools/mempalace/lib/python3.13/site-packages/mempalace/`:
|
||||
|
||||
| Claim | Location |
|
||||
| --- | --- |
|
||||
| Content-addressed drawer id, 24 hex over `wing\|room\|content` | `ids.py:80` (`make_drawer_id_from_content`), `_HASH_TRUNC_DRAWER = 24` at `ids.py:36` |
|
||||
| MCP `add_drawer` uses that recipe | `mcp_server.py:2560` |
|
||||
| Miner id over `source_file\|chunk_index` | `ids.py:67` (`make_drawer_id_from_chunk`), used `miner.py:1381`, `format_miner.py:645` |
|
||||
| Idempotency probe + `already_exists` (probes parent **and** last chunk) | `mcp_server.py:2593-2604` |
|
||||
| `filed_at` stamped server-side, no override | `mcp_server.py:2580` |
|
||||
| Diary id recipe, suffix = `sha256(entry)[:12]` | `mcp_server.py:3510-3513` |
|
||||
| Triple id `t_{s}_{p}_{o}_{sha256(valid_from\|recorded_at)[:12]}` | `ids.py:111-131`, `_HASH_TRUNC_TRIPLE = 12` at `ids.py:37` |
|
||||
| `add_triple` guard scoped to open facts | `knowledge_graph.py:305-311` |
|
||||
| Chunk size 800 | `config.py:274` |
|
||||
| Direct-write precedent preserving ids+metadata | `migrate.py:326-330` |
|
||||
| No join/replay/import tooling exists | `cli.py` subcommands; `exporter.py` emits markdown (lossy, not a join primitive); `diary_ingest.py` ingests *daily-summary files*, unrelated to agent diaries; `migrate.py` is single-palace chromadb recovery; `dedup.py` is cosine-similarity pruning within one `source_file` |
|
||||
| Closets are miner-derived, no MCP write path | `closet_llm.py:8-10` ("Regex closets are always created by the miner"); `mcp_server.py:2913` exposes only `_purge_source_closets` |
|
||||
| Two collections in one sqlite file | `mempalace_drawers` (14,778 rows) and `mempalace_closets` (1,560) in the archive, via `segments`→`collections` |
|
||||
@@ -0,0 +1,315 @@
|
||||
# synlig primary — Phase 0 runbook and handoff
|
||||
|
||||
Companion to [`rfc-001-global-palace.md`](./rfc-001-global-palace.md). Records what was actually done
|
||||
on the primary, with verified evidence, so the next session (or the next machine) does not re-derive it.
|
||||
|
||||
> **Status 2026-08-14 17:00 — SUPERSEDED IN PART. The primary is live, exposed, and seeded.**
|
||||
> Serving since 2026-08-12 at `https://mempalace.jordbo.se/mcp`. Seeded 2026-08-14 15:07 from
|
||||
> EMB-7KJ4VR4G's palace (itself a carry-over from the previous work computer EMB-X1JY06WJ — rfc-001 §4.4)
|
||||
> — 14,777 drawers / 9 wings / 16,337 embeddings / KG 46 entities, 34 triples,
|
||||
> now 14,803 drawers. One client (EMB-7KJ4VR4G's pi-devbox container) is flipped and verified
|
||||
> end-to-end. Both Phase 0 blockers below are cleared.
|
||||
>
|
||||
> **Update 2026-08-16 00:20 — the transcript feed is live too, and it is the primary's third moving
|
||||
> part** (alongside the HTTPS tunnel and the palace itself). See §2.6: transcripts arrive over SSH into
|
||||
> `~/mempalace-feed/<device>/` and are mined *by this host's own server process*. 15,478 drawers as of
|
||||
> that check — but treat every count in this document as a timestamp, not a fact: `status` counts chunk
|
||||
> rows, and §4 item 7 explains why counts adjudicate nothing.
|
||||
>
|
||||
> **Read §3 "Deliberately NOT done" as a record of the 2026-08-10 state, not of today's** — every
|
||||
> item in it has since been done. And before running anything in §5 Rollback, read the warning at the
|
||||
> top of it: `~/.mempalace` on synlig is no longer disposable.
|
||||
|
||||
Original status, kept for the record:
|
||||
|
||||
**Status 2026-08-10 00:30 — Phase 0 prep complete. Not serving. Nothing exposed.**
|
||||
Blocked on two things, both deliberately left to Joakim: the Pangolin update on nyvaken, and one `sudo`.
|
||||
|
||||
---
|
||||
|
||||
## 1. What synlig is (discovered, not assumed)
|
||||
|
||||
| Fact | Value |
|
||||
| --- | --- |
|
||||
| SSH | `synlig` → `synlig.erdc.ericsson.net`, user `ecsjper` (from `~/.ssh/config`) |
|
||||
| OS | Ubuntu 24.04.4 LTS, 7.8 GiB RAM, 78 G disk (**29 G free**), uptime 12 d |
|
||||
| Python / uv | system `python3` 3.12.3; `uv` at `~/.local/bin/uv` (**not** on the non-login `PATH`) |
|
||||
| Interfaces | `lo` 127.0.0.1, `ens3` 10.0.0.4/16, `docker0` 172.17.0.1/16, `br-…` 172.19.0.1/16 |
|
||||
| Already listening | 22, 80, 443, 3000 (node), 3389 + 3350 + 4822 (xrdp/guacamole), 631 |
|
||||
| Docker | present; running `act_runner-runner-1` (**Gitea Actions runner**) and `digikam` |
|
||||
| Pre-existing MemPalace | **none** — no `mempalace` binary, no `~/.mempalace`. Greenfield. |
|
||||
|
||||
The Gitea Actions runner living here is worth remembering: synlig is not a dedicated appliance, and CI
|
||||
load competes with the palace for the same 7.8 GiB.
|
||||
|
||||
## 2. Done tonight
|
||||
|
||||
### 2.1 MemPalace installed, pinned to the fleet version
|
||||
|
||||
```sh
|
||||
~/.local/bin/uv tool install "mempalace==3.6.0" # → mempalace, mempalace-mcp
|
||||
```
|
||||
|
||||
Pinned deliberately: the clients run 3.6.0, and the id recipes / idempotency probes this RFC leans on are
|
||||
version-specific. Reversible with `uv tool uninstall mempalace`.
|
||||
|
||||
### 2.2 Embedder model pre-warmed — the corporate-network risk that wasn't
|
||||
|
||||
The first embed pulls `all-MiniLM-L6-v2` ONNX (79.3 MB) from the chroma CDN into
|
||||
`~/.cache/chroma/onnx_models/` (167 M on disk once unpacked). **This was the main unknown** — an
|
||||
egress-filtered work VM would have failed here, at the worst possible moment (first client write).
|
||||
It downloaded at ~20 MB/s with no proxy interference. Done in a throwaway palace, since deleted, so the
|
||||
real palace never saw it. Same model as the clients use, so the semantic space matches.
|
||||
|
||||
### 2.3 Palace created with the §7.1 landmine structurally removed
|
||||
|
||||
`~/.mempalace/palace` — the **stock default**, so no `MEMPALACE_PALACE_PATH` and no `config.json` is
|
||||
needed anywhere on synlig. One less thing to drift.
|
||||
|
||||
RFC §7.1 says to `mv` three HOME-anchored stores into the palace dir before first `serve`. **On a
|
||||
greenfield primary there is nothing to move — but the hazard is not actually a migration hazard, and the
|
||||
RFC understated it:** with *stock defaults* `palace_path` is `~/.mempalace/palace` while `DEFAULT_KG_PATH`
|
||||
is `~/.mempalace/knowledge_graph.sqlite3`. Those differ, so the split is the **out-of-the-box** behaviour,
|
||||
not a consequence of a custom path. It is permanent, not one-time: `serve` always passes `--palace` (KG
|
||||
inside the palace), while any CLI command run *without* `--palace` uses the HOME path. Two KGs on one box,
|
||||
forever, silently.
|
||||
|
||||
Fixed by making both resolution rules land on one inode:
|
||||
|
||||
```sh
|
||||
ln -sfn palace/knowledge_graph.sqlite3 ~/.mempalace/knowledge_graph.sqlite3
|
||||
ln -sfn palace/known_entities.json ~/.mempalace/known_entities.json
|
||||
```
|
||||
|
||||
Relative targets, so a home-directory move survives. `hallways.json` was originally left **un**symlinked:
|
||||
it is already palace-derived, and its HOME path is a warning-only legacy probe (`hallways.py:73-95`) that
|
||||
never auto-migrates.
|
||||
|
||||
> **Update 2026-08-14 — `hallways.json` is now symlinked too**, during the seeding session:
|
||||
> ```sh
|
||||
> ln -sfn palace/hallways.json ~/.mempalace/hallways.json
|
||||
> ```
|
||||
> Rationale changed: the point is no longer "only symlink what the code demands" but *all real state
|
||||
> lives under `palace/` as a single backup unit*, so one `palace/` copy is a complete copy. All three
|
||||
> parent-level paths now resolve, which matters because mempalace 3.6.0 resolves these three paths
|
||||
> inconsistently (MCP server: palace-relative; KG CLI default: `~/.mempalace`; `hallways.json`:
|
||||
> `dirname(palace_path)`; `known_entities.json`: hardcoded `~`). Revert by deleting the symlink if it
|
||||
> ever causes trouble.
|
||||
|
||||
Verified the symlink assumption rather than trusting it (`python3 sqlite3` on synlig, temp dir):
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| Dangling symlink + `sqlite3.connect` | creates the target |
|
||||
| `-wal` / `-shm` placement | next to the **target**, inside the palace dir — *not* beside the symlink |
|
||||
| Write via symlink → read via palace path | same data, **same inode** |
|
||||
|
||||
The WAL placement is the part that mattered: it keeps the palace directory a single self-contained
|
||||
backup/bind-mount unit.
|
||||
|
||||
### 2.4 §6.2's Host/Origin policy verified by experiment, not by reading
|
||||
|
||||
Ran on synlig, loopback and docker0 binds, then stopped. **11/11 as predicted:**
|
||||
|
||||
| # | Bind | Request | Expected | Got |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| A1 | 127.0.0.1 | `/healthz`, correct Host | 200 | ✅ 200 |
|
||||
| A2 | 127.0.0.1 | `/healthz`, `Host: palace.example.com` | **403** | ✅ 403 |
|
||||
| A3 | 127.0.0.1 | `/healthz`, `Origin: https://evil.example` | 403 | ✅ 403 |
|
||||
| A4 | 127.0.0.1 | `POST /mcp`, no token | 401 | ✅ 401 |
|
||||
| A5 | 127.0.0.1 | `POST /mcp`, wrong token | 401 | ✅ 401 |
|
||||
| A6 | 127.0.0.1 | `POST /mcp`, correct token | 200 | ✅ 200 (`tools/list` → **36 tools**) |
|
||||
| B1 | 172.17.0.1 | `/healthz`, bound-host Host | 200 | ✅ 200 |
|
||||
| B2 | 172.17.0.1 | `/healthz`, `Host: palace.example.com` | **200** | ✅ 200 |
|
||||
| B3 | 172.17.0.1 | `/healthz`, `Origin: https://evil.example` | 403 | ✅ 403 |
|
||||
| B4 | 172.17.0.1 | `/healthz`, loopback Origin | 200 | ✅ 200 |
|
||||
| B5 | 172.17.0.1 | `POST /mcp`, foreign Host + token | 200 | ✅ 200 |
|
||||
|
||||
**Operational conclusions:**
|
||||
|
||||
1. **Do not bind loopback behind the tunnel.** A2 vs B2 is the whole story: the reflex "bind 127.0.0.1,
|
||||
it's safer" produces a 403 that looks like a Pangolin misconfiguration and is not one.
|
||||
2. **Bind `172.17.0.1` (docker0).** Non-loopback, so the Host pin relaxes — but reachable only from
|
||||
synlig and its containers, so a newt container on this box can reach it while the LAN cannot. This is
|
||||
strictly better than `0.0.0.0` here. It is what `contrib/systemd/mempalace-serve.service` uses.
|
||||
3. **`Origin` is never relaxed** (B3). No browser-based MCP client, and no proxy that injects `Origin`.
|
||||
4. `/healthz` is Host/Origin-gated but token-free — a usable liveness probe for the tunnel.
|
||||
|
||||
Test script kept at `/tmp/synlig-phase0-test.sh` on this container (ephemeral — re-create from the table
|
||||
above if needed; it starts, probes and stops the server, and asserts nothing is left listening).
|
||||
|
||||
### 2.5 A start unit — written, staged, and (since 2026-08-12) installed and running
|
||||
|
||||
> **This subsection describes 2026-08-10. The unit is now live.** Verified 2026-08-16 00:15:
|
||||
> `systemctl --user list-units` shows `mempalace-serve.service … loaded active running`, and the
|
||||
> process is
|
||||
> `~/.local/share/uv/tools/mempalace/bin/python -m mempalace.mcp_server --transport http
|
||||
> --host 172.17.0.1 --port 8765 --palace /home/ecsjper/.mempalace/palace`.
|
||||
> Note what that means and §2.6 depends on: **the server is a NATIVE process, not a container** — it
|
||||
> sees synlig's real filesystem paths, and `docker ps` on synlig lists no mempalace container.
|
||||
|
||||
`contrib/systemd/mempalace-serve.service` — user unit, follows the existing `contrib/systemd/` style,
|
||||
carries the bind rationale inline so nobody "fixes" it back to loopback. A copy is already staged on synlig
|
||||
at `~/.config/systemd/user/mempalace-serve.service.staged` — **the `.staged` suffix is deliberate**:
|
||||
systemd only reads `*.service`, so the file cannot be activated by accident, not even by a stray
|
||||
`daemon-reload`. **Not** installed, **not** enabled: it needs one `sudo loginctl enable-linger`, and
|
||||
standing up a network-reachable service while you were asleep was not mine to decide.
|
||||
(Both were done on 2026-08-12 — §4 item 3 has the exact commands that were run.)
|
||||
|
||||
### 2.6 The transcript inbox — `~/mempalace-feed/<device>/` (added 2026-08-16)
|
||||
|
||||
Flipped clients write drawers over HTTPS, but their **session transcripts** cannot travel that way:
|
||||
`mempalace_mine` resolves its `source` path *in the server process*, so the server cannot see a
|
||||
client's staged exports. `mempalace-pi-session --mode remote` therefore rsyncs each client's stage into
|
||||
a per-device inbox here and then asks the server to mine its own local path:
|
||||
|
||||
```sh
|
||||
ls ~/mempalace-feed/ # one dir per device, e.g. emb-7kj4vr4g/
|
||||
ls ~/mempalace-feed/emb-7kj4vr4g/ # pi_<session-uuid>.jsonl, mtimes preserved
|
||||
```
|
||||
|
||||
Client side, that needs three variables — and **the third one is the trap**:
|
||||
|
||||
| Variable | Value for this fleet | Why |
|
||||
| --- | --- | --- |
|
||||
| `MEMPALACE_PI_SSH_TARGET` | `ecsjper@synlig:/home/ecsjper/mempalace-feed` | where rsync puts the files |
|
||||
| `MEMPALACE_PI_DEVICE` | e.g. `emb-7kj4vr4g` | inbox subdirectory per machine |
|
||||
| `MEMPALACE_PI_REMOTE_PATH` | `/home/ecsjper/mempalace-feed` | the inbox **as the server process sees it** |
|
||||
|
||||
The feeder's default for the third is `/data/feed`, which assumes a *containerized* palace server with
|
||||
the inbox bind-mounted there. **This primary is native (§2.5), so it only ever sees host paths and the
|
||||
value must equal the path half of the SSH target.** Get it wrong and the failure is quiet in the worst
|
||||
way: rsync succeeds, the files are all present here, and only the mine fails with
|
||||
`source directory not found: '/data/feed/<device>'`.
|
||||
|
||||
That is exactly what happened on 2026-08-15, and it went unnoticed for a session because the feeder
|
||||
decided success with `'"error"' in body` — MCP returns HTTP 200 with the tool's own JSON **escaped**
|
||||
inside `result.content[].text`, so those bytes are `\"error\"`, the substring never matched, and
|
||||
`~/.pi/agent/mempalace-catchup.log` printed `Done. Wing 'wing_conversations' updated.` directly under the
|
||||
error. Fixed in `6e1f4f3`: the envelope is parsed, `--self-test` pins that exact response body, and a
|
||||
preflight warning fires whenever the ship path and `MEMPALACE_PI_REMOTE_PATH` disagree.
|
||||
|
||||
**Operational notes for this inbox:**
|
||||
|
||||
- Dedup keys on the **absolute source path**, so the inbox path is load-bearing: it must stay stable, or
|
||||
every transcript re-files under its new name. Migrating it on 2026-08-15 (from the clients' old
|
||||
container-local stage paths, which arrived with the seed) cost a full re-mine plus
|
||||
`mempalace_delete_by_source` on 6 old paths — 651 drawers purged, 1243 re-filed. `wing_conversations`
|
||||
is now keyed entirely on `/home/ecsjper/mempalace-feed/<device>/…`.
|
||||
- A **grown** session is purged and re-filed for the same path (mtime-based), so re-feeding a live
|
||||
session refreshes it instead of duplicating it. That is why the inbox keeps whole transcripts rather
|
||||
than deltas — do not "tidy" it by deleting files the palace still references.
|
||||
- `mempalace_mine` over MCP can **exceed a client's request timeout while the server keeps working and
|
||||
finishes normally**. A client-side timeout is not a failed mine: check
|
||||
`SELECT COUNT(*) FROM embedding_metadata WHERE key='source_file' AND string_value LIKE '<inbox>%'`
|
||||
(read-only, `file:…?mode=ro`) before retrying anything.
|
||||
- Health check after any client recreate, from the client: the tail of
|
||||
`~/.pi/agent/mempalace-catchup.log` should end in `Done. Wing … updated.` with no `error:` line above
|
||||
it. With the fixed feeder a broken run exits 5 and names the reason.
|
||||
|
||||
## 3. Deliberately NOT done
|
||||
|
||||
> **⚠️ Historical — this section describes 2026-08-10 and is no longer true.** All five items were
|
||||
> done between 2026-08-12 and 2026-08-14. Kept because the *reasoning* for deferring them is still
|
||||
> the record of why the order was chosen. Current state per item is inlined below.
|
||||
|
||||
- **Nothing is serving.** No listener on 8765; no mempalace process. Re-verified at the end of the run.
|
||||
→ **Now serving** since 2026-08-12 (`mempalace-serve.service`, `systemctl --user`), reachable at
|
||||
`https://mempalace.jordbo.se/mcp` via newt/Pangolin.
|
||||
- **No client `.env` was touched.** Your working setup is exactly as you left it (R6: reversible).
|
||||
→ **One client flipped 2026-08-14**: four variables on EMB-7KJ4VR4G, `docker-compose.yaml` unchanged.
|
||||
Still reversible in ~30s (§3.8 of [`phase-1-exposure-runbook.md`](./phase-1-exposure-runbook.md)).
|
||||
- **No data joined.** The palace is empty. The §4.4 join needs the diary-dedup decision (§7.6) first —
|
||||
replaying diaries today duplicates them, and the primary is the one place that must stay clean.
|
||||
→ **Seeded 2026-08-14** from *one* palace by file-level copy. This sidestepped §7.6 rather than
|
||||
solving it: a file-level copy replays no diaries, so it cannot duplicate them. **§7.6 is still a
|
||||
hard blocker for the second machine to join.**
|
||||
- **nyvaken untouched.** Read nothing, changed nothing.
|
||||
- **No sudo.** `sudo -n` on synlig requires a password.
|
||||
|
||||
## 4. Tomorrow, in order
|
||||
|
||||
> **2026-08-12: items 1–2 and 5 now have their own runbook —**
|
||||
> [`phase-1-exposure-runbook.md`](./phase-1-exposure-runbook.md). Pangolin on nyvaken is updated (done),
|
||||
> and **newt is now installed on synlig and connected to Pangolin (done 2026-08-12)** — so the blocker is
|
||||
> now item 3, the one `sudo`. That doc also records why per-device Pangolin users are the wrong layer, why
|
||||
> the HTTPS tunnel and the feeder's SSH path are **not** redundant (§1.3), the client-flip variable trap
|
||||
> (§3.7), and an additional loopback finding: a loopback bind does not merely 403, it also silently starts
|
||||
> the server with **no token at all** (auto-minting is gated on the bind being non-loopback).
|
||||
|
||||
1. **Pangolin update on nyvaken** (yours). ✅ done 2026-08-12.
|
||||
2. ~~**⚠️ synlig has no tunnel client.**~~ ✅ **done 2026-08-12** — newt installed and connected to Pangolin.
|
||||
(Kept for the reasoning: `docker ps` showed only the Gitea runner and digikam. Pangolin on nyvaken
|
||||
cannot reach synlig by itself; synlig had to dial out. Easy to miss because Pangolin looks healthy on
|
||||
its own side — which is also why "connected" is not yet proof it can reach the palace: verify
|
||||
`172.17.0.1:8765/healthz` from *inside* newt's namespace, exposure runbook §3.3.) Since newt runs in
|
||||
Docker here, the docker0 bind above is already correct for it.
|
||||
3. **One sudo, then start** (the unit is already staged; just drop the suffix). ✅ **done 2026-08-12** —
|
||||
linger enabled, unit enabled, `172.17.0.1:8765/healthz` → `ok`.
|
||||
```sh
|
||||
sudo loginctl enable-linger ecsjper
|
||||
cd ~/.config/systemd/user && mv mempalace-serve.service.staged mempalace-serve.service
|
||||
systemctl --user daemon-reload && systemctl --user enable --now mempalace-serve
|
||||
curl -s 172.17.0.1:8765/healthz # ok
|
||||
curl -s 127.0.0.1:8765/healthz # NOTHING — refused, exit 7 (not 403; see below)
|
||||
ss -ltnp | grep 8765 # 172.17.0.1:8765 only
|
||||
```
|
||||
⚠ **Corrected 2026-08-12:** this line predicted `403`. The real run returned empty, which is *more*
|
||||
reassuring. With the docker0-only bind nothing listens on loopback, so the connection is refused before
|
||||
any header is sent (`%{http_code}` → `000`, `$?` → `7`). The 403 in §2.4 is the **loopback-bind** case:
|
||||
a server on `127.0.0.1` answering a proxy-forwarded foreign `Host:`. Two different failures that were
|
||||
collapsed into one expectation here.
|
||||
4. **Collect the shared token** (auto-minted on first non-loopback start, stable across restarts):
|
||||
```sh
|
||||
cat ~/.mempalace/server/f5d849287f6d73f0141b29d7/token
|
||||
```
|
||||
That directory name is `sha256(realpath(palace))[:24]` — it changes if the palace path ever changes.
|
||||
5. **Route it through Pangolin**, then verify `/healthz` end-to-end through the public hostname *before*
|
||||
pointing any client at it.
|
||||
6. **Then, and only then**, Phase 1 client flip — one machine first, and remember opencode containers
|
||||
need the §4.1 sidecar merge (or Phase 1.5) before the `.env` takes effect.
|
||||
7. **Before the first join:** settle §7.6 diary dedup, then dry-run §4.4 from **one** palace.
|
||||
> **Correction 2026-08-14 — do NOT verify a join "by checking counts", which is what this item
|
||||
> originally said.** Counts are not evidence, in either direction. `mempalace status` counts
|
||||
> **chunk rows**, not logical drawers (3 drawers plus one 2-chunk diary presented as +9), and chunk
|
||||
> counts legitimately differ between two palaces whenever a drawer was updated on either side,
|
||||
> because an update re-chunks to the new length and deletes the surplus rows. Diffing chunk-id sets
|
||||
> is a useful first pass but **over**-reports: a chunk id present on one side only is the ordinary
|
||||
> signature of an edit, not of loss. This cost real time on 2026-08-14 — a missing
|
||||
> `chunk_000007` was read as a truncated seed, when in fact the two palaces held two revisions of
|
||||
> one drawer and nothing was lost. **Adjudicate by fetching the parent drawer on both sides and
|
||||
> comparing the reassembled `content`.**
|
||||
|
||||
## 5. Rollback
|
||||
|
||||
> **⚠️ STOP — 2026-08-14. Do not run this block as it was originally written.** `~/.mempalace` on
|
||||
> synlig is now the fleet primary. That tree holds the only central palace (14,803 drawers, seeded
|
||||
> from EMB-7KJ4VR4G) **and the server's bearer token** at `~/.mempalace/server/<hash>/token` — the
|
||||
> single credential every flipped client authenticates with, of which there is no second copy.
|
||||
> `rm -rf ~/.mempalace` destroys both. The original comment ("empty today — check before running once
|
||||
> it isn't") is far too soft for a destructive command someone runs under pressure, which is exactly
|
||||
> why it is being replaced rather than amended.
|
||||
|
||||
Stopping the service is safe and reversible on its own, and is the whole of what "rollback" should
|
||||
normally mean now:
|
||||
|
||||
```sh
|
||||
systemctl --user disable --now mempalace-serve # clients fail CLOSED — they lose the palace
|
||||
# tools; they do NOT fall back to a local palace
|
||||
```
|
||||
|
||||
To genuinely decommission the primary, in this order:
|
||||
|
||||
1. Flip every client back first (§3.8 of [`phase-1-exposure-runbook.md`](./phase-1-exposure-runbook.md),
|
||||
in reverse) so nothing is pointed at a host that is about to lose its palace.
|
||||
2. Copy `~/.mempalace/palace/` **and** the token file off the host, and verify the copy by comparing
|
||||
reassembled drawer `content`, not counts (see §4 item 7).
|
||||
3. Only then remove anything. Never `rsync --delete` into `~/.mempalace` — the token lives inside it.
|
||||
|
||||
The two destructive steps below were written on 2026-08-10, when `~/.mempalace` was genuinely empty.
|
||||
Kept for the record; **must not be run while the primary is live**:
|
||||
|
||||
```sh
|
||||
~/.local/bin/uv tool uninstall mempalace
|
||||
rm -rf ~/.mempalace # ⚠️ DESTROYS THE FLEET PALACE AND THE ONLY TOKEN
|
||||
```
|
||||
@@ -0,0 +1,442 @@
|
||||
# pi ↔ MemPalace MCP bridge
|
||||
|
||||
The canonical source of `~/.pi/agent/extensions/mempalace.ts` — the TypeScript
|
||||
extension that wires [MemPalace](https://github.com/MemPalace/mempalace)'s MCP
|
||||
server into the [pi coding-agent](https://github.com/earendil-works/pi)
|
||||
harness. Installs wake-up context injection, per-tool schema passthrough,
|
||||
and a `/mempalace-diary` slash-command.
|
||||
|
||||
This directory **only** holds the bridge. Pi's own base config (keybindings,
|
||||
environment loader, settings template) lives in the sibling
|
||||
[`pi-toolkit`](https://gitea.jordbo.se/joakimp/pi-toolkit) repo — split out
|
||||
2026-05-05 so [`opencode-devbox`](https://gitea.jordbo.se/joakimp/opencode-devbox)
|
||||
can build slim containers that include pi without dragging in mempalace's
|
||||
dependencies (~300 MB).
|
||||
|
||||
**Jump to:**
|
||||
- [What it does](#what-it-does)
|
||||
- [Transport: local vs external](#transport-local-vs-external)
|
||||
- [Automatic transcript feeding](#automatic-transcript-feeding)
|
||||
- [The `Type.Unsafe` gotcha](#the-typeunsafe-gotcha)
|
||||
- [Deploying pi with mempalace on a new machine](#deploying-pi-with-mempalace-on-a-new-machine)
|
||||
- [Fail-soft, identity, debugging](#fail-soft)
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Connects to MemPalace** and does the MCP handshake (`initialize` +
|
||||
`notifications/initialized` + `tools/list`). By default it **spawns
|
||||
`mempalace-mcp`** as a local stdio subprocess (`StdioMcpClient`); if
|
||||
`$MEMPALACE_REMOTE_URL` is set it instead talks to a shared MemPalace over
|
||||
HTTP (`RemoteMcpClient`) and spawns no local process — see
|
||||
[Transport](#transport-local-vs-external).
|
||||
2. **Registers each MCP tool** as a pi tool with its real `inputSchema`
|
||||
passed through via `Type.Unsafe(...)` (see gotcha below).
|
||||
3. **Wake-up auto-injection** (`before_agent_start`, one-shot per fresh
|
||||
session): calls `mempalace_status` + `mempalace_diary_read` and
|
||||
injects the result as a `mempalace-wakeup` system message so the
|
||||
agent orients itself the way `~/.agents/skills/mempalace/SKILL.md`
|
||||
describes. Skipped on resume/fork (context is already in the thread).
|
||||
4. **Automatic transcript feeding** (`session_shutdown`, and a debounced
|
||||
`agent_settled`): stages + mines this pi installation's own session
|
||||
transcripts into the palace with no user action needed — **as of
|
||||
mempalace-toolkit `29e660e` (2026-08-12); see the version gate below,
|
||||
because "the extension is installed" does not imply "this copy can feed"**.
|
||||
Unlike the diary
|
||||
below, this needs no LLM turn — it's a subprocess + a tool call — so it
|
||||
*can* run on `session_shutdown` where the diary cannot. See
|
||||
[Automatic transcript feeding](#automatic-transcript-feeding).
|
||||
5. **Manual wind-down** via a `/mempalace-diary [topic]` slash command:
|
||||
sends a prompt asking the LLM to call `mempalace_diary_write` with
|
||||
an AAAK-formatted entry summarizing the session. This one stays manual
|
||||
because it needs the LLM to compose the entry, and `session_shutdown`
|
||||
fires too late to drive another LLM turn — a constraint that applies to
|
||||
the diary specifically, not to feeding (see above).
|
||||
|
||||
## Automatic transcript feeding
|
||||
|
||||
> **⚠️ Version gate — requires mempalace-toolkit ≥ `29e660e` (2026-08-12), and "installed" is not
|
||||
> the same question as "capable".** Feeding was added to this extension on 2026-08-12. A copy baked
|
||||
> into a container image built before that date has *no* feed path at all — its entire
|
||||
> `session_shutdown` handler is `client.stop()` — and it fails the only way a memory system must not:
|
||||
> silently, looking exactly like a healthy run with nothing to do.
|
||||
>
|
||||
> **Check the deployed artifact, never the repo.** `/opt/*` in an image is baked at build time and can
|
||||
> be days behind a bind-mounted clone, and `~/.pi/agent/extensions/mempalace.ts` is usually a symlink
|
||||
> *into* that baked copy:
|
||||
>
|
||||
> ```sh
|
||||
> grep -c MEMPALACE_FEED "$(readlink -f ~/.pi/agent/extensions/mempalace.ts)" # 0 = cannot feed
|
||||
> ```
|
||||
>
|
||||
> Zero hits means this machine needs the fallback recipes in
|
||||
> [`contrib/`](../../contrib/README.md) until it is rebuilt, regardless of what the toolkit repo's HEAD
|
||||
> looks like. Date the deployed copy with `stat` plus that content probe — not `git log`, which fails
|
||||
> with *"detected dubious ownership"* inside a root-owned `/opt` tree. As of 2026-08-14 the whole
|
||||
> pi-devbox fleet fails this check.
|
||||
|
||||
The bridge feeds this pi installation's own session transcripts into the
|
||||
palace by itself — no scheduler, no cron, no manual invocation. It fires on
|
||||
`session_shutdown` (covers quit, `/new`, `/resume`, `/fork`) and on a
|
||||
debounced `agent_settled` (covers a long session that later crashes, since a
|
||||
hard kill runs no shutdown handler at all).
|
||||
|
||||
The work is split across two processes, and the reason is a hard constraint,
|
||||
not a style choice: **the palace is single-writer.** A live pi session
|
||||
always holds it through this extension's own `mempalace-mcp` subprocess, so
|
||||
an unattended `mempalace mine` from anywhere else fails outright with
|
||||
`palace ... is held by PID <n>`. The bridge therefore:
|
||||
|
||||
1. Runs `mempalace-pi-session --prepare --reason <trigger> --wing <wing>` as a
|
||||
subprocess. This does every palace-free step — parse pi's JSONL, apply
|
||||
the quality threshold, stage the export, and (remote mode only) `rsync`
|
||||
it to the palace host — and prints one line, `MINE_SOURCE=<path>`,
|
||||
without ever touching the palace.
|
||||
2. Calls the `mempalace_mine` MCP tool **through this extension's own
|
||||
client** on that path. Going through the client that already holds the
|
||||
lock is the only way to write during a live session, and it automatically
|
||||
targets whichever palace the bridge is pointed at — local stdio or a
|
||||
shared remote one.
|
||||
|
||||
`mempalace-pi-session` (in this repo's `bin/`) is the actual exporter and
|
||||
owns the quality gate, the remote transport, and every flag — see its
|
||||
`--help` for the full reference; this section only covers the extension's
|
||||
side of the wiring.
|
||||
|
||||
**Env knobs (extension side):**
|
||||
|
||||
| Var | Default | Effect |
|
||||
|---|---|---|
|
||||
| `MEMPALACE_FEED` | `1` | Set `0` to disable automatic feeding entirely. |
|
||||
| `MEMPALACE_FEED_BIN` | `mempalace-pi-session` | Helper to run. |
|
||||
| `MEMPALACE_FEED_WING` | `wing_conversations` | Target wing — passed to both the exporter and the `mempalace_mine` call. |
|
||||
| `MEMPALACE_FEED_DEBOUNCE_MS` | `600000` (10 min) | Minimum gap between mid-session (`agent_settled`) feeds. Bounds crash loss to one window instead of a whole session. |
|
||||
| `MEMPALACE_FEED_PREPARE_TIMEOUT_MS` | `120000` | Kills a wedged `--prepare` subprocess. |
|
||||
| `MEMPALACE_FEED_MINE_TIMEOUT_MS` | `30000` | Caps the `mempalace_mine` call so a stalled palace can't hang session exit. |
|
||||
|
||||
**Remote palace:** if `$MEMPALACE_REMOTE_URL` is set (see
|
||||
[Transport](#transport-local-vs-external)), `mempalace_mine`'s source path is
|
||||
expanded on the *server*, which cannot see this machine's transcripts —
|
||||
that's exactly why step 1 above rsyncs first in that mode. Configure the
|
||||
inbox with `MEMPALACE_PI_SSH_TARGET` (required for remote feeding — feeding
|
||||
is silently skipped without it), `MEMPALACE_PI_SSH_CONFIG`, and
|
||||
`MEMPALACE_PI_REMOTE_PATH`; see `mempalace-pi-session --help`.
|
||||
|
||||
**Concurrency:** overlapping triggers coalesce — a `session_shutdown` landing
|
||||
while a debounced tick is still running joins that in-flight feed instead of
|
||||
racing it. `mempalace-pi-session` itself also takes a non-blocking `flock`,
|
||||
so even two independent invocations (e.g. this extension and the
|
||||
container-start catch-up some devbox images run) never race each other;
|
||||
losing that race is harmless because the next trigger re-exports from
|
||||
scratch.
|
||||
|
||||
## Transport: local vs external
|
||||
|
||||
The bridge speaks the same MCP protocol over two interchangeable transports,
|
||||
chosen at load time:
|
||||
|
||||
- **Local (default)** — spawns `mempalace-mcp` as a stdio subprocess; the palace
|
||||
lives wherever that process opens it (default `~/.mempalace`). This is the
|
||||
hardened path with per-request timeouts and respawn/self-heal (below).
|
||||
- **External** — set `MEMPALACE_REMOTE_URL` to a MemPalace HTTP endpoint (e.g.
|
||||
`https://mempalace.jordbo.se/mcp`, the live fleet primary — full path
|
||||
including `/mcp`, no trailing slash) and the bridge connects over HTTP
|
||||
instead, spawning no local process. Use this to share **one** palace across
|
||||
several harnesses/containers (pi + opencode + native).
|
||||
`MEMPALACE_REMOTE_TOKEN`, if set, is sent as `Authorization: Bearer <token>`.
|
||||
Use `https://` for anything crossing a network — the plaintext `http://`
|
||||
example that stood here until 2026-08-14 predated the reverse proxy.
|
||||
|
||||
The two transports are **either/or**, decided once at load time: with the URL
|
||||
set, writes go **only** to the remote palace. There is no dual-write, no
|
||||
local mirror, and no local `mempalace-mcp` process at all.
|
||||
|
||||
⚠️ **Consequence: once `MEMPALACE_REMOTE_URL` is set, the `mempalace` CLI on
|
||||
that machine is no longer a valid way to inspect or feed the palace the agent
|
||||
is using.** The CLI has no remote support whatsoever — its only selector is
|
||||
`--palace <path>` — so it reads and writes the LOCAL on-disk archive. After a
|
||||
flip that archive is frozen, yet `mempalace status` / `mempalace search`
|
||||
still report a plausible drawer count and look exactly like success: a
|
||||
false-positive machine. Memories filed with the CLI post-flip land in the
|
||||
dead archive, not in the shared palace. Use the agent's own palace tools
|
||||
(which go over HTTP), and mine backfills **on the palace host**.
|
||||
|
||||
**Setting these for a *native* pi install — there is no `.env` to edit.**
|
||||
Worth stating plainly, because the obvious guess is wrong: **pi loads no
|
||||
dotenv file and has no `env` block in `settings.json`**, and this extension
|
||||
reads `process.env` and nothing else (`createClient()` →
|
||||
`process.env.MEMPALACE_REMOTE_URL`). A native install therefore inherits
|
||||
whatever **the shell that launches `pi`** exports — that is the only hook.
|
||||
So export them from your shell rc, or from a file it explicitly sources:
|
||||
|
||||
```sh
|
||||
# ~/.zshrc (or ~/.bashrc) — if you keep secrets in ~/.config/pi/.env,
|
||||
# nothing sources it for you; do it yourself:
|
||||
set -a; [ -f ~/.config/pi/.env ] && . ~/.config/pi/.env; set +a
|
||||
```
|
||||
|
||||
Two traps. A pi launched from a **GUI** (Spotlight, dock, an editor's
|
||||
terminal that spawns a non-login shell) does not necessarily read that rc, so
|
||||
it can silently stay on the local palace. And exporting the variable in the
|
||||
shell where you *edited* the rc does not affect an already-running pi — the
|
||||
transport is chosen once at extension load. Confirm the result the same way
|
||||
as a container flip: ask the agent for `mempalace_status` and check the
|
||||
reported palace path is the **remote** host's, not your own
|
||||
`$HOME/.mempalace/palace` — see
|
||||
[`docs/phase-1-exposure-runbook.md`](../../docs/phase-1-exposure-runbook.md) §3.8.
|
||||
|
||||
Serve such an endpoint with `mempalace serve --host 172.17.0.1 --port 8765`
|
||||
(the `pi-devbox` / `opencode-devbox` repos ship a
|
||||
`docker-compose.mempalace.yml` for exactly this).
|
||||
|
||||
**The HTTP transport is authenticated as of mempalace 3.6.0** — earlier docs
|
||||
here said otherwise, from the v1.3.0 era. `serve` mints a bearer token, keeps
|
||||
it 0600, passes it via the environment (never argv), compares it with
|
||||
`hmac.compare_digest`, and **refuses to bind a non-loopback host without one**
|
||||
unless `--allow-insecure`. It also pins `Host` and allowlists `Origin`
|
||||
(anti-DNS-rebinding), and can terminate TLS itself.
|
||||
|
||||
Two binds to avoid. `0.0.0.0` publishes the palace to the whole LAN. And
|
||||
`127.0.0.1` is the trap that looks safe: the Host pin is enforced *only* on
|
||||
loopback binds, so behind a tunnel every proxied request 403s — and
|
||||
token auto-minting is gated on the bind being non-loopback, so it starts with
|
||||
**no authentication at all**, no warning. Bind the docker0 gateway
|
||||
(`172.17.0.1`): reachable from the host and its containers, not from the LAN.
|
||||
See
|
||||
[`docs/phase-1-exposure-runbook.md`](../../docs/phase-1-exposure-runbook.md).
|
||||
|
||||
Implementation note: the HTTP client (`RemoteMcpClient`) is **vendored** from
|
||||
[`pi-extensions`](https://gitea.jordbo.se/joakimp/pi-extensions)'
|
||||
`mcp-loader.ts`. A `MCP-STREAMABLE-HTTP-CLIENT-SYNC` token keeps the two
|
||||
copies from drifting — [`scripts/check-mcp-client-sync.sh`](../../scripts/check-mcp-client-sync.sh)
|
||||
fails if they diverge (it skips gracefully when the `pi-extensions` checkout
|
||||
isn't present).
|
||||
|
||||
## Fail-soft
|
||||
|
||||
If `mempalace-mcp` can't be spawned (PATH missing, binary crashes at
|
||||
startup, …) the extension logs to stderr and returns early. pi keeps
|
||||
working without palace tools rather than refusing to start.
|
||||
|
||||
**In remote mode the triggers differ but the outcome is identical.** An
|
||||
unreachable server, a DNS failure, or an HTTP 401 from a wrong/expired token
|
||||
all end the same way: after bounded retries the extension prints
|
||||
`mempalace-mcp unavailable after retries; continuing without palace tools` and
|
||||
**does not register the palace tools**.
|
||||
|
||||
It is **fail-closed, not fail-local**: it does *not* quietly fall back to the
|
||||
local palace, so a remote outage can never scatter memories into a local copy
|
||||
nobody will look at again. The practical corollary, worth knowing before you
|
||||
debug the wrong layer: **"the agent has no `mempalace_*` tools" is the
|
||||
expected symptom of a server, token, or DNS fault**, not of a broken install.
|
||||
Diagnose it with a direct `curl` to `MEMPALACE_REMOTE_URL` — see
|
||||
[`docs/phase-1-exposure-runbook.md`](../../docs/phase-1-exposure-runbook.md)
|
||||
§3.8. The design rationale for de-registering rather than degrading is in
|
||||
[`docs/rfc-001-global-palace.md`](../../docs/rfc-001-global-palace.md) §2 and §4.1.
|
||||
|
||||
## Identity
|
||||
|
||||
`agent_name` for diary calls comes from `$MEMPALACE_AGENT_NAME`, defaulting
|
||||
to `"pi"`. First diary write against that identity creates `wing_<name>`
|
||||
in the palace. Set the env var if you want to run pi under a distinct
|
||||
identity on a given machine (e.g. `pi-laptop` vs `pi-server`).
|
||||
|
||||
## Stall protection (per-request timeout)
|
||||
|
||||
Every JSON-RPC request to `mempalace-mcp` carries a timeout. Without it, a
|
||||
wedged server (classically: an OrbStack/virtiofs cold-open of a large
|
||||
`chroma.sqlite3` or an HNSW load) leaves the awaiting promise pending
|
||||
*forever*, which freezes the pi TUI — ESC cancels the LLM stream, not a
|
||||
pending tool `execute()`. On timeout the extension rejects the request
|
||||
**and** kills the stalled child (SIGTERM→SIGKILL), so pi gets a clear
|
||||
error instead of hanging. This is a per-REQUEST timeout, not a process-lifetime
|
||||
one — the long-lived server is only killed when a request genuinely stalls.
|
||||
|
||||
- `MEMPALACE_MCP_TIMEOUT_MS` — tool-call/request timeout. Default `60000`.
|
||||
Kept short on purpose: a *query* taking this long is genuinely wedged.
|
||||
- `MEMPALACE_MCP_INIT_TIMEOUT_MS` — `initialize` + `tools/list` handshake
|
||||
timeout. Default `300000`. Deliberately generous: a genuine first
|
||||
cold-open over virtiofs can legitimately take minutes, and killing a
|
||||
still-progressing init only to respawn and re-pay the same cold cost is
|
||||
strictly worse than waiting.
|
||||
- Set either to `0` to disable (legacy unbounded behavior).
|
||||
|
||||
### Self-heal (respawn instead of a permanent latch)
|
||||
|
||||
A stall-kill (or any crash) used to be a **permanent** latch: `available`
|
||||
flipped off and stayed off until you restarted pi. It is now self-healing —
|
||||
the next tool call transparently respawns `mempalace-mcp` and retries.
|
||||
|
||||
- Respawns use **capped exponential backoff** so a persistently-broken
|
||||
server can't hot-loop: `MEMPALACE_MCP_MAX_RESPAWNS` attempts (default
|
||||
`2`; set `0` to disable self-heal and keep the old fail-fast latch),
|
||||
with `MEMPALACE_MCP_RESPAWN_BACKOFF_MS` (default `1000`) doubled per
|
||||
attempt.
|
||||
- The budget **resets on any successful JSON-RPC response** — proof the
|
||||
server is actually live — so a server that recovers regains full
|
||||
patience, while one that keeps dying hits the cap and stays down (then
|
||||
restart pi).
|
||||
- Why the long init timeout and bounded respawn compose rather than
|
||||
overlap: once a server has opened the palace once, the OS page cache is
|
||||
warm, so respawn cold-opens are fast. The long init timeout prevents
|
||||
killing a healthy *first* cold-open; the respawn handles a genuinely
|
||||
dead server cheaply afterwards. (Note the HNSW deserialize is CPU work
|
||||
that isn't page-cacheable across spawns, which is exactly why we can't
|
||||
rely on respawn-warming alone and keep the generous init budget.)
|
||||
- The initial startup is tolerant too: if the very first `start()` fails,
|
||||
the extension runs the same bounded respawn before falling back to
|
||||
fail-soft (pi keeps working without palace tools).
|
||||
|
||||
## Debugging
|
||||
|
||||
- `MEMPALACE_EXT_DEBUG=1` — surface `mempalace-mcp` stderr into pi's
|
||||
stderr. Without this, stderr is drained silently so a misbehaving
|
||||
server doesn't flood the TUI.
|
||||
- If a tool call fails with a generic "Internal tool error", spawn
|
||||
`mempalace-mcp` manually with raw JSON-RPC on stdin to read the
|
||||
server-side error — much faster than guessing.
|
||||
|
||||
## The `Type.Unsafe` gotcha
|
||||
|
||||
Earlier versions of this extension registered every MCP tool with
|
||||
`parameters: Type.Object({}, { additionalProperties: true })`, which
|
||||
discarded each tool's real `inputSchema`. The LLM then saw no parameter
|
||||
names and had to guess, leading to bugs like `mempalace_diary_read`
|
||||
being called with `agent=` instead of the required `agent_name=` and
|
||||
crashing the Python server with `TypeError: missing 1 required
|
||||
positional argument`.
|
||||
|
||||
The fix (≈ lines 160-170) is to wrap the incoming JSON Schema with
|
||||
`Type.Unsafe<...>(tool.inputSchema)`. TypeBox schemas are plain JSON
|
||||
Schema at runtime plus a `Symbol` marker, so wrapping an
|
||||
externally-sourced schema with `Unsafe` is sufficient — no conversion
|
||||
to a full TypeBox tree is needed, and the LLM now sees every tool's
|
||||
real parameter names.
|
||||
|
||||
If you ever need to re-loosen the schema for debugging, fall back to
|
||||
the `Type.Object({}, { additionalProperties: true })` default only for
|
||||
that specific tool, not globally.
|
||||
|
||||
---
|
||||
|
||||
## Deploying pi with mempalace on a new machine
|
||||
|
||||
This is the "pi + memory" recipe. For pi without mempalace, see
|
||||
[`pi-toolkit`'s README](https://gitea.jordbo.se/joakimp/pi-toolkit/src/branch/main/README.md#deploying-pi-on-a-new-machine).
|
||||
|
||||
### 0. Prerequisites
|
||||
|
||||
- Shell: zsh + oh-my-zsh recommended (both toolkits install loaders into
|
||||
`~/.oh-my-zsh/custom/`; bash works too, installers print the manual
|
||||
`source` snippet).
|
||||
- `git`, `node` ≥ 20, `uv`, `tmux` ≥ 3.2, pi installed upstream.
|
||||
- AWS credentials reachable via `AWS_PROFILE` — only if using
|
||||
`amazon-bedrock` as pi's provider.
|
||||
|
||||
### 1. Dotfiles (if you keep one)
|
||||
|
||||
Brings `~/.config/pi/.env` (AWS creds, git-crypt encrypted), tmux CSI-u
|
||||
extended keys, and other machine state:
|
||||
|
||||
```bash
|
||||
git clone <your-dotfiles> ~/src/dotfiles
|
||||
cd ~/src/dotfiles
|
||||
git-crypt unlock <key>
|
||||
./provision.sh --profile <profile> # or your equivalent tool
|
||||
```
|
||||
|
||||
### 2. Install pi upstream
|
||||
|
||||
```bash
|
||||
brew install pi-coding-agent # macOS
|
||||
# or see https://github.com/earendil-works/pi for Linux
|
||||
pi --help # creates ~/.pi/agent/
|
||||
```
|
||||
|
||||
### 3. Install pi-toolkit (base pi config)
|
||||
|
||||
```bash
|
||||
git clone ssh://git@gitea.jordbo.se:2222/joakimp/pi-toolkit.git ~/pi-toolkit
|
||||
cd ~/pi-toolkit && ./install.sh
|
||||
```
|
||||
|
||||
Symlinks `keybindings.json`, copies `pi-env.zsh` into
|
||||
`~/.oh-my-zsh/custom/`, and prints the `settings.json` bootstrap command.
|
||||
|
||||
### 4. Bootstrap pi settings
|
||||
|
||||
```bash
|
||||
cp ~/pi-toolkit/settings.example.json ~/.pi/agent/settings.json
|
||||
$EDITOR ~/.pi/agent/settings.json # eu./us./anthropic: prefix
|
||||
```
|
||||
|
||||
### 5. Install mempalace CLI + this toolkit
|
||||
|
||||
```bash
|
||||
uv tool install mempalace
|
||||
git clone ssh://git@gitea.jordbo.se:2222/joakimp/mempalace-toolkit.git ~/mempalace-toolkit
|
||||
cd ~/mempalace-toolkit && ./install.sh
|
||||
```
|
||||
|
||||
Detects pi, symlinks `mempalace.ts` into `~/.pi/agent/extensions/`.
|
||||
Also detects pi-toolkit artifacts and prints a green check (or a warning
|
||||
telling you to install pi-toolkit first if you skipped step 3).
|
||||
|
||||
### 6. Register mempalace MCP with opencode (if applicable)
|
||||
|
||||
Skip if this box is pi-only. Otherwise:
|
||||
- Install [`opencode-toolkit`](https://gitea.jordbo.se/joakimp/opencode-toolkit) so `~/.config/opencode/.env` is sourced into every shell (GitHub / Gitea / other MCP server tokens).
|
||||
- Register the mempalace MCP server in `~/.config/opencode/opencode.json` — see [root README § Registering mempalace with opencode](../../README.md#registering-mempalace-with-opencode-or-other-mcp-clients).
|
||||
|
||||
### 7. First run
|
||||
|
||||
```bash
|
||||
exec zsh
|
||||
pi # should start with defaults; wake-up injection shows palace status
|
||||
```
|
||||
|
||||
If the wake-up doesn't print, run `MEMPALACE_EXT_DEBUG=1 pi` to surface
|
||||
`mempalace-mcp` stderr.
|
||||
|
||||
### Verification checklist
|
||||
|
||||
```bash
|
||||
# MCP bridge in place
|
||||
ls -la ~/.pi/agent/extensions/mempalace.ts # → this repo
|
||||
|
||||
# pi-toolkit artifacts also in place
|
||||
ls -la ~/.pi/agent/keybindings.json # → pi-toolkit
|
||||
ls -la ~/.oh-my-zsh/custom/pi-env.zsh # cp from pi-toolkit
|
||||
|
||||
# Env loaded
|
||||
zsh -ic 'echo $AWS_PROFILE $AWS_REGION'
|
||||
|
||||
# Palace reachable
|
||||
mempalace status
|
||||
```
|
||||
|
||||
### Uninstall
|
||||
|
||||
```bash
|
||||
cd ~/mempalace-toolkit && ./install.sh --uninstall --yes # bridge only
|
||||
cd ~/pi-toolkit && ./install.sh --uninstall --yes # pi base config
|
||||
# Leaves pi itself, mempalace CLI, and ~/.config/pi/.env alone.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
mempalace-toolkit/
|
||||
└── extensions/
|
||||
└── pi/
|
||||
├── README.md ← this file
|
||||
└── mempalace.ts ← symlinked into ~/.pi/agent/extensions/
|
||||
```
|
||||
|
||||
Pi base config (keybindings, env loader, settings template) lives in
|
||||
[`pi-toolkit`](https://gitea.jordbo.se/joakimp/pi-toolkit). `install.sh`
|
||||
detects pi via `~/.pi/agent/extensions/` and runs a `check_pi_toolkit`
|
||||
probe that warns if pi-toolkit's artifacts are missing.
|
||||
@@ -0,0 +1,933 @@
|
||||
/**
|
||||
* MemPalace ↔ pi bridge.
|
||||
*
|
||||
* Registers every MemPalace MCP tool as a pi tool that proxies to `tools/call`.
|
||||
* Two interchangeable transports, selected at load time:
|
||||
*
|
||||
* - LOCAL (default): spawn the `mempalace-mcp` stdio server as a subprocess
|
||||
* (StdioMcpClient) — hardened with per-request timeouts + generation-tracked
|
||||
* respawn/self-heal for slow cold-opens (see below).
|
||||
* - EXTERNAL: connect to a shared MemPalace over HTTP (RemoteMcpClient) when
|
||||
* $MEMPALACE_REMOTE_URL is set — e.g. one palace serving pi + opencode +
|
||||
* native. Optional bearer auth via $MEMPALACE_REMOTE_TOKEN. No local
|
||||
* `mempalace-mcp` process is spawned in this mode.
|
||||
*
|
||||
* Either way the client performs the MCP `initialize` handshake, lists tools,
|
||||
* and the extension body below is transport-agnostic (depends only on the
|
||||
* IMcpClient interface).
|
||||
*
|
||||
* Lifecycle automation (per ~/.agents/skills/mempalace/SKILL.md):
|
||||
* - Wake-up (auto): on first user prompt of a fresh session, inject
|
||||
* `mempalace_status` + `mempalace_diary_read` output as context so the
|
||||
* agent orients itself the way the mempalace skill describes. Skipped
|
||||
* on resume/fork (palace context is already in the thread).
|
||||
* - Feeding (auto): stage + mine this container's pi transcripts into the
|
||||
* palace on `session_shutdown` and on a debounced `agent_settled`. Needs
|
||||
* no LLM turn (pi transcripts are JSONL on disk), which is why it CAN be
|
||||
* automatic where the diary cannot. The file-side work is delegated to
|
||||
* `mempalace-pi-session --prepare` (export + threshold + staging, plus the
|
||||
* rsync to the palace host when the palace is remote); the mine itself
|
||||
* must run through THIS client, because the palace is single-writer and
|
||||
* this process is the holder — a CLI `mempalace mine` during a live
|
||||
* session dies with "palace ... is held by PID <ours>". Going through the
|
||||
* client also means it automatically targets whichever palace this bridge
|
||||
* is pointed at (local stdio or a shared remote one).
|
||||
* - MEMPALACE_FEED=0 disable feeding entirely
|
||||
* - MEMPALACE_FEED_BIN helper to run (default mempalace-pi-session)
|
||||
* - MEMPALACE_FEED_WING target wing (default wing_conversations)
|
||||
* - MEMPALACE_FEED_DEBOUNCE_MS min gap between mid-session feeds (default 600000)
|
||||
* - Wind-down (manual): `/mempalace-diary` command prompts the LLM to
|
||||
* write an AAAK-formatted diary entry. Not fully auto because pi
|
||||
* sessions are typically short/tactical and session_shutdown is too
|
||||
* late to drive an LLM turn.
|
||||
*
|
||||
* Identity: `agent_name` for diary calls comes from $MEMPALACE_AGENT_NAME,
|
||||
* defaulting to "pi". First diary write creates `wing_pi`.
|
||||
*
|
||||
* Fail-soft: if the MCP subprocess can't start, pi keeps working without
|
||||
* palace tools (warning on stderr only).
|
||||
*
|
||||
* Stall protection: every JSON-RPC request carries a timeout. If
|
||||
* `mempalace-mcp` wedges (e.g. OrbStack virtiofs cold-open of a large
|
||||
* chroma.sqlite3 / HNSW load), the awaiting promise would otherwise hang
|
||||
* forever and freeze the pi TUI (ESC cancels the LLM stream, not a pending
|
||||
* tool execution). On timeout we reject the request AND kill the wedged
|
||||
* child, so pi gets an error instead of hanging and later calls fail fast.
|
||||
* This is a per-REQUEST timeout, not a process-lifetime one — the
|
||||
* long-lived server is only killed when a request genuinely stalls.
|
||||
* - MEMPALACE_MCP_TIMEOUT_MS tool-call/request timeout (default 60000)
|
||||
* - MEMPALACE_MCP_INIT_TIMEOUT_MS initialize+tools/list timeout (default 300000)
|
||||
* Set either to 0 to disable (legacy unbounded behavior).
|
||||
*
|
||||
* Self-heal (respawn): a stall-kill (or any crash) is no longer a permanent
|
||||
* latch. The next tool call transparently respawns `mempalace-mcp` and
|
||||
* retries, with capped exponential backoff so a persistently-broken server
|
||||
* can't hot-loop. The respawn budget resets on ANY successful JSON-RPC
|
||||
* response (proof the server is actually live), so a recovered server
|
||||
* regains full patience. The two timeouts above are deliberately split:
|
||||
* the long INIT timeout lets a genuine first cold-open finish without being
|
||||
* killed (the original incident), while the short per-call timeout still
|
||||
* aggressively kills a stuck query. After a server has opened the palace
|
||||
* once, the OS page cache is warm, so respawn cold-opens are fast — which
|
||||
* is exactly why a generous INIT timeout + bounded respawn compose well
|
||||
* instead of overlapping.
|
||||
* - MEMPALACE_MCP_MAX_RESPAWNS respawn attempts before giving up (default 2; 0 disables self-heal)
|
||||
* - MEMPALACE_MCP_RESPAWN_BACKOFF_MS base backoff, doubled per attempt (default 1000)
|
||||
*
|
||||
* Debug: set MEMPALACE_EXT_DEBUG=1 to surface mempalace-mcp stderr.
|
||||
*/
|
||||
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
|
||||
// Minimal MCP stdio JSON-RPC client. MCP uses newline-delimited JSON.
|
||||
interface McpTool {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: unknown;
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
resolve: (v: any) => void;
|
||||
reject: (e: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
// Transport-agnostic client contract. The extension body depends only on this,
|
||||
// so LOCAL (StdioMcpClient) and EXTERNAL (RemoteMcpClient) are drop-in swaps.
|
||||
// Each implementation owns its own reliability model — stdio uses timeout +
|
||||
// kill + respawn; http uses fetch + AbortController timeout (+ 404 re-init if
|
||||
// a future streamable-HTTP server issues sessions).
|
||||
interface IMcpClient {
|
||||
tools: McpTool[];
|
||||
readonly alive: boolean;
|
||||
onExit: (() => void) | null;
|
||||
start(): Promise<void>;
|
||||
callTool(name: string, args: Record<string, unknown>): Promise<any>;
|
||||
ensureAlive(): Promise<boolean>;
|
||||
stop(): void | Promise<void>;
|
||||
}
|
||||
|
||||
const num = (envVal: string | undefined, fallback: number): number => {
|
||||
const n = envVal !== undefined ? Number(envVal) : Number.NaN;
|
||||
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
||||
};
|
||||
|
||||
const sleep = (ms: number): Promise<void> =>
|
||||
new Promise((res) => {
|
||||
const t = setTimeout(res, ms);
|
||||
if (typeof t.unref === "function") t.unref();
|
||||
});
|
||||
|
||||
class StdioMcpClient implements IMcpClient {
|
||||
private proc: ChildProcessWithoutNullStreams | null = null;
|
||||
private nextId = 1;
|
||||
private pending = new Map<number, Pending>();
|
||||
private stdoutBuf = "";
|
||||
private ready: Promise<void> | null = null;
|
||||
public tools: McpTool[] = [];
|
||||
|
||||
// Per-request timeouts (ms). 0 = disabled (unbounded, legacy behavior).
|
||||
private requestTimeoutMs = num(process.env.MEMPALACE_MCP_TIMEOUT_MS, 60_000);
|
||||
private initTimeoutMs = num(process.env.MEMPALACE_MCP_INIT_TIMEOUT_MS, 300_000);
|
||||
// Self-heal (respawn) controls.
|
||||
private maxRespawns = num(process.env.MEMPALACE_MCP_MAX_RESPAWNS, 2);
|
||||
private respawnBackoffMs = num(process.env.MEMPALACE_MCP_RESPAWN_BACKOFF_MS, 1_000);
|
||||
private respawns = 0; // consecutive respawn attempts; reset on any success
|
||||
private reviving: Promise<boolean> | null = null;
|
||||
// Liveness: true only between a completed init and the matching death.
|
||||
// Inferring from `proc` is racy (non-null during the SIGTERM→exit window).
|
||||
private healthy = false;
|
||||
// Spawn generation. Each (re)spawn bumps it; death handlers carry the gen
|
||||
// they were attached for and no-op if a newer server has since taken over
|
||||
// (prevents a stale OLD-proc 'exit' from clobbering a freshly respawned one).
|
||||
private gen = 0;
|
||||
// Spawn args remembered so a respawn can reuse them (set via constructor).
|
||||
private command: string;
|
||||
private args: string[];
|
||||
// Fired when the child process dies (exit or stall-kill). Lets the
|
||||
// extension flip `available` so later tool calls fail fast.
|
||||
public onExit: (() => void) | null = null;
|
||||
|
||||
constructor(command = "mempalace-mcp", args: string[] = []) {
|
||||
this.command = command;
|
||||
this.args = args;
|
||||
}
|
||||
|
||||
/** True only when a server has completed init and not since died. */
|
||||
get alive(): boolean {
|
||||
return this.healthy;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.ready) return this.ready;
|
||||
this.ready = (async () => {
|
||||
const myGen = ++this.gen;
|
||||
const child = spawn(this.command, this.args, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
this.proc = child;
|
||||
child.on("error", (err) => this.handleDeath(myGen, err));
|
||||
child.on("exit", (code) =>
|
||||
this.handleDeath(myGen, new Error(`mempalace-mcp exited (code=${code})`)),
|
||||
);
|
||||
this.proc.stdout.setEncoding("utf8");
|
||||
this.proc.stdout.on("data", (chunk: string) => this.onStdout(chunk));
|
||||
// Drain stderr silently. Re-enable by setting MEMPALACE_EXT_DEBUG=1.
|
||||
this.proc.stderr.setEncoding("utf8");
|
||||
if (process.env.MEMPALACE_EXT_DEBUG) {
|
||||
this.proc.stderr.on("data", (chunk: string) => {
|
||||
process.stderr.write(`[mempalace-mcp stderr] ${chunk}`);
|
||||
});
|
||||
} else {
|
||||
this.proc.stderr.resume(); // drain without logging
|
||||
}
|
||||
|
||||
// MCP initialize handshake. Cold-open over virtiofs can be slow, so
|
||||
// use the (longer) init timeout here rather than the per-call one.
|
||||
await this.request(
|
||||
"initialize",
|
||||
{
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "pi-mempalace-ext", version: "0.1.0" },
|
||||
},
|
||||
this.initTimeoutMs,
|
||||
);
|
||||
this.notify("notifications/initialized", {});
|
||||
|
||||
const listed = await this.request("tools/list", {}, this.initTimeoutMs);
|
||||
this.tools = (listed?.tools as McpTool[]) ?? [];
|
||||
// Init complete and the process is still ours — mark live so ensureAlive()
|
||||
// reports true. Guard against a death that raced in during init.
|
||||
if (myGen === this.gen) this.healthy = true;
|
||||
})();
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
private onStdout(chunk: string) {
|
||||
this.stdoutBuf += chunk;
|
||||
let nl: number;
|
||||
while ((nl = this.stdoutBuf.indexOf("\n")) !== -1) {
|
||||
const line = this.stdoutBuf.slice(0, nl).trim();
|
||||
this.stdoutBuf = this.stdoutBuf.slice(nl + 1);
|
||||
if (!line) continue;
|
||||
let msg: any;
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (typeof msg.id === "number" && this.pending.has(msg.id)) {
|
||||
const p = this.pending.get(msg.id)!;
|
||||
this.settle(msg.id);
|
||||
if (msg.error) p.reject(new Error(msg.error.message ?? "MCP error"));
|
||||
else {
|
||||
// A successful response proves the server is live — restore the
|
||||
// full respawn budget for any future (unrelated) stall.
|
||||
this.respawns = 0;
|
||||
p.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
// notifications (no id) ignored for now.
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a pending request and clear its timeout timer. */
|
||||
private settle(id: number) {
|
||||
const p = this.pending.get(id);
|
||||
if (p?.timer) clearTimeout(p.timer);
|
||||
this.pending.delete(id);
|
||||
}
|
||||
|
||||
private failAll(err: Error) {
|
||||
for (const id of [...this.pending.keys()]) {
|
||||
const p = this.pending.get(id)!;
|
||||
this.settle(id);
|
||||
p.reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Child died (exit, spawn error, or stall-kill): reject everything.
|
||||
* Ignored if a newer generation has already taken over — a late 'exit'
|
||||
* from a killed old process must not tear down a fresh respawn.
|
||||
*/
|
||||
private handleDeath(gen: number, err: Error) {
|
||||
if (gen !== this.gen) return; // stale handler from a superseded process
|
||||
this.proc = null;
|
||||
this.healthy = false;
|
||||
// Clear the memoized start() promise so a subsequent start()/ensureAlive()
|
||||
// spawns a fresh server instead of returning the dead one's promise.
|
||||
this.ready = null;
|
||||
this.failAll(err);
|
||||
try {
|
||||
this.onExit?.();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a live, initialized server — respawning a dead one with capped
|
||||
* exponential backoff. Returns whether the server is alive afterwards.
|
||||
* Concurrent callers share a single in-flight revive. The attempt counter
|
||||
* is reset by `onStdout` on any successful response, so a server that comes
|
||||
* back and works regains its full budget; a server that keeps dying hits
|
||||
* `maxRespawns` and stays down (restart pi) rather than hot-looping.
|
||||
*/
|
||||
async ensureAlive(): Promise<boolean> {
|
||||
if (this.alive) return true;
|
||||
if (this.reviving) return this.reviving;
|
||||
this.reviving = (async () => {
|
||||
while (!this.alive && this.respawns < this.maxRespawns) {
|
||||
this.respawns++;
|
||||
await sleep(this.respawnBackoffMs * 2 ** (this.respawns - 1));
|
||||
// Force a fresh spawn: drop any settled (rejected/dead) start()
|
||||
// promise so start() doesn't short-circuit on a stale memo. Safe
|
||||
// because ensureAlive is serialized (single `reviving`) and only
|
||||
// runs while not healthy — no useful in-flight start() can exist.
|
||||
this.ready = null;
|
||||
try {
|
||||
await this.start();
|
||||
} catch {
|
||||
// start() rejected (e.g. respawn cold-open also stalled and was
|
||||
// killed). Loop until the budget is exhausted.
|
||||
}
|
||||
}
|
||||
return this.alive;
|
||||
})();
|
||||
try {
|
||||
return await this.reviving;
|
||||
} finally {
|
||||
this.reviving = null;
|
||||
}
|
||||
}
|
||||
|
||||
private write(obj: unknown) {
|
||||
if (!this.proc) throw new Error("MCP process not started");
|
||||
this.proc.stdin.write(`${JSON.stringify(obj)}\n`);
|
||||
}
|
||||
|
||||
private notify(method: string, params: unknown) {
|
||||
this.write({ jsonrpc: "2.0", method, params });
|
||||
}
|
||||
|
||||
request(method: string, params: unknown, timeoutMs = this.requestTimeoutMs): Promise<any> {
|
||||
const id = this.nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
if (timeoutMs > 0) {
|
||||
timer = setTimeout(() => {
|
||||
if (!this.pending.has(id)) return;
|
||||
this.settle(id);
|
||||
reject(
|
||||
new Error(
|
||||
`mempalace-mcp request '${method}' timed out after ${timeoutMs}ms ` +
|
||||
`(server wedged — likely cold storage open). Terminating the ` +
|
||||
`stalled server; it will be respawned on the next call.`,
|
||||
),
|
||||
);
|
||||
// Kill the wedged child so subsequent calls fail fast instead of
|
||||
// stacking up behind a dead server. The 'exit' handler
|
||||
// (handleDeath) rejects any other pending requests.
|
||||
this.kill();
|
||||
}, timeoutMs);
|
||||
// Don't let a pending MCP timer keep the event loop alive.
|
||||
if (typeof timer.unref === "function") timer.unref();
|
||||
}
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
try {
|
||||
this.write({ jsonrpc: "2.0", id, method, params });
|
||||
} catch (err) {
|
||||
this.settle(id);
|
||||
reject(err as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async callTool(name: string, args: Record<string, unknown>): Promise<any> {
|
||||
return this.request("tools/call", { name, arguments: args });
|
||||
}
|
||||
|
||||
/** SIGTERM then SIGKILL grace, for stall recovery. */
|
||||
private kill() {
|
||||
const proc = this.proc;
|
||||
if (!proc) return;
|
||||
try {
|
||||
proc.kill("SIGTERM");
|
||||
} catch {}
|
||||
const t = setTimeout(() => {
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch {}
|
||||
}, 2_000);
|
||||
if (typeof t.unref === "function") t.unref();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill("SIGTERM");
|
||||
} catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
// RemoteMcpClient — EXTERNAL transport (streamable-HTTP / sessionless JSON-RPC).
|
||||
//
|
||||
// VENDORED from pi-extensions/extensions/mcp-loader.ts (class RemoteMcpClient).
|
||||
// Kept as a copy rather than a shared import because the two repos ship
|
||||
// independently; the canonical implementation lives in mcp-loader.ts. Port
|
||||
// protocol fixes in both directions.
|
||||
//
|
||||
// MCP-STREAMABLE-HTTP-CLIENT-SYNC: v1
|
||||
// This token must match the canonical block's. Bump it in BOTH files whenever
|
||||
// the streamable-HTTP protocol handling changes; scripts/check-mcp-client-sync.sh
|
||||
// fails when they diverge (skips gracefully if pi-extensions isn't checked out).
|
||||
//
|
||||
// Deltas from the canonical copy:
|
||||
// • protocolVersion pinned to "2024-11-05" to match mempalace-mcp's stdio
|
||||
// handshake (the server echoes whatever it is sent, so cosmetic today,
|
||||
// but keeps both transports consistent).
|
||||
// • per-request AbortController timeout honouring MEMPALACE_MCP_TIMEOUT_MS /
|
||||
// MEMPALACE_MCP_INIT_TIMEOUT_MS, mirroring StdioMcpClient's timeout ethos.
|
||||
// • alive / ensureAlive / onExit to satisfy IMcpClient.
|
||||
//
|
||||
// NOTE: mempalace-mcp --transport http is a SESSIONLESS, stateless JSON-RPC
|
||||
// server (no Mcp-Session-Id, always application/json, Connection: close), so
|
||||
// the session-id / SSE / 404-reinit branches below are never exercised against
|
||||
// it today. They are retained so this client also works unchanged if mempalace
|
||||
// later moves to a full streamable-HTTP (FastMCP) transport.
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
const REMOTE_PROTOCOL_VERSION = "2024-11-05";
|
||||
const REMOTE_CLIENT_INFO = { name: "pi-mempalace-ext", version: "0.1.0" };
|
||||
|
||||
class RemoteMcpClient implements IMcpClient {
|
||||
private url: string;
|
||||
private extraHeaders: Record<string, string>;
|
||||
private sessionId: string | null = null;
|
||||
private nextId = 1;
|
||||
public tools: McpTool[] = [];
|
||||
// Unused for HTTP (no child process to die) — present to satisfy IMcpClient.
|
||||
public onExit: (() => void) | null = null;
|
||||
|
||||
private requestTimeoutMs = num(process.env.MEMPALACE_MCP_TIMEOUT_MS, 60_000);
|
||||
private initTimeoutMs = num(process.env.MEMPALACE_MCP_INIT_TIMEOUT_MS, 300_000);
|
||||
private healthy = false;
|
||||
private reviving: Promise<boolean> | null = null;
|
||||
|
||||
constructor(url: string, headers?: Record<string, string>) {
|
||||
this.url = url;
|
||||
this.extraHeaders = headers ?? {};
|
||||
}
|
||||
|
||||
get alive(): boolean {
|
||||
return this.healthy;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.request(
|
||||
"initialize",
|
||||
{ protocolVersion: REMOTE_PROTOCOL_VERSION, capabilities: {}, clientInfo: REMOTE_CLIENT_INFO },
|
||||
{ timeoutMs: this.initTimeoutMs },
|
||||
);
|
||||
await this.notify("notifications/initialized", {});
|
||||
const listed = await this.request("tools/list", {}, { timeoutMs: this.initTimeoutMs });
|
||||
this.tools = (listed?.tools as McpTool[]) ?? [];
|
||||
this.healthy = true;
|
||||
}
|
||||
|
||||
async callTool(name: string, args: Record<string, unknown>): Promise<any> {
|
||||
return this.request("tools/call", { name, arguments: args });
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-establish connectivity (and re-list tools) after a fetch failure.
|
||||
* For the stateless server this is just a fresh initialize+tools/list;
|
||||
* concurrent callers share one in-flight attempt.
|
||||
*/
|
||||
async ensureAlive(): Promise<boolean> {
|
||||
if (this.healthy) return true;
|
||||
if (this.reviving) return this.reviving;
|
||||
this.reviving = (async () => {
|
||||
try {
|
||||
this.sessionId = null;
|
||||
await this.start();
|
||||
} catch {
|
||||
// stays unhealthy
|
||||
}
|
||||
return this.healthy;
|
||||
})();
|
||||
try {
|
||||
return await this.reviving;
|
||||
} finally {
|
||||
this.reviving = null;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.healthy = false;
|
||||
if (!this.sessionId) return;
|
||||
try {
|
||||
await fetch(this.url, { method: "DELETE", headers: this.buildHeaders() });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private buildHeaders(): Record<string, string> {
|
||||
const h: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
"MCP-Protocol-Version": REMOTE_PROTOCOL_VERSION,
|
||||
...this.extraHeaders,
|
||||
};
|
||||
if (this.sessionId) h["Mcp-Session-Id"] = this.sessionId;
|
||||
return h;
|
||||
}
|
||||
|
||||
private signalFor(timeoutMs: number): AbortSignal | undefined {
|
||||
return timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined;
|
||||
}
|
||||
|
||||
private async reinitialize(): Promise<void> {
|
||||
await this.request(
|
||||
"initialize",
|
||||
{ protocolVersion: REMOTE_PROTOCOL_VERSION, capabilities: {}, clientInfo: REMOTE_CLIENT_INFO },
|
||||
{ timeoutMs: this.initTimeoutMs, allowReinitOn404: false },
|
||||
);
|
||||
await this.notify("notifications/initialized", {});
|
||||
}
|
||||
|
||||
private async request(
|
||||
method: string,
|
||||
params: unknown,
|
||||
opts: { timeoutMs?: number; allowReinitOn404?: boolean } = {},
|
||||
): Promise<any> {
|
||||
const timeoutMs = opts.timeoutMs ?? this.requestTimeoutMs;
|
||||
const allowReinitOn404 = opts.allowReinitOn404 ?? true;
|
||||
const id = this.nextId++;
|
||||
const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
||||
const sessionAtStart = this.sessionId;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(this.url, {
|
||||
method: "POST",
|
||||
headers: this.buildHeaders(),
|
||||
body,
|
||||
signal: this.signalFor(timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
// Network failure / timeout (AbortError) — mark unreachable so the next
|
||||
// tool call triggers ensureAlive().
|
||||
this.healthy = false;
|
||||
const e = err as Error;
|
||||
const reason =
|
||||
e.name === "AbortError" || e.name === "TimeoutError"
|
||||
? `timed out after ${timeoutMs}ms`
|
||||
: e.message;
|
||||
throw new Error(`mempalace remote request '${method}' failed: ${reason}`);
|
||||
}
|
||||
|
||||
const sid = res.headers.get("Mcp-Session-Id") ?? res.headers.get("mcp-session-id");
|
||||
if (sid) this.sessionId = sid;
|
||||
|
||||
// 404 + we sent a session id → server forgot us. Drop id, re-init, retry once.
|
||||
if (res.status === 404 && sessionAtStart && allowReinitOn404) {
|
||||
try {
|
||||
await res.arrayBuffer();
|
||||
} catch {}
|
||||
this.sessionId = null;
|
||||
await this.reinitialize();
|
||||
return this.request(method, params, { timeoutMs, allowReinitOn404: false });
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
detail = (await res.text()).slice(0, 200);
|
||||
} catch {}
|
||||
throw new Error(`mempalace remote: HTTP ${res.status} on ${method}${detail ? `: ${detail}` : ""}`);
|
||||
}
|
||||
|
||||
const ct = (res.headers.get("content-type") ?? "").toLowerCase();
|
||||
if (ct.includes("text/event-stream")) {
|
||||
return await this.readSseForResponse(res, id);
|
||||
}
|
||||
if (ct.includes("application/json")) {
|
||||
const json: any = await res.json();
|
||||
if (json.error) throw new Error(json.error.message ?? "MCP error");
|
||||
return json.result;
|
||||
}
|
||||
if (res.status === 202) return undefined;
|
||||
throw new Error(`mempalace remote: unexpected content-type "${ct}" on ${method}`);
|
||||
}
|
||||
|
||||
private async notify(method: string, params: unknown): Promise<void> {
|
||||
const body = JSON.stringify({ jsonrpc: "2.0", method, params });
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(this.url, {
|
||||
method: "POST",
|
||||
headers: this.buildHeaders(),
|
||||
body,
|
||||
signal: this.signalFor(this.requestTimeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(`mempalace remote: fetch failed on notify ${method}: ${(err as Error).message}`);
|
||||
}
|
||||
try {
|
||||
await res.arrayBuffer();
|
||||
} catch {}
|
||||
if (!res.ok && res.status !== 202) {
|
||||
throw new Error(`mempalace remote: HTTP ${res.status} on notify ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async readSseForResponse(res: Response, expectedId: number): Promise<any> {
|
||||
if (!res.body) throw new Error("mempalace remote: SSE response had no body");
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let dataLines: string[] = [];
|
||||
|
||||
const tryDispatch = (): { matched: boolean; result?: any } => {
|
||||
if (dataLines.length === 0) return { matched: false };
|
||||
const data = dataLines.join("\n");
|
||||
dataLines = [];
|
||||
let msg: any;
|
||||
try {
|
||||
msg = JSON.parse(data);
|
||||
} catch {
|
||||
return { matched: false };
|
||||
}
|
||||
if (typeof msg.id === "number" && msg.id === expectedId) {
|
||||
if (msg.error) throw new Error(msg.error.message ?? "MCP error");
|
||||
return { matched: true, result: msg.result };
|
||||
}
|
||||
return { matched: false };
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf("\n")) !== -1) {
|
||||
const rawLine = buf.slice(0, nl);
|
||||
buf = buf.slice(nl + 1);
|
||||
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
||||
if (line === "") {
|
||||
const out = tryDispatch();
|
||||
if (out.matched) return out.result;
|
||||
} else if (line.startsWith(":")) {
|
||||
// SSE comment / keepalive — ignore
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice(5).replace(/^ /, ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
const out = tryDispatch();
|
||||
if (out.matched) return out.result;
|
||||
} finally {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch {}
|
||||
}
|
||||
throw new Error(`mempalace remote: SSE stream closed without response for id=${expectedId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the transport from the environment: MEMPALACE_REMOTE_URL selects the
|
||||
* EXTERNAL (HTTP) client; unset falls back to the LOCAL stdio server.
|
||||
*/
|
||||
function createClient(): IMcpClient {
|
||||
const remoteUrl = process.env.MEMPALACE_REMOTE_URL?.trim();
|
||||
if (remoteUrl) {
|
||||
return new RemoteMcpClient(remoteUrl, authHeaders());
|
||||
}
|
||||
return new StdioMcpClient("mempalace-mcp");
|
||||
}
|
||||
|
||||
/** Bearer auth header for the remote transport, if MEMPALACE_REMOTE_TOKEN is set. */
|
||||
function authHeaders(): Record<string, string> | undefined {
|
||||
const token = process.env.MEMPALACE_REMOTE_TOKEN?.trim();
|
||||
return token ? { Authorization: `Bearer ${token}` } : undefined;
|
||||
}
|
||||
|
||||
export default async function mempalaceExtension(pi: ExtensionAPI) {
|
||||
const client = createClient();
|
||||
let available = false;
|
||||
const agentName = process.env.MEMPALACE_AGENT_NAME ?? "pi";
|
||||
|
||||
// Gate: inject wake-up context only on the first before_agent_start of a
|
||||
// fresh session. Set true on resume/fork (context already in thread).
|
||||
let wokeUp = false;
|
||||
|
||||
try {
|
||||
client.onExit = () => {
|
||||
// Child died (stall-kill or crash): mark unavailable. The next tool
|
||||
// call will attempt a bounded respawn via client.ensureAlive().
|
||||
available = false;
|
||||
};
|
||||
await client.start();
|
||||
available = true;
|
||||
} catch (err) {
|
||||
// First cold-open stalled/crashed. Give the bounded self-heal a chance
|
||||
// before giving up entirely — a respawn often succeeds against a now-warm
|
||||
// page cache. Only fail-soft if even that is exhausted.
|
||||
process.stderr.write(
|
||||
`[mempalace ext] mempalace-mcp start failed: ${(err as Error).message} — attempting respawn\n`,
|
||||
);
|
||||
available = await client.ensureAlive();
|
||||
if (!available) {
|
||||
process.stderr.write(
|
||||
"[mempalace ext] mempalace-mcp unavailable after retries; continuing without palace tools\n",
|
||||
);
|
||||
return; // fail-soft: pi keeps working without palace tools
|
||||
}
|
||||
}
|
||||
|
||||
// Register MCP tools as pi tools. Pass the MCP `inputSchema` through as
|
||||
// the pi `parameters` schema so the LLM sees the real parameter names
|
||||
// (e.g. `agent_name`, not guessed `agent`). TypeBox schemas are plain
|
||||
// JSON Schema at runtime, so `Type.Unsafe` is sufficient to wrap an
|
||||
// externally-sourced JSON Schema — no conversion needed.
|
||||
for (const tool of client.tools) {
|
||||
const schema =
|
||||
tool.inputSchema && typeof tool.inputSchema === "object"
|
||||
? (Type.Unsafe<Record<string, unknown>>(tool.inputSchema as object) as unknown as ReturnType<typeof Type.Object>)
|
||||
: Type.Object({}, { additionalProperties: true });
|
||||
pi.registerTool({
|
||||
name: tool.name,
|
||||
label: tool.name,
|
||||
description: tool.description ?? `MemPalace tool: ${tool.name}`,
|
||||
parameters: schema,
|
||||
async execute(_toolCallId, params) {
|
||||
if (!available) {
|
||||
// Stall-kill/crash is not a permanent latch: try a bounded respawn.
|
||||
available = await client.ensureAlive();
|
||||
}
|
||||
if (!available) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "mempalace-mcp not available (respawn budget exhausted) — restart pi to retry palace tools",
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const result = await client.callTool(tool.name, (params ?? {}) as Record<string, unknown>);
|
||||
// MCP tool results use { content: [...], isError?: boolean }
|
||||
return {
|
||||
content: result?.content ?? [{ type: "text", text: JSON.stringify(result) }],
|
||||
details: { raw: result },
|
||||
isError: result?.isError === true,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: "text", text: `MCP call failed: ${(err as Error).message}` }],
|
||||
details: {},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Automatic transcript feeding ---
|
||||
//
|
||||
// Split deliberately: `mempalace-pi-session --prepare` does the palace-free
|
||||
// file work (export + quality threshold + staging, plus the rsync to the
|
||||
// palace host in remote mode) and prints the path to mine; we then mine it
|
||||
// through this client. See the header note on single-writer contention.
|
||||
const feedEnabled = (process.env.MEMPALACE_FEED ?? "1") !== "0";
|
||||
const feedBin = process.env.MEMPALACE_FEED_BIN || "mempalace-pi-session";
|
||||
const feedWing = process.env.MEMPALACE_FEED_WING ?? "wing_conversations";
|
||||
const feedDebounceMs = num(process.env.MEMPALACE_FEED_DEBOUNCE_MS, 600_000);
|
||||
const feedPrepareTimeoutMs = num(process.env.MEMPALACE_FEED_PREPARE_TIMEOUT_MS, 120_000);
|
||||
const feedMineTimeoutMs = num(process.env.MEMPALACE_FEED_MINE_TIMEOUT_MS, 30_000);
|
||||
let lastFeedAt = 0; // 0 => the first settled turn also acts as a catch-up
|
||||
let feedInFlight: Promise<void> | null = null;
|
||||
|
||||
/** Run `mempalace-feed --prepare`; resolve the path to mine, or null. */
|
||||
function prepareFeed(reason: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
// A missing helper surfaces as an async 'error' event (ENOENT), not a
|
||||
// throw, so the handler below is the fail-soft path.
|
||||
const child = spawn(feedBin, ["--prepare", "--reason", reason, "--wing", feedWing], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let out = "";
|
||||
let settled = false;
|
||||
const finish = (value: string | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
finish(null);
|
||||
}, feedPrepareTimeoutMs);
|
||||
child.stdout.on("data", (chunk) => {
|
||||
out += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", () => {
|
||||
/* the script keeps its own log */
|
||||
});
|
||||
child.on("error", () => finish(null));
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0) return finish(null);
|
||||
const match = out.match(/^MINE_SOURCE=(.+)$/m);
|
||||
finish(match ? match[1].trim() : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage + mine this container's transcripts. Never throws, and coalesces:
|
||||
* an overlapping trigger joins the in-flight run instead of racing it.
|
||||
*/
|
||||
function feedPalace(reason: string): Promise<void> {
|
||||
if (!feedEnabled || !available) return Promise.resolve();
|
||||
if (feedInFlight) return feedInFlight;
|
||||
const run = (async () => {
|
||||
try {
|
||||
const source = await prepareFeed(reason);
|
||||
if (!source) return;
|
||||
await Promise.race([
|
||||
client.callTool("mempalace_mine", {
|
||||
source,
|
||||
mode: "convos",
|
||||
wing: feedWing,
|
||||
agent: agentName,
|
||||
}),
|
||||
new Promise((_resolve, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`mine timed out after ${feedMineTimeoutMs}ms`)),
|
||||
feedMineTimeoutMs,
|
||||
),
|
||||
),
|
||||
]);
|
||||
lastFeedAt = Date.now();
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[mempalace ext] feed (${reason}) failed: ${(err as Error).message}\n`,
|
||||
);
|
||||
}
|
||||
})();
|
||||
feedInFlight = run.finally(() => {
|
||||
feedInFlight = null;
|
||||
});
|
||||
return feedInFlight;
|
||||
}
|
||||
|
||||
// Mid-session feed. A hard container kill runs no handler at all, so this is
|
||||
// what bounds crash loss to one debounce window instead of a whole session.
|
||||
// Re-mining a grown transcript purges and refiles that source_file, so
|
||||
// repeated ticks refresh a session's drawers rather than duplicating them.
|
||||
pi.on("agent_settled", async () => {
|
||||
if (Date.now() - lastFeedAt < feedDebounceMs) return;
|
||||
void feedPalace("tick"); // deliberately not awaited: never stall a turn
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
// Feed before stopping the client: we are the palace holder, so nothing
|
||||
// else can mine while we live. pi awaits this handler, so the mine really
|
||||
// does complete; feedMineTimeoutMs keeps a wedged palace from hanging exit.
|
||||
await feedPalace("shutdown");
|
||||
client.stop();
|
||||
});
|
||||
|
||||
pi.on("session_start", async (event, ctx) => {
|
||||
// On resume/fork, the previous session's palace context is already in
|
||||
// the thread — skip the wake-up injection.
|
||||
if (event.reason === "resume" || event.reason === "fork") {
|
||||
wokeUp = true;
|
||||
}
|
||||
ctx.ui.notify(
|
||||
`mempalace bridge: ${client.tools.length} tools registered (agent=${agentName})`,
|
||||
"info",
|
||||
);
|
||||
});
|
||||
|
||||
// --- Auto wake-up (mempalace skill Phase 1) ---
|
||||
pi.on("before_agent_start", async (_event, _ctx) => {
|
||||
if (wokeUp || !available) return;
|
||||
wokeUp = true; // one-shot, even if the calls below fail
|
||||
|
||||
const sections: string[] = [];
|
||||
try {
|
||||
const status = await client.callTool("mempalace_status", {});
|
||||
const text = extractText(status);
|
||||
if (text) sections.push(`## mempalace_status\n\n${text}`);
|
||||
} catch (err) {
|
||||
sections.push(`## mempalace_status\n\n(error: ${(err as Error).message})`);
|
||||
}
|
||||
try {
|
||||
const diary = await client.callTool("mempalace_diary_read", {
|
||||
agent_name: agentName,
|
||||
last_n: 5,
|
||||
});
|
||||
const text = extractText(diary);
|
||||
if (text) sections.push(`## mempalace_diary_read (agent=${agentName}, last_n=5)\n\n${text}`);
|
||||
} catch (err) {
|
||||
sections.push(`## mempalace_diary_read\n\n(error: ${(err as Error).message})`);
|
||||
}
|
||||
|
||||
if (sections.length === 0) return;
|
||||
|
||||
const body =
|
||||
`MemPalace wake-up context (auto-injected by the mempalace extension). ` +
|
||||
`This is your palace orientation for this session — do not announce it to the user, ` +
|
||||
`just use it to inform your answers. Agent identity for diary tools: "${agentName}".\n\n` +
|
||||
sections.join("\n\n---\n\n");
|
||||
|
||||
return {
|
||||
message: {
|
||||
customType: "mempalace-wakeup",
|
||||
content: body,
|
||||
display: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// --- Manual wind-down (mempalace skill Phase 3) ---
|
||||
pi.registerCommand("mempalace-diary", {
|
||||
description: "Ask the LLM to write an AAAK diary entry summarizing this session",
|
||||
handler: async (args, ctx) => {
|
||||
if (!available) {
|
||||
ctx.ui.notify("mempalace bridge not available", "warning");
|
||||
return;
|
||||
}
|
||||
const topic = args.trim() || "session-summary";
|
||||
const prompt =
|
||||
`Write a MemPalace diary entry for this session using the AAAK format ` +
|
||||
`described in the mempalace skill. Call mempalace_diary_write with ` +
|
||||
`agent_name="${agentName}", topic="${topic}", and a compressed AAAK entry ` +
|
||||
`that summarizes what we worked on, what was discovered, and any open ` +
|
||||
`threads. Then confirm the write succeeded. Do not ask me for ` +
|
||||
`clarification — draft from the session so far.`;
|
||||
pi.sendUserMessage(prompt);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Flatten MCP tool result content into plain text for context injection. */
|
||||
function extractText(mcpResult: any): string {
|
||||
const parts = mcpResult?.content;
|
||||
if (!Array.isArray(parts)) return typeof mcpResult === "string" ? mcpResult : "";
|
||||
return parts
|
||||
.map((p: any) => (typeof p?.text === "string" ? p.text : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
+103
@@ -16,6 +16,13 @@ SKILL_SRC="${SCRIPT_DIR}/SKILL.md"
|
||||
SKILL_DEST_DIR="${HOME}/.agents/skills/opencode-mempalace-bridge"
|
||||
SKILL_DEST="${SKILL_DEST_DIR}/SKILL.md"
|
||||
|
||||
# pi coding-agent MCP bridge extension (optional — only linked if pi is installed)
|
||||
# Pi-generic config (env loader, keybindings, settings template) lives in
|
||||
# the pi-toolkit repo; install it separately for the base pi bring-up.
|
||||
PI_EXT_SRC="${SCRIPT_DIR}/extensions/pi/mempalace.ts"
|
||||
PI_EXT_DEST_DIR="${HOME}/.pi/agent/extensions"
|
||||
PI_EXT_DEST="${PI_EXT_DEST_DIR}/mempalace.ts"
|
||||
|
||||
# ── args ─────────────────────────────────────────────
|
||||
ACTION="install"
|
||||
ASSUME_YES="no"
|
||||
@@ -38,13 +45,23 @@ What install does:
|
||||
- Symlinks SKILL.md into ~/.agents/skills/opencode-mempalace-bridge/SKILL.md
|
||||
(auto-discovered by opencode; run agents-sync from cli_utils to also
|
||||
reach Claude Code and Kiro)
|
||||
- If pi (~/.pi/agent/extensions/) exists, symlinks extensions/pi/mempalace.ts
|
||||
into ~/.pi/agent/extensions/mempalace.ts (the pi↔mempalace MCP bridge).
|
||||
Skipped on machines without pi.
|
||||
- Warns if pi is installed but pi-toolkit doesn't appear to be (i.e. the
|
||||
keybindings, env loader, and settings template are missing). pi-toolkit
|
||||
is a separate repo owning pi's own config: split out 2026-05-05.
|
||||
Clone: ssh://git@gitea.jordbo.se:2222/joakimp/pi-toolkit.git
|
||||
- Drops a .skill-source marker in the skill dir so sibling tooling
|
||||
(deploy-skills.sh, agents-sync.zsh) knows the dir is externally owned
|
||||
|
||||
What uninstall does:
|
||||
- Removes symlinks in ~/.local/bin/ that point into this repo
|
||||
- Removes the skill symlink if it points into this repo
|
||||
- Removes the pi↔mempalace MCP bridge symlink if it points into this repo
|
||||
- Removes the .skill-source marker and empty skill dir
|
||||
- Does NOT touch pi-toolkit-owned artifacts (keybindings, env loader).
|
||||
Run pi-toolkit/install.sh --uninstall for those.
|
||||
EOF
|
||||
exit 0 ;;
|
||||
*) echo "Unknown flag: $1" >&2; exit 2 ;;
|
||||
@@ -219,6 +236,71 @@ check_opencode_mcp() {
|
||||
return 0
|
||||
}
|
||||
|
||||
install_pi_extension() {
|
||||
# The pi coding-agent extension is optional: link it only if pi is
|
||||
# already installed on this machine (its ~/.pi/agent/extensions/
|
||||
# directory exists). Otherwise silently skip — mempalace-toolkit is
|
||||
# useful on opencode-only boxes too.
|
||||
if [[ ! -d "$PI_EXT_DEST_DIR" ]]; then
|
||||
note "pi not detected at $PI_EXT_DEST_DIR — skipping pi extension"
|
||||
printf ' (install pi first if you want the pi↔mempalace bridge:\n'
|
||||
printf ' https://github.com/earendil-works/pi)\n'
|
||||
return 0
|
||||
fi
|
||||
|
||||
note "Linking pi extension into $PI_EXT_DEST_DIR"
|
||||
if [[ -e "$PI_EXT_DEST" || -L "$PI_EXT_DEST" ]]; then
|
||||
if link_if_into_repo "$PI_EXT_DEST"; then
|
||||
ok "pi extension already linked"
|
||||
return 0
|
||||
fi
|
||||
# Non-symlink (or foreign symlink) in the way — back it up rather
|
||||
# than clobber. User may have local edits they want to preserve.
|
||||
local backup="${PI_EXT_DEST}.bak.$(date +%Y%m%d-%H%M%S)"
|
||||
mv "$PI_EXT_DEST" "$backup"
|
||||
warn "Existing $PI_EXT_DEST backed up to $backup"
|
||||
fi
|
||||
ln -s "$PI_EXT_SRC" "$PI_EXT_DEST"
|
||||
ok "Linked mempalace.ts → $PI_EXT_SRC"
|
||||
printf ' Restart pi to load the extension (it reads ~/.pi/agent/extensions/\n'
|
||||
printf ' at startup only).\n'
|
||||
}
|
||||
|
||||
|
||||
# ── Pi-toolkit detection ─────────────────────────────────────────
|
||||
# Pi-generic config (keybindings, settings template, shell env loader) lives
|
||||
# in the sibling pi-toolkit repo. This file used to own those; the split
|
||||
# happened 2026-05-05 so opencode-devbox can build slim containers that
|
||||
# include pi without dragging in mempalace. We don't install those artifacts
|
||||
# here — we only print a pointer when pi is detected but pi-toolkit isn't.
|
||||
check_pi_toolkit() {
|
||||
[[ -d "$PI_EXT_DEST_DIR" ]] || return 0 # no pi → nothing to say
|
||||
|
||||
# Best-effort detection: pi-toolkit installs keybindings.json as a
|
||||
# symlink and copies pi-env.zsh into ~/.oh-my-zsh/custom/. We don't
|
||||
# know where the pi-toolkit repo is cloned, so just check whether the
|
||||
# downstream artifacts exist.
|
||||
local has_keys="no"
|
||||
local has_env="no"
|
||||
[[ -L "$HOME/.pi/agent/keybindings.json" ]] && has_keys="yes"
|
||||
[[ -f "$HOME/.oh-my-zsh/custom/pi-env.zsh" ]] && has_env="yes"
|
||||
|
||||
if [[ "$has_keys" == "yes" || "$has_env" == "yes" ]]; then
|
||||
ok "pi-toolkit artifacts detected (keybindings=$has_keys env-loader=$has_env)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
warn "pi detected but pi-toolkit doesn't appear installed"
|
||||
printf ' pi-toolkit ships pi'"'"'s own config (keybindings, env loader,\n'
|
||||
printf ' settings template) — formerly part of this repo, split out\n'
|
||||
printf ' 2026-05-05. Install it separately for the full pi bring-up:\n'
|
||||
printf ' git clone ssh://git@gitea.jordbo.se:2222/joakimp/pi-toolkit.git\n'
|
||||
printf ' cd pi-toolkit && ./install.sh\n'
|
||||
printf ' This toolkit'"'"'s install still does the pi↔mempalace MCP bridge\n'
|
||||
printf ' (mempalace.ts extension) regardless — that part is mempalace-side.\n'
|
||||
return 0
|
||||
}
|
||||
|
||||
do_install() {
|
||||
echo
|
||||
echo "mempalace-toolkit installer"
|
||||
@@ -227,6 +309,10 @@ do_install() {
|
||||
echo "==> Installation plan:"
|
||||
echo " Symlink executables in bin/ into $BIN_DEST"
|
||||
echo " Symlink SKILL.md into $SKILL_DEST"
|
||||
if [[ -d "$PI_EXT_DEST_DIR" ]]; then
|
||||
echo " Symlink extensions/pi/mempalace.ts into $PI_EXT_DEST"
|
||||
echo " (install pi-toolkit separately for keybindings + env loader + settings template)"
|
||||
fi
|
||||
echo
|
||||
confirm || { echo "Aborted."; exit 0; }
|
||||
echo
|
||||
@@ -234,12 +320,16 @@ do_install() {
|
||||
echo
|
||||
install_skill
|
||||
echo
|
||||
install_pi_extension
|
||||
echo
|
||||
check_path
|
||||
echo
|
||||
check_wake_up_protocol
|
||||
echo
|
||||
check_opencode_mcp
|
||||
echo
|
||||
check_pi_toolkit
|
||||
echo
|
||||
ok "Done."
|
||||
echo
|
||||
echo "Next: ./bin/mempalace-session --dry-run"
|
||||
@@ -278,6 +368,19 @@ do_uninstall() {
|
||||
ok "No skill symlink to remove"
|
||||
fi
|
||||
|
||||
echo
|
||||
note "Removing pi extension symlink"
|
||||
if link_if_into_repo "$PI_EXT_DEST"; then
|
||||
rm "$PI_EXT_DEST"
|
||||
ok "Removed pi extension symlink"
|
||||
else
|
||||
ok "No pi extension symlink to remove"
|
||||
fi
|
||||
|
||||
# Note: pi keybindings + pi-env.zsh loader are owned by pi-toolkit now
|
||||
# (split 2026-05-05). Run `pi-toolkit/install.sh --uninstall` to remove
|
||||
# those artifacts. We deliberately do not touch them here.
|
||||
|
||||
# Remove the marker and the now-empty skill directory, but only if
|
||||
# the marker was written by us and the directory has nothing else in it.
|
||||
local marker="$SKILL_DEST_DIR/.skill-source"
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-mcp-client-sync.sh — drift guard for the vendored streamable-HTTP MCP client.
|
||||
#
|
||||
# The RemoteMcpClient in extensions/pi/mempalace.ts is a VENDORED copy of the
|
||||
# canonical implementation in pi-extensions/extensions/mcp-loader.ts (the two
|
||||
# repos ship independently, so a shared import isn't practical for one small,
|
||||
# protocol-frozen class — see the annotated header in mempalace.ts).
|
||||
#
|
||||
# Both blocks carry a matching sync token:
|
||||
# MCP-STREAMABLE-HTTP-CLIENT-SYNC: vN
|
||||
# Bump it in BOTH files whenever the streamable-HTTP protocol handling changes.
|
||||
# This guard fails when the tokens diverge, so a protocol fix in one file can't
|
||||
# silently skip the other.
|
||||
#
|
||||
# It is a LOCAL developer guard: it needs the sibling pi-extensions checkout to
|
||||
# compare against. If that isn't found it SKIPS (exit 0) rather than failing —
|
||||
# so it's safe to wire into CI that only checks out this repo.
|
||||
#
|
||||
# Point it at a specific checkout with: PI_EXTENSIONS_DIR=/path/to/pi-extensions
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VENDORED="$REPO_ROOT/extensions/pi/mempalace.ts"
|
||||
|
||||
TOKEN_RE='MCP-STREAMABLE-HTTP-CLIENT-SYNC: v[0-9]+'
|
||||
extract_token() { grep -oE "$TOKEN_RE" "$1" 2>/dev/null | head -1 | grep -oE 'v[0-9]+' || true; }
|
||||
|
||||
if [[ ! -f "$VENDORED" ]]; then
|
||||
echo "ERROR: vendored file not found: $VENDORED" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VENDORED_TOKEN="$(extract_token "$VENDORED")"
|
||||
if [[ -z "$VENDORED_TOKEN" ]]; then
|
||||
echo "FAIL: no '$TOKEN_RE' token in $VENDORED" >&2
|
||||
echo " The vendored RemoteMcpClient must carry a sync token." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Locate the canonical mcp-loader.ts across common dev layouts.
|
||||
CANDIDATES=(
|
||||
"${PI_EXTENSIONS_DIR:-}/extensions/mcp-loader.ts"
|
||||
"$REPO_ROOT/../pi-extensions/extensions/mcp-loader.ts"
|
||||
"/workspace/pi-extensions/extensions/mcp-loader.ts"
|
||||
"$HOME/src/pi-extensions/extensions/mcp-loader.ts"
|
||||
"$HOME/src/src_local/pi-extensions/extensions/mcp-loader.ts"
|
||||
"/opt/pi-extensions/extensions/mcp-loader.ts"
|
||||
)
|
||||
CANONICAL=""
|
||||
for c in "${CANDIDATES[@]}"; do
|
||||
[[ -n "$c" && -f "$c" ]] && { CANONICAL="$c"; break; }
|
||||
done
|
||||
|
||||
if [[ -z "$CANONICAL" ]]; then
|
||||
echo "SKIP: canonical pi-extensions/extensions/mcp-loader.ts not found — cannot compare."
|
||||
echo " (vendored token: $VENDORED_TOKEN). Set PI_EXTENSIONS_DIR to enable the check."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CANONICAL_TOKEN="$(extract_token "$CANONICAL")"
|
||||
if [[ -z "$CANONICAL_TOKEN" ]]; then
|
||||
echo "FAIL: no '$TOKEN_RE' token in canonical $CANONICAL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$VENDORED_TOKEN" != "$CANONICAL_TOKEN" ]]; then
|
||||
echo "FAIL: MCP client sync token drift." >&2
|
||||
echo " vendored ($VENDORED): $VENDORED_TOKEN" >&2
|
||||
echo " canonical ($CANONICAL): $CANONICAL_TOKEN" >&2
|
||||
echo " A streamable-HTTP protocol change landed in one file but not the other." >&2
|
||||
echo " Reconcile RemoteMcpClient in both, then bump the token in BOTH to match." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: MCP client sync token matches ($VENDORED_TOKEN)."
|
||||
echo " vendored: $VENDORED"
|
||||
echo " canonical: $CANONICAL"
|
||||
Reference in New Issue
Block a user