Compare commits

...

37 Commits

Author SHA1 Message Date
Joakim Persson 947604b25d docs: backup and recovery, plus units; move host runbook to a private repo
Adds bin/mempalace-backup and docs/backup-and-recovery.md — the mechanism a
palace actually needs, none of it site-specific.

Why a palace cannot be backed up with cp: it is chroma.sqlite3 (authoritative),
knowledge_graph.sqlite3 (usually WAL, so -wal/-shm make a plain copy a
same-instant gamble), derived HNSW segment dirs, hallways.json, the embedder
descriptor, and a HIDDEN .mempalace/origin.json. Both SQLite files are therefore
copied through the online-backup API. Two bugs are documented because both
produce a backup that looks fine: "$PALACE"/*/ silently skips the hidden dir, and
per-directory rsync collides the identically named data_level0.bin in every HNSW
segment. Treating the palace as one tree fixes both and makes a backup a faithful
palace IMAGE, so restore is a copy rather than a procedure.

Two modes: hot (default, zero downtime, ~4 s, index may lag but SQLite is
authoritative and repair --mode from-sqlite rebuilds) and cold (--cold, ~5 s
downtime, byte-consistent, restart trapped so a failed run still brings the
server back). Verification runs on the COPY — quick_check plus row counts — and
the backup is committed by mv only after it passes, with retention pruned only
after a verified commit, so a broken new backup cannot delete the last good one.

Documented because they are easy to get wrong: the sqlite3 CLI is often absent
where the Python module is present; mempalace_embedder.json must be restored with
the drawers or search silently degrades; a tested restore means running status
AND search against the restored copy, since search is what actually exercises the
index; mempalace-serve is a USER unit, so root systemctl reports "not found";
Persistent=true is what makes a missed window run after boot; and installing
against the system Python couples the palace's availability to distribution
upgrades, with the uv-managed-interpreter fix plus the two PATH traps that bite
scripted upgrades.

Also moves docs/synlig-primary-runbook.md out to a private fleet repository,
leaving a stub that explains the split, since a host inventory is operator data
for one deployment rather than part of a public toolkit. The path stays valid so
existing links do not break. Remaining host references in the README, RFCs and
ARCHITECTURE are left alone deliberately: they are load-bearing prose, contain no
secrets, and are best generalised as they are next edited rather than in one
churn-heavy pass.
2026-08-17 00:50:16 +02:00
Joakim Persson b609cf5a69 docs(synlig): the transcript inbox is the primary's third moving part; and survive an unset HOME
Runbook gaps found while fixing the 2026-08-15 feed failure:

- §2.5 still titled "written but not installed" and still asserting "Not
  installed, not enabled" — false since 2026-08-12. A reader landing there got
  a flat contradiction of §4 item 3. Retitled, with the verified-2026-08-16
  process line, and it now states the fact §2.6 depends on: the server is a
  NATIVE process (no mempalace container on synlig), so it sees host paths.
- New §2.6 documents ~/mempalace-feed/<device>/: why transcripts cannot travel
  over the HTTPS leg at all, the three client variables, and why
  MEMPALACE_PI_REMOTE_PATH is the trap (its /data/feed default assumes a
  containerized server; here it must equal the ssh-target path, and a mismatch
  fails with rsync succeeding and only the mine failing). Plus the operational
  notes that cost time: dedup keys on the absolute path so the inbox path is
  load-bearing, grown sessions are purged+refiled by mtime, and a client-side
  MCP timeout is NOT a failed mine.
- Header status: counts refreshed with an explicit "treat counts as timestamps".

Also, bin/mempalace-pi-session: default HOME from the passwd database when it is
unset. `docker run --entrypoint="" <image>` inherits no HOME when the image
config declares none, and every default is HOME-anchored under `set -u`, so the
script — including the palace-free --self-test — died with "HOME: unbound
variable" in exactly the environment pi-devbox's smoke suite uses. pi-devbox
v1.8.0 lost a release to the same assumption from the other side.
2026-08-16 00:52:27 +02:00
Joakim Persson 6e1f4f30fc fix(pi-session): a failed remote mine reported success — MCP escapes the payload
The remote-mine leg decided success with `'"error"' in body`. MCP answers a
hard tool failure with HTTP 200 and a JSON-RPC *result* whose content[].text
carries the tool's own JSON as an ESCAPED string, so those bytes are
\"error\" and the substring never matches. On 2026-08-15 (EMB-7KJ4VR4G, first
boot of the fresh pi-devbox image) a mine that failed with

  {"success": false, "error": "source directory not found: '/data/feed/emb-7kj4vr4g'"}

printed "Done. Wing 'wing_conversations' updated." directly under that error
and exited 0. Transcripts had been rsynced for the whole session and filed
nowhere; the only artifact anyone would check said it worked.

- classify(): parse the envelope instead of grepping it. Catches JSON-RPC
  errors, MCP isError, and inner success=false/error, and distinguishes
  "verified ok" from "unverified: no JSON tool payload" rather than assuming.
- --self-test: six recorded MCP responses (fixture 1 is the real 2026-08-15
  body) plus a regression guard asserting the old substring check is blind to
  it. Needs no palace, no network, no sessions dir.
- Preflight warning when the rsync destination path and
  MEMPALACE_PI_REMOTE_PATH disagree. The /data/feed default assumes a
  CONTAINERIZED palace server; a native one (systemd unit / uv tool) sees host
  paths, and then the two must match. Warned in preflight so --dry-run and
  --prepare surface it too.
- Remote mode no longer previews NEW/SKIP from the LOCAL palace: dedup happens
  on the palace host keyed on the remote inbox path, so this machine cannot
  answer it. Tags become [?] and the summary says who decides. It had been
  reporting "6 already filed" about a palace it was not feeding.
2026-08-16 00:28:50 +02:00
Joakim Persson f60cf9c732 feat(census): RFC 002 Phase A — read-only join census, and three RFC corrections it found
bin/mempalace-census classifies a palace on disk into the RFC 002 §2 classes
(mined / diary / agent-authored) and emits a human report or a --json manifest
that feeds Phases B/C. Read-only: every sqlite handle is opened mode=ro, no
-wal/-shm is created, safe against a live mempalace-serve. Reads LOCAL DISK only
and warns if MEMPALACE_REMOTE_URL is set, so a local census can't be mistaken
for a remote one.

The design point is that it SELF-VERIFIES instead of trusting ids.py's
docstrings: for every replayable drawer it reassembles content from chunks,
recomputes the upstream id and compares to the stored id. That single check
covers the id recipe, the chunk reassembly order and the classifier at once --
176/176 accounted for on the reference palace -- and it falsified three things
the RFC previously asserted from a docs-only reading (now RFC 002 §2.1):

  (a) The hash input is LENGTH-PREFIXED, not "|"-joined. ids.py:31 defines
      _DELIM = "|" and the make_* docstrings describe f"{wing}|{room}|{content}",
      but _DELIM is dead code and _delimited_sha256 builds
      "".join(f"{len(part)}:{part}"). Measured: length-prefixed reproduces real
      ids 5/5, pipe-joined 0/5. Diary ids differ again -- a PLAIN sha256.

  (b) id_recipe is NOT a mined-only marker. It looked like a clean
      discriminator (same 14,586 count as source_file) but the server stamps
      'v3' on content ids too, so classifying on `source_file OR id_recipe`
      swallowed all 60 agent-authored drawers into MINED -- the dangerous
      direction, since Phase C would try to re-mine drawers that have no source
      file and silently drop them. Discriminator is a TRUTHY source_file (the
      writer stores "" rather than omitting the key), cross-checked against the
      miner-only keys source_mtime / normalize_version; disagreement is now a
      first-class warning.

  (c) Content ids DRIFT: 9 of 60 agent-authored drawers no longer reproduce
      their own id, because update_drawer preserves the id while rewriting and
      re-chunking. So "recompute the content id and skip if present" -- the
      strategy this RFC specified for regime A -- misses every drifted drawer
      and duplicates it. Phase C must key on the STORED id. Flagged as
      edited_since_filing in the manifest so Phase C can be tested on them.

Also corrects §4.1's headline number: 15,949 mined / 98.9% was reconstructible
exactly as 16,338 (all embeddings rows) - 192 (diary rows) - 197 (agent rows),
i.e. it counted rows rather than parent drawers AND spanned both collections,
absorbing all 1,560 non-joinable mempalace_closets rows into the mined total.
Correct figures: 14,389 mined / 116 diary / 60 agent-authored = 14,565 parents,
replay surface 176 (1.2%). Two rules now enforced in the tool: always filter by
collection (one sqlite file holds both), and always say whether a count is rows
or parent drawers -- a chunked drawer contributes N rows and no parent row.

One implementation trap worth recording: Chroma splits metadata across
string_value and int_value, so reading only string_value nulls every numeric key
(source_mtime, chunk_index, line_start) -- which made the miner-marker
cross-check report 100% conflict until the loader coalesced the two columns.
2026-08-15 09:06:40 +02:00
Joakim Persson 2f9170428c docs(rfc-002): run the census for real — the replay surface is 176 records, and closets were missing
Ran Phase A's classifier against the frozen EMB-7KJ4VR4G archive. Since the
fleet shares one devbox image this is a reasonable prior for tor-ms22 and
MBP-M1-2020:

  mined (source_file set)   15949   98.9%   re-mine, never replay
  diary entries               116    0.7%   replay + §7.6 suffix skip
  agent-authored drawers       60    0.4%   replay, idempotent
  KG open facts                34      --   server guard dedupes
  KG closed facts               0      --   nothing to do

Two consequences that shrink this project: the replay-only surface is 176
records, not thousands, so the writer is a small job and the §7.6 diary guard
treated as the blocker governs 116 records; and there are ZERO closed KG facts,
so the unguarded-closed-fact gap is real in the code but empty in the data.

filed_at spread in the same archive -- 12 in May, 52 in June, 15174 in July, 887
in August -- is the concrete argument for the history-preserving regime.

Two corrections to the first draft:

1. CLOSETS were omitted entirely. mempalace_closets is a second Chroma
   collection (1560 rows, ~10% of the palace) and there is NO MCP tool that
   writes one -- mcp_server.py exposes only _purge_source_closets, and
   closet_llm.py says outright that regex closets are always created by the
   miner. So closets cannot be replayed even in principle; they return only by
   re-mining. Same category as hallways/known_entities/palace-graph, and it
   resolves itself for the ~99% that gets re-mined anyway.

2. Census gotcha: chroma.sqlite3 holds BOTH collections, so an embeddings-wide
   query over-counts by ~10%. Must join segments->collections and keep
   mempalace_drawers. Doing so reconciles exactly: 14,778 = the 14,777 seeded to
   the primary + the one known post-snapshot chunk.

Also drops the earlier "438 of 14,829" figure, which conflated central's
no_source count (which includes its own later agent writes) with the archive's
actual replay surface.
2026-08-15 00:29:58 +02:00
Joakim Persson a25e22922b docs: scope the joiner (RFC 002) — and the chronology loss nobody had costed
RFC 001 §4.4 designs a join as "idempotent replay of local history", but no
replay tool exists and the two joins done so far were whole-palace file copies
that cannot merge. tor-ms22 and MBP-M1-2020 each hold a palace that needs to
reach the primary, so this scopes the tool.

The finding that drives the design: MCP replay CANNOT preserve filed_at.
add_drawer stamps it server-side (mcp_server.py:2580) with no override, and
diary_write builds its own now()-based id. A pure MCP replay would therefore
collapse months of history into the join instant -- on a palace whose value IS
its chronology, and where list_drawers filters on filed_at. RFC 001 does not
mention this. kg_add is the exception: it takes valid_from/valid_to, so fact
windows survive.

Hence two regimes, and a recommendation to build the history-preserving one
first: direct disk write on synlig (preserves ids + filed_at, bypasses the
server guards, needs the service stopped) vs MCP replay (guards work, timestamps
flatten). migrate.py is already a working model for the direct path -- it reads
drawers straight from the palace sqlite and re-adds them preserving ids,
documents and metadata.

Also concretised: every dedup key verified against mempalace 3.6.0 source rather
than assumed --
  - agent-authored drawers: sha256(wing|room|content)[:24], fully deterministic
  - mined drawers: sha256(source_file|chunk_index)[:24], PATH-dependent, so
    replay duplicates instead of deduping -> re-mine on synlig, do not replay
  - diaries: full ids never repeat (wall-clock component); match the
    sha256(entry)[:12] suffix only -- this is §7.6
  - KG closed facts: no server guard at all (it is scoped to valid_to IS NULL)
  - hallways/known_entities/palace-graph are built at MINE time, so replayed
    drawers arrive with no co-occurrence edges and traverse under-reports

Scope is phased so the useful half lands first: Phase A is a read-only census
that sizes the job and cannot break anything, and is the piece to run the moment
tor-ms22 is reachable. The replay-only surface is small -- only 438 of 14,829
drawers on the primary have no source_file.

Method note recorded in the doc: an attempt to gather these mechanisms via a
delegated subagent returned confident, fabricated code for a package path that
does not exist on this machine. Everything here is cited to file:line and was
re-read directly.
2026-08-15 00:27:36 +02:00
Joakim Persson c349d007e1 docs(§7.2): measure the shared-palace sync blast radius — it is 0 today, and the feeder is what changes that
Unscoped `mempalace_sync` dry-run against the live primary (14,829 drawers):
out_of_scope 14391, no_source 438, missing 0, kept 0. Zero drawers deletable.

The mechanism is the finding: an entirely-absent source root yields
`out_of_scope`, NOT `missing`, and only missing/gitignored drawers get removed.
So a wipe needs the root to EXIST while files under it do not -- not merely a
host that lacks the repos. §7.2's "from a laptop that lacks the repos, it is a
fleet-wide wipe" therefore overstates today's risk.

It also understates tomorrow's, which is the part worth acting on. Two guards
currently prevent the laptop scenario and neither was designed to: the CLI has
no remote support, so a client's `mempalace sync` cannot reach central at all;
and the MCP tool runs server-side on synlig, where clients' /workspace roots do
not exist. That is safety by coincidence of layout.

The feeder's remote mode dissolves it: it rsyncs staged transcripts into
per-device inboxes ON synlig, so those sources begin existing on the palace host
and become in-scope for the first time. A later stage rotation or inbox cleanup
then marks that device's conversation drawers `missing` -- prunable, and
prunable from a different device. mempalace-pi-session already documents the
single-machine form of this; a shared palace makes it cross-device.

So stage retention on synlig plus the sync guard belong to enabling the feeder
fleet-wide, not to a later cleanup pass.
2026-08-14 23:15:01 +02:00
Joakim Persson 6e8172d93a docs: record the primary's real lineage — EMB-X1JY06WJ -> EMB-7KJ4VR4G -> synlig
Three docs said the primary was "seeded from EMB-7KJ4VR4G's palace", which is
true but stops one hop short. That palace was itself carried over from
EMB-X1JY06WJ, the previous work computer, when it was replaced around
2026-07-06.

Evidence, read read-only out of the frozen archive on EMB-7KJ4VR4G: 76 drawers
carry `source_machine=EMB-X1JY06WJ` and they are the oldest in the store --
earliest filed_at 2026-05-04, two months before that machine's stack existed --
with no other source_machine value present. The remaining ~16k were filed
locally afterwards (15,189 in July, 1,073 in August). Last write is
2026-08-14T15:07:06, the seed instant.

Two consequences, recorded in rfc-001 S4.4:

- It was the *second* whole-palace file copy, not the first, and both were
  single-source clones. So the method has two successes behind it and still
  zero exercises of merge semantics -- S7.6 is *less* tested than "we've done
  this twice" would suggest, not more.
- The primary now holds records from a machine that no longer exists, and
  `source_machine` is the only thing marking them. Not noise; don't prune it.

Also corrects a94eb7f, which inferred the archive's origin from the
`.devbox-owner` marker. That marker only records that the devbox adopted the
directory and says nothing about provenance -- the archive predates the stack.
And native pi on EMB-7KJ4VR4G is "not installed *yet*", a deferred hazard rather
than a closed one: a native install there would resolve its palace to
~/.mempalace, i.e. the frozen archive, and split that machine's memory from
central silently. Flip at install time.
2026-08-14 23:04:35 +02:00
Joakim Persson a94eb7fdd0 docs: native pi has no .env to flip, and EMB-7KJ4VR4G has no native pi
Follow-up to ec436ed, closing the one item that commit left as "unverified".

Verified on the EMB-7KJ4VR4G host: native pi is not installed at all -- no `pi`
or `mempalace` on PATH, no ~/.config/pi/, no ~/.pi/agent/extensions/. That
host's ~/.mempalace exists solely to back the devbox container through the bind
mount (.devbox-owner holds 1000:1000). So the 2026-08-14 flip covers every pi
on that machine and there is no split-brain to fix. The general hazard stays
documented, because it is a per-machine question.

Also documents how a native install *would* be flipped, since the obvious guess
is wrong: pi loads no dotenv file and has no `env` block in settings.json, and
the extension reads process.env only. The launching shell is the sole hook, so
the vars must be exported from a shell rc (a GUI-launched pi may not read one),
and ~/.config/pi/.env is not sourced by anything automatically.
2026-08-14 23:00:46 +02:00
Joakim Persson ec436ed3ad docs: reconcile the RFC-001 docs with what is actually deployed
Audit of every doc touching the global-palace rollout against the running
fleet. Each correction below was verified against the filesystem or the host,
not against another doc:

- synlig-primary-runbook: the decommission `rm -rf ~/.mempalace` now carries a
  STOP block. That tree holds the fleet palace *and* the only copy of the
  bearer token every client authenticates with; the old "empty today" comment
  stopped being true when the palace was seeded on 2026-08-14. Adds an ordered
  safe decommission, and drops count-based join verification.

- phase-1-exposure-runbook: new S3.8, how to verify a flip actually took --
  the procedure that until now existed only in an untracked handover file.
  Three claims that fail independently (env var / curl / the palace-path
  discriminator) plus an explicit list of checks that produce FALSE POSITIVES:
  drawer counts (both sides were seeded from the same palace, and `status`
  counts chunks not drawers), write-then-read through the same transport, and
  the `mempalace` CLI -- which has no remote support at all, so post-flip it
  reads the dead local archive and reports success.

- rfc-001: status Draft -> Phases 0-1 implemented. Records that the join was a
  file-level copy, which SIDESTEPPED the S7.6 diary-dedup question rather than
  answering it -- so S7.6 remains a hard blocker for the second machine, which
  is the one that will actually exercise merge semantics.

- ARCHITECTURE, SKILL, contrib/README, extensions/pi/README all claimed pi
  feeds the palace automatically, unconditionally. That is gated on
  mempalace-toolkit >= 29e660e and every deployed image predates it, so the
  claim is currently false fleet-wide. Each site now states the gate plus a
  check that inspects the *deployed* file rather than repo HEAD.

- extensions/pi/README: plaintext http://mempalace.lan example -> https
  endpoint; the two transports are either/or (no dual-write, no local mirror);
  the bridge fails CLOSED, so "the agent has no mempalace_* tools" is the
  expected symptom of a server/token/DNS fault, not of a broken install.

- contrib/README: documents mempalace-serve.service, which this directory has
  shipped since day one without explaining it (linger, the load-bearing
  172.17.0.1 bind and why loopback is the unsafe-looking-safe option, the
  token path, and an uninstall warning).

- Fixes a pre-existing stray ```sh fence that was swallowing S3.2's heading and
  the token command into a code block.

Docs only; no behaviour change.
2026-08-14 22:57:22 +02:00
Joakim Persson 2293f1c89b docs: the silent-skip fix is base-image-gated, so today's fleet still skips silently
pi-devbox cbd7cf5 makes the remote-palace-without-inbox skip announce itself, but
entrypoint-user.sh is COPY'd in Dockerfile.base, so the fix only reaches a
container after a base rebuild. Every image running today still skips silently --
recording both behaviours with the cut-off, so the table stays true for whichever
image a container is actually on, rather than describing a fix that has not
shipped yet.
2026-08-13 16:31:18 +02:00
Joakim Persson 2e73a9eae8 docs: REMOTE_URL includes /mcp, and the entrypoint skips silently where the feeder exits 1
Two details needed before the first client flip.

1. MEMPALACE_REMOTE_URL is the full endpoint including /mcp with no trailing
   slash. The feeder POSTs to it verbatim (bin/mempalace-pi-session:718), and the
   server matches `path != "/mcp"` exactly, so a base URL or a trailing slash
   both 404 -- and a 404 here looks like a routing/proxy fault, not a config
   typo, which is a bad hour to spend. Matches the existing pi-devbox examples.
   For this fleet: https://mempalace.jordbo.se/mcp

2. The 3.7 trap has TWO symptoms, not one, and I had only documented the loud
   one. Direct runs / session-end / cron exit 1 with a clear error (:298-300).
   But pi-devbox's entrypoint-user.sh:134 checks the same condition and skips
   *quietly* -- and the skip happens before the subshell that writes
   ~/.pi/agent/mempalace-catchup.log, so there is not even an empty log to find.
   A container flipped with REMOTE_URL but no SSH_TARGET therefore contributes
   nothing to the palace and leaves no artifact explaining why. The skip is
   correct in itself (there is genuinely no inbox to ship to) but it is
   indistinguishable from a healthy run with nothing to do. Documented with two
   commands that tell those apart after a flip.
2026-08-13 16:22:42 +02:00
Joakim Persson 4cb70ce3e1 docs: record the 302 auth-redirect fingerprint and the server's exact HTTP surface
Phase 1 exposure now verified end to end (2026-08-12): /healthz -> ok and an
unauthenticated POST /mcp -> 401, both from a client container and from the
primary itself. 3.4 and 3.6 marked passed.

Two findings from the failure in between, worth more than a checkbox:

1. Leaving Pangolin's resource authentication on does NOT surface as 401 or 403.
   It is a 302 with `location: https://pangolin.jordbo.se/auth/resource/<uuid>
   ?redirect=...`, sent *before* the request is proxied -- so the palace never
   sees it and its journal stays silent, `curl -s` prints an empty body, and an
   MCP client gets non-JSON. That is 1.1's "Pangolin's HTTP auth is
   browser-shaped; the clients are not" arriving as a concrete symptom rather
   than an argument. Now recorded with the exact header shape and the two curl
   flags that reveal it (-D-, -w '%{redirect_url}'), plus the reading: a 302 is
   good news, because DNS, TLS and routing all worked and only auth intervened.

2. Read the server's routing to settle whether path-scoped proxy rules are
   sufficient. The entire HTTP surface is two exact paths (mcp_server.py:5299-
   5318): GET /healthz (no token, Host/Origin gated) and POST /mcp (Bearer,
   compare_digest on the exact string). Everything else is a 404 from the palace
   itself. So path-scoped rules are tighter than a host-wide proxy and lose
   nothing. Two client-facing consequences: /mcp is matched exactly, so a
   trailing slash 404s -- configure clients without one; and there is no GET
   /mcp, no SSE, no session id, no DELETE, so this is plain JSON-RPC over POST,
   not MCP streamable-HTTP. A strict client opening with a GET handshake sees
   404. Also means the verify step needs no initialize and no Accept:
   text/event-stream, which the old snippet left ambiguous.

Also: run the 200 and 401 from two different networks, not one -- passing from
only the primary leaves split-horizon DNS untested.
2026-08-13 16:21:32 +02:00
Joakim Persson 08e344b047 docs: proxy target is http:// not https://; loopback probe refuses, it does not 403
Both corrections come from the first real Phase 1 start on synlig (2026-08-12).

1. The Pangolin resource target was documented as bare `172.17.0.1:8765` with no
   scheme, and the obvious guess from that is `https://` -- which cannot work.
   `contrib/systemd/mempalace-serve.service` runs `serve --host 172.17.0.1
   --port 8765` with no --tls-cert, so the primary speaks plaintext HTTP; TLS
   terminates at Pangolin, which is the whole point of the RFC 6.2 decision.
   Point a proxy at https:// and it attempts a TLS handshake against a plaintext
   listener: 502 from outside, while `curl 172.17.0.1:8765/healthz` on the box
   still says ok -- a confusing pair of symptoms. Now spelled `http://` with the
   failure mode named, in the runbook and in the unit's comments.

2. `curl -s 127.0.0.1:8765/healthz` was documented as "expect 403". Wrong: the
   real run returns empty. With a docker0-only bind nothing is listening on
   loopback, so the connection is refused at TCP level before any header is sent
   (%{http_code} -> 000, exit 7). The 403 is the *loopback-bind* case verified
   2026-08-10 -- server on 127.0.0.1 answering a proxy-forwarded foreign Host.
   Two distinct behaviours had been collapsed into one expectation in three
   places (both runbooks and the unit).

   Worth stating why the correction matters rather than just fixing it: refusal
   is the *stronger* signal. A 403 proves only that a request was rejected; a
   refused connection proves the loopback and LAN surface is not listening at
   all. Someone who expected 403, saw silence, and "fixed" it by rebinding to
   0.0.0.0 would have converted a correct configuration into an exposed one.
   The docs now also say what to do if it hangs, or if ss shows 0.0.0.0:8765.
2026-08-13 16:01:02 +02:00
Joakim Persson 00a95d1a2f docs: why the tunnel and the feeder's SSH path are not redundant; newt done
Asked "why do we need Pangolin if you proposed rsync/ssh?", and the runbook did
not actually answer it -- it stated both were needed without saying why neither
substitutes. New section 1.3:

  - Pangolin/HTTPS carries the MCP tool surface (search, add_drawer,
    diary_write, kg_*) -- every live tool call, from any MCP client.
  - SSH/rsync carries transcript *files* only, because mempalace_mine expands
    its source path server-side, so the server can only mine its own disk.

HTTPS alone is a palace you can query but cannot feed; SSH alone is files with
no query API. The rsync is not a transport preference, it is a workaround for
where `mine` resolves paths.

Records honestly that `ssh -L 8765:172.17.0.1:8765 synlig` *would* replace the
tunnel for MCP, and why we don't: synlig dials out (reaching for a dial-out
tunnel is itself the evidence inbound was unavailable), MCP clients want a
durable URL rather than a per-session forward, and the forward must be up on
every device before every session.

And the design's weak point, stated instead of glossed: the rsync runs
client -> synlig, so mining needs synlig's SSH reachable *from the client*. Were
that true everywhere, no tunnel would be needed for MCP either. Honest
expectation after Phase 1 is therefore: query/write from anywhere, mine only
from devices that can reach synlig's SSH. Section 4 now carries the upstream ask
that would close the 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.

New section 3.7, a live trap for the imminent client flip:
MEMPALACE_REMOTE_URL on its own does not degrade to local feeding, it *stops*
feeding. auto mode switches to remote as soon as the URL is set (:286) and
remote mode then exits 1 without MEMPALACE_PI_SSH_TARGET (:298-300), before
anything is staged or filed -- so a cron feeder just starts failing, and the
loudest symptom is silence. Two safe orders given: set all three variables in
one edit, or set URL+token and pin --mode local until the SSH target exists.

newt is installed on synlig and connected to Pangolin (done 2026-08-12), marked
here and in the synlig runbook's item 2; the blocker is now item 3, the one
sudo. Added the follow-up that "connected to Pangolin" only proves newt reached
nyvaken -- reaching the *palace* is a separate claim that fails independently,
so probe 172.17.0.1:8765/healthz from inside newt's namespace.

All seven code citations verified against the source at commit time.
2026-08-13 00:15:57 +02:00
Joakim Persson 29e660e18f feeders: stage beside the palace, not in ~/.cache; document Phase 1 exposure
Staging default moves out of ~/.cache to <palace-root>/pi-stage (pi) and
<palace-root>/opencode-stage (opencode), resolved with mempalace's own
palace-path precedence ($MEMPALACE_PALACE_PATH -> $MEMPAL_PALACE_PATH ->
~/.mempalace/config.json -> ~/.mempalace/palace), then dirname.

Why: the convos miner keys dedup on the *staged* path, so a wiped stage plus a
sync scoped to include it prunes the drawers mined from those sources --
deleting memories, not a cache. Under ~/.cache that state was reachable by
anything treating a cache as disposable. Staging inside the palace makes the
coupling structural: the stage cannot be wiped without touching the palace
itself. Overrides ($MEMPALACE_PI_STAGE / $MEMPALACE_SESSION_STAGE, --stage) are
unchanged. Note the old default had never been created on any host, so this
closed a latent hazard, not a live one.

Measured, and the docs now claim only this much: sync prunes only within the
scope it is given -- wing-only, 1299 scanned / 1299 out of scope / 0 removed;
scoped at the palace root, 651 kept / 648 out of scope. The previous blanket
"sync prunes every drawer" wording overstated it, which is a liability: the next
reader disproves the overstatement and discards the real constraint with it.

Also in this change:
- cron log dir ~/.cache/mempalace-session -> ~/.cache/mempalace-logs. The stage
  left that namespace, so the old name now read as "the stage".
- AGENTS.md: the convos miner *does* check mtime (verified against upstream
  convo_miner.py); the previous "no mtime check" claim was wrong.
- smoke-test assertions use `mktemp -d` for --sessions-dir. One pointed at /tmp,
  which still held earlier synthetic transcripts, so a --dry-run exported a fake
  session into the real stage: --dry-run skips the mine, not the export.

docs/phase-1-exposure-runbook.md -- the newt/DNS/auth step that RFC 001 and the
synlig runbook leave open (runbook section 4, items 2 and 5). Port 8765 at /mcp,
newt targets 172.17.0.1, and the authentication is the single shared bearer
token (RFC 6.2, decided 2026-08-09) rather than per-device proxy users. The
latter cannot work today: mempalace validates exactly one token, and Pangolin's
SSO/PIN/password are browser-shaped while every client here is a headless
JSON-RPC POST -- enabling that protection breaks the clients it protects. The
per-device axis that *does* exist is the feeder's SSH key + per-device inbox.

New finding recorded there: a loopback bind does not merely 403 behind a tunnel
(already known, runbook 2.4) -- it also silently starts the server with no token
at all, because auto-minting is gated on the bind being non-loopback.

extensions/pi/README.md: the HTTP transport IS authenticated as of mempalace
3.6.0; the "sessionless and unauthenticated" note dated from the v1.3.0 era.
Closes the RFC section 8 Phase-0 hygiene item.
2026-08-12 17:04:01 +02:00
Joakim Persson 3626946013 Phase 0 on synlig: install, palace layout, verified Host/Origin policy
synlig is greenfield — no mempalace, no ~/.mempalace at all — so Phase 0
became "provision correctly from birth" rather than "migrate carefully".
Nothing is serving; no client config was touched.

Done:
- mempalace 3.6.0 installed via uv, pinned to the fleet version (the id
  recipes and idempotency probes this RFC leans on are version-specific).
- Embedder pre-warmed. This was the real unknown: the first embed pulls a
  79.3 MB ONNX model from the chroma CDN, and an egress-filtered work VM
  would have failed at the worst moment — the first client write. Pulled
  at ~20 MB/s, no proxy interference. Done in a throwaway palace so the
  real one never saw it.
- Palace at the stock default ~/.mempalace/palace, so no config file and
  no MEMPALACE_PALACE_PATH is needed on synlig at all.

Two corrections to the RFC, both from provisioning rather than reading:

- §7.1 was understated. DEFAULT_PALACE_PATH (~/.mempalace/palace) and
  DEFAULT_KG_PATH (~/.mempalace/knowledge_graph.sqlite3) already differ
  with stock defaults, so the KG split is out-of-the-box behaviour, not a
  consequence of a custom --palace, and it is permanent rather than
  one-time: serve always passes --palace, any CLI call without it uses
  the HOME path. A one-time mv does not fix that, it only picks which of
  the two files gets populated. Fixed instead by converging both rules on
  one inode via relative symlinks, and verified the load-bearing
  assumption: a dangling symlink is created on connect, -wal/-shm land
  next to the target (so the palace dir stays a self-contained backup
  unit, which is the part that mattered), cross-path read works, same
  inode. hallways.json deliberately left alone — already palace-derived,
  HOME path is a warning-only probe.
- §6.2 upgraded from "test early" to verified: 11/11 as predicted. The
  headline is that the safe-sounding reflex is the failure mode — loopback
  bind + proxy forwarding a public Host is 403, non-loopback is 200. Also
  confirmed Origin is never relaxed on either bind, and /healthz is
  Host/Origin-gated but token-free, so it works as the tunnel probe.
  Recommends binding the docker0 gateway over 0.0.0.0: non-loopback so
  the pin relaxes, but reachable only from the host and its containers.

New docs/synlig-primary-runbook.md carries the discovered facts about the
box, the evidence tables, an explicit "deliberately not done" list, and
tomorrow's ordered steps. New contrib/systemd/mempalace-serve.service
carries the bind rationale inline so nobody "fixes" it back to loopback;
staged on synlig with a .staged suffix so systemd cannot pick it up by
accident.

Flagged for tomorrow: synlig has no newt/tunnel client (docker ps shows
only the Gitea runner and digikam), so Pangolin on nyvaken cannot reach
it until one is added — easy to miss, because Pangolin will look healthy
from its own side.
2026-08-10 00:14:06 +02:00
Joakim Persson 7e51055c96 rfc-001: join protocol, diary non-idempotency, deployment decisions
Second planning round. Three findings from verification, and the
decisions that were blocking phasing.

Verified in mempalace 3.6.0 and written up:

- NEW §7.6 — diary_write has no idempotency guard at all. The id embeds
  datetime.now() at microsecond resolution and the write is a bare
  col.add (mcp_server.py:3546) with no col.get probe, in direct contrast
  to add_drawer's probe 900 lines earlier (:2593-2600). So §3's "log the
  intent, replay the intent" is false for diaries — replay duplicates.
  This lands on the critical path because diaries are `replicated` and
  are exactly what a join replays.
- NEW §4.4 — join/bootstrap protocol, which the RFC simply lacked. Not
  "seed the primary from one palace": every container joins the same way,
  repeatedly, so a join is idempotent replay and the only question per
  record type is what dedupes it. Drawers and open KG facts need zero
  client bookkeeping; closed KG facts and diaries need client-side keys.
  Join state belongs in the palace dir, not the container (~/.mempalace
  is not preserved by default), which also makes two containers sharing
  one host's palace the easy case rather than a double-upload hazard.
- §3.2 — the add_triple guard is scoped to `valid_to IS NULL`, so closed
  historical facts are unguarded. §3 read as unconditionally idempotent.
- §7.3 — drawer/diary metadata is exhaustive: no session, PID or
  conversation field. Two concurrent pi sessions are indistinguishable,
  and pi-vs-opencode is only accidentally distinguishable because the pi
  extension never sets added_by (it sets identity for diaries only,
  mempalace.ts:758). Corrects the §7.3.3 bullet that claimed
  multi-harness attribution was already solved — the field is the right
  home, but nothing populates it. Design rule stated: device + agent,
  never session; provenance granularity equals token granularity.
- §6.2 — Host-pinning is coupled to the bind address
  (enforce_host_pin = _http_is_loopback(host), :5367). The trap is the
  RFC's own "bind loopback" reflex: behind a proxy that forwards a public
  Host, a loopback bind 403s, while a non-loopback bind deliberately
  relaxes the pin. The Origin check is never relaxed. This replaces the
  warning I first wrote, which had it backwards.
- §9.3 — largely resolved: the last-chunk-only probe is deliberate
  (batched upsert is all-or-nothing, :2586-2592). Only a crash mid-upsert
  remains untested.
- §4.1 — fix shape for opencode's stale-config asymmetry: make the
  mcp.mempalace subtree env-authoritative, with a fingerprint so
  hand-edits still win. pi-devbox/entrypoint-user.sh:131-162 already has
  the jq deep-merge pattern to port; no OPENCODE_CONFIG* layer exists
  upstream, so the real file must be written. Now Phase 1.5.

Decisions (new §8.1): primary = synlig; TLS at Pangolin; single shared
token for now (so origin_device stays advisory); diaries replicated.
Work/personal is per-wing, not per-device — two primaries split along
machine lines is rejected, because pi-devbox/opencode-devbox are
simultaneously work and home projects and the device where work happened
cannot classify the project. MEMPALACE_REMOTE_URL stays scalar so
multi-store remains additive later (new §9.7 keeps the placement
question open).
2026-08-09 23:48:57 +02:00
Joakim Persson fdcd5871de rfc-001: resolve open questions 1 and 6 — both permissive
Recon before planning the implementation, and both blockers dissolved.

Q1, does opencode support remote MCP: yes. Its published schema defines
McpRemoteConfig (type/url/headers/oauth) as a sibling of McpLocalConfig, with
headers as a free string→string map. The RFC had been treating "every sampled
config in myconfigs is type:local" as evidence about the schema when it was
only evidence about deployments. Consequence: the edge proxy is not the only
option for opencode, so nothing in the phasing hangs on this. What opencode
still lacks is offline/local-first, which is the honest Phase 2 argument.

Q6, how opencode-devbox learns the opt-in: it already does. generate-config.py,
run from entrypoint-user.sh:117, registers mempalace as remote+bearer when
MEMPALACE_REMOTE_URL is set and local stdio otherwise, and its comment says it
deliberately mirrors the mempalace.ts env contract. The image also ships
mempalace by default, so R5's premise was wrong and is corrected. Phase 2 there
is a third branch in an existing script, not a new mechanism.

One new constraint found while confirming this, and it is the sharpest edge in
the whole opt-in story: generate-config.py never overwrites an existing config
and ~/.config/opencode is a named volume, so flipping the .env on a container
that already has a config is a no-op — it only writes an opencode.jsonc.proposed
sidecar. pi re-reads env every start; opencode does not. Documented in §4.1 and
§9.6, and it applies symmetrically to R6 reversibility.
2026-08-09 22:42:00 +02:00
Joakim Persson 052dbb8038 docs(rfc-001): provenance belongs to the sync boundary, not the agent
Reverses the previous §7.3 on review. It said "stamp added_by everywhere,
now, because it cannot be backfilled" and was about to become a skill
instruction telling agents to do it. Both halves were wrong.

Wrong on ownership: provenance answers "which device asserted this?", so
only a party that can verify the answer should write it. An agent must shell
out to read env, can forget, and will improvise when the values are absent —
the worst possible stamper, and its claim is unverifiable by anyone. Under
the §4 design every write reaches the primary over an authenticated channel,
including offline ones at outbox-flush time, so the primary can stamp the
complete set with no client cooperation. Provenance is a property of the sync
channel, not of the record's author. §7.3.2 adds the trust ladder; edge-side
stamping is demoted to an advisory interim, because 3.6.0's serve takes a
single shared bearer token (mcp_server.py:5291-5293) and the package has zero
device/origin concept, so authoritative stamping needs the per-device
credentials of §6 — Phase 4, not Phase 0.

Wrong on backfill: a solitary devbox is a single-origin store by definition,
so origin is a property of the whole palace and can be assigned wholesale at
import (one --origin-device flag) at the moment it stops being solitary. Bulk
attribution is strictly more reliable than per-record stamping since it
cannot be partially applied. Per-record provenance is only needed where
origins interleave, which is only the primary. Solitary containers therefore
stamp nothing and lose nothing — more R1-compliant than the previous draft,
which quietly asked users who had opted out to carry metadata for the
feature. Multi-harness-on-one-host stays solved by added_by = agent name.

Keeps the verified mechanics (fixed metadata schema, argument whitelisting at
mcp_server.py:4777 silently dropping unknown fields, the diary agent_name →
wing_pi@host trap, kg_add having no provenance slot, added_by absent from
search results) and the identity findings (a container cannot discover its
host's identity; hostnames are neither unique nor stable; rename splits one
device's history in two). §7.3.5 keeps the fail-closed rule for whichever
component does stamp.

Phase 0 drops its provenance item accordingly, and §6.2 now specifies
server-side stamping from the authenticated credential.

Also flags in-document that these notes are a poisoning vector: a future
agent reading them out of the palace must not conclude it should hand-stamp.
2026-08-09 15:40:25 +02:00
joakimp 661ee20b39 docs(rfc-001): require solitary-first operation, opt-in centralization
Adds §1.1 (R1–R6) and §1.2 as a hard constraint on the design rather than a
preference. A shared palace is valuable only for the multi-machine /
multi-container pattern; for most users of the published pi-devbox and
opencode-devbox images it is useless overhead. Evidence: all three sampled
opencode-devbox deployments contain zero mempalace references.

Requirements: solitary operation stays the default and stays byte-identical
(no extra process, no outbox, no network calls); opt-in via docker-compose.yml
+ .env only, never an image rebuild; credentials only in .env, never in
compose, the image, a command line, or a log; no new required services (the
primary stays a separate standalone compose project); degrade-not-fail where
mempalace is absent; fully reversible.

§1.2 extends the convention pi-devbox already ships (.env.example:12-23,
"local by default" with commented MEMPALACE_REMOTE_URL/_TOKEN) into a
three-state ladder — local stdio (default, unchanged) / direct remote
(unchanged) / edge (new, MEMPALACE_EDGE=1) — instead of inventing a new
mechanism. Notes that the devbox-palace volume coupling reverses under edge
mode: the local palace holds the outbox, so persisting it becomes required
rather than irrelevant.

Consequence recorded in §4.1: mempalace-edge must be selected at registration
time, not left always-in-path to decide by env at runtime, since that would
insert a process and a failure mode into every solitary user's setup. pi
branches in createClient(); opencode needs its static MCP JSON templated at
container start, which is new open question §9.6.

Also: adds an R1 acceptance test, marks pi-devbox/.env.example:21 as stale
(advertises mempalace-mcp --transport http rather than mempalace serve
--token/--tls-cert), and extends the evidence index.
2026-08-09 15:02:51 +02:00
joakimp 35b1e3d81d docs: add RFC 001 — global palace with local fallback (mempalace-edge)
Design for moving from one MemPalace per machine per harness to a single
primary palace with per-machine offline fallback, plus the source-verified
archaeology behind it.

Key decisions recorded:

- Sync operations (MCP tool calls), not databases. Embeddings are computed
  client-side and are not portable across architectures; the KG `triples`
  table has no UNIQUE(subject,predicate,object,valid_from) and its ids embed
  datetime.now(), so row copies duplicate facts. Replaying tool calls is
  idempotent where it matters.
- Implement as `mempalace-edge`, a local stdio MCP proxy (child mempalace-mcp
  + HTTPS to the primary + outbox.sqlite), not as per-harness patches. Needs
  zero mempalace internals, so it serves pi, opencode and the CLI alike and
  survives mempalace upgrades.
- Merged reads (query both, re-sort, dedupe by drawer id) give fleet-wide
  recall without replication — which is why Phase 3 (pull replication) is
  deferred: "own writes plus whatever it can reach" is good enough.
- Per-wing replication policy: curated/content-addressed wings replicate,
  mined code/docs stay local (derived, re-mineable, path-dependent ids).

Also documents two silently destructive footguns to avoid during rollout:
`--palace` vs MEMPALACE_PALACE_PATH (the KG follows the flag only, so
`mempalace serve` can start with a silently empty knowledge graph), and
`mempalace sync`, which is gitignore-aware drawer deletion rather than
replication and would wipe fleet memory when run from a host lacking the
repos. Notes that mempalace 3.6.0's `serve` already ships token auth + TLS,
making the "unauthenticated, front it with a proxy" notes elsewhere stale.

Includes an evidence index mapping each claim to file:line in mempalace
3.6.0, and four upstream candidates (origin_host provenance, per-wing ACL,
mempalace_kg_supersede missing from service.py WRITE_TOOLS, and a
sync --refuse-shared guard).
2026-08-09 00:30:00 +02:00
pi 96699f2a17 feat(pi-bridge): external MemPalace transport via MEMPALACE_REMOTE_URL
Let the pi<->mempalace bridge connect to a shared MemPalace over HTTP instead
of always spawning a local mempalace-mcp:
- Extract IMcpClient; rename McpClient -> StdioMcpClient (ctor command, arg-less start()).
- Add RemoteMcpClient (vendored from pi-extensions/mcp-loader.ts): streamable-HTTP
  with AbortController timeouts, protocolVersion pinned 2024-11-05, alive/ensureAlive.
  mempalace-mcp --transport http is sessionless JSON-RPC today; session/SSE/404
  branches retained for a future streamable-HTTP server.
- createClient() selects transport from MEMPALACE_REMOTE_URL; MEMPALACE_REMOTE_TOKEN
  -> Authorization: Bearer. Lifecycle automation (wake-up, /mempalace-diary) unchanged.
- scripts/check-mcp-client-sync.sh: drift guard vs canonical mcp-loader.ts.
- README: document local-vs-external transport.

Typechecks clean (strict); both transports smoke-tested against live mempalace-mcp.
2026-07-02 13:09:29 +02:00
pi e12b624cf7 feat(pi-ext): self-healing respawn + scoped init timeout for mempalace-mcp
A stall-kill (or any crash) of mempalace-mcp was a permanent latch:
available flipped off and stayed off until pi restart. Now the next tool
call transparently respawns the server and retries.

- ensureAlive(): bounded respawn with capped exponential backoff
  (MEMPALACE_MCP_MAX_RESPAWNS, default 2; MEMPALACE_MCP_RESPAWN_BACKOFF_MS,
  default 1000). Respawn budget resets on any successful JSON-RPC response,
  so a recovered server regains full patience while a persistently-broken
  one hits the cap and stays down (no hot-loop).
- Init timeout default raised 120000 -> 300000 (scoped to init only): a
  genuine virtiofs cold-open shouldn't be killed mid-progress only to
  respawn and re-pay the same cost. Per-call timeout stays 60000.
- Concurrency hardening: generation counter so a late exit from a killed
  old process can't tear down a fresh respawn; explicit healthy flag
  replaces racy proc!=null liveness check.
- README: document self-heal, new env vars, and why generous-init +
  bounded-respawn compose rather than overlap.
2026-06-26 00:22:21 +02:00
joakimp a3b8829991 feat(pi-ext): per-request timeout + stall-kill for mempalace-mcp
A wedged mempalace-mcp (classically an OrbStack virtiofs cold-open of a
large chroma.sqlite3 / HNSW load) left the awaiting JSON-RPC promise
pending forever, freezing the pi TUI uninterruptibly: ESC cancels the
LLM stream, not a pending tool execute().

The JSON-RPC client now arms a per-request timer. On expiry it rejects
the request AND kills the stalled child (SIGTERM->SIGKILL), so pi gets
an error instead of hanging; the extension flips available=false so
later calls fail fast (restart pi to retry). Per-REQUEST, not
per-process: the long-lived server only dies on a genuine stall.

Knobs: MEMPALACE_MCP_TIMEOUT_MS (default 60000),
MEMPALACE_MCP_INIT_TIMEOUT_MS (default 120000), 0 = disable.

This supersedes the planned standalone stdio-watchdog shim: the
extension already owns request/response correlation, so a separate
framing-reparsing shim is unnecessary.
2026-06-13 23:48:33 +02:00
joakimp ce09d25c97 Rename to @earendil-works/pi-coding-agent + earendil-works/pi URL
Pi moved to its new home at earendil-works on 2026-05-07
(https://pi.dev/news/2026/5/7/pi-has-a-new-home).

Sweep:
- extensions/pi/mempalace.ts: 'import type { ExtensionAPI } from
  "@mariozechner/pi-coding-agent"' -> @earendil-works/pi-coding-agent.
- README and extensions/pi/README: github.com/mariozechner/pi-coding-agent
  URL refs -> github.com/earendil-works/pi.
- install.sh: same URL substitution in the user-facing pointer line.

Brew install references (`brew install pi-coding-agent`) left as-is:
formula still works at 0.73.1, tap update tracked upstream at
earendil-works/pi#2755.
2026-05-09 17:56:46 +02:00
joakimp 90e70fff61 docs: add Ecosystem diagram to README + update harness-extension conventions post-split
Two small doc updates consolidating the session's architectural arc:

1. README.md gets a new 'Ecosystem' section (after the contents list,
   before 'Why this exists') showing the five-repo composition:
     myconfigs -> opencode-toolkit + pi-toolkit -> mempalace-toolkit
   Plus an ownership table clarifying which scope lives where. The
   diagram makes the opt-out pattern visible \u2014 opencode-devbox's slim
   container path skips mempalace-toolkit and still gets a functional
   stack.

2. AGENTS.md 'Adding a new harness extension' section was still written
   for the pre-split extensions/pi/ which had keybindings.json +
   settings.example.json + pi-env.zsh. Rewrote to reflect:
   - Bridge-only scope (harness-generic config goes in <harness>-toolkit).
   - 'Probe for the sibling toolkit' step replaces the old symlink-keybindings
     and template-settings steps.
   - Worked example now points at install_pi_extension + check_pi_toolkit
     rather than the four functions that moved out.
   - Explicitly names the pattern: opencode-toolkit + pi-toolkit as
     the two existing examples of the sibling-toolkit convention.
2026-05-05 17:50:13 +02:00
joakimp 16915f0e55 refactor: split pi-generic config into pi-toolkit repo
Parallel to the opencode-toolkit split earlier today. Pi's own config
(keybindings, shell env loader, settings template) moves to a new
sibling repo so opencode-devbox's mempalace opt-out can build slim
containers that include pi without dragging in chromadb + embedding
models (~300 MB).

What moved to pi-toolkit (https://gitea.jordbo.se/joakimp/pi-toolkit):
- extensions/pi/keybindings.json          (mosh/tmux newline fix)
- extensions/pi/pi-env.zsh                (sources ~/.config/pi/.env)
- extensions/pi/settings.example.json     (Bedrock template)
- install.sh::install_pi_keybindings      (symlink step)
- install.sh::install_pi_env_loader       (cp step + bash fallback)
- install.sh::check_pi_settings           (probe)
- install.sh::check_aws_env               (probe)

What stays here (this is the pi\u2194mempalace bridge, mempalace-side):
- extensions/pi/mempalace.ts              (the MCP extension)
- install.sh::install_pi_extension        (symlink step)
- NEW: install.sh::check_pi_toolkit       (probe: warns if pi is
                                           installed but pi-toolkit's
                                           artifacts are missing, with
                                           git-clone pointer)

install.sh shrank from 520 to 403 lines. Uninstall mirror correctly
does NOT touch pi-toolkit-owned files (explicit comment).

Docs updated:
- extensions/pi/README.md: rewritten as 'pi\u2194MemPalace MCP bridge',
  recipe becomes 'Deploying pi with mempalace' (pi-toolkit step 3,
  this repo step 5).
- AGENTS.md: Structure block + 'What install.sh does' section reflect
  the narrower scope and list the four things that moved out.
- README.md: repo-contents line + Setup section's deploy summary.

Verified on tor-ms22: full install\u2192uninstall\u2192reinstall lifecycle clean.
After mempalace-toolkit uninstall, pi-toolkit artifacts
(~/.pi/agent/keybindings.json, ~/.oh-my-zsh/custom/pi-env.zsh) remain
intact \u2014 correctly untouched. check_pi_toolkit probe fires green when
both exist.
2026-05-05 17:26:53 +02:00
joakimp 3d3a0fb125 docs(pi): add opencode-toolkit pointer in deploy recipe step 6
Cross-reference the newly-extracted opencode-toolkit repo, which owns
~/.config/opencode/.env loading. The recipe now distinguishes between
registering mempalace in opencode.json (still this repo's concern) and
ensuring opencode's own env loader is in place (opencode-toolkit's).
2026-05-05 17:14:32 +02:00
joakimp 118bd20fec feat(extensions/pi): ship pi-env.zsh shell loader
The loader that sources ~/.config/pi/.env into every shell was only
living in the myconfigs tor-ms22 backup \u2014 a fresh machine had nowhere
to get it from except copying by hand. Now canonical here.

- extensions/pi/pi-env.zsh: 20-line POSIX-compatible loader
  (set -a; source ~/.config/pi/.env; set +a). Works in bash and zsh.
- install.sh install_pi_env_loader:
  * oh-my-zsh detected (~/.oh-my-zsh/custom/ exists)
    \u2192 cp into that dir (NOT symlink \u2014 that dir is typically part of
      a dotfiles backup, and a symlink to mempalace-toolkit would
      break when restored on another host).
    \u2192 Idempotent: if target content matches repo, says 'already
      installed'. If it differs, leaves user edits alone and points
      at diff for manual reconcile.
  * No oh-my-zsh \u2192 prints source-this-line snippet for ~/.zshrc or
    ~/.bashrc (derived from $SHELL). Does NOT auto-edit rc files.
- install.sh uninstall: only removes the copy if content still matches
  repo. Local edits preserved.
- Docs:
  * extensions/pi/README.md Environment setup section rewritten with
    both install paths, step 5 of deploy recipe updated.
  * AGENTS.md Structure block lists pi-env.zsh.
  * Root README repo-contents line mentions it.

Verified on tor-ms22: install fresh \u2192 uninstall (content match \u2192 remove)
\u2192 reinstall \u2192 zsh -ic loads AWS vars correctly. Also tested bash fallback
path via HOME=/tmp/fake-home SHELL=/bin/bash \u2014 prints right .bashrc snippet.
2026-05-05 16:58:56 +02:00
joakimp 5d8f523cb3 docs(AGENTS): unstale wrapper count + new 'Adding a new harness extension' section
Two stale lines from the 2-wrapper era (we have three now), plus a
missing convention doc for future harness extensions:

- 'What this is': two wrappers \u2192 three + extensions/ tree
- 'Adding a new wrapper': drop the 'third wrapper triggers helper lib'
  claim since three coexist fine as standalone scripts; keep the
  threshold at four, re-evaluate then.
- New 'Adding a new harness extension' section codifies the extensions/
  pattern so a future claude-code or kiro bridge follows the same shape
  (per-harness dir, symlink-vs-template rules, gated install steps,
  probe-don't-halt, uninstall mirror, README + Structure updates).
2026-05-05 15:29:15 +02:00
joakimp 71c335148a docs(pi): full 'new machine' deploy recipe in extensions/pi/README
Consolidates the step-by-step recipe that's been living in diary entries
and session chat into the canonical pi bring-up doc. Covers:

  0. Prerequisites (zsh+oh-my-zsh, uv, tmux 3.2+, AWS creds)
  1. Dotfiles: myconfigs provision (tmux CSI-u, ~/.config/pi/.env, zsh loader)
  2. pi install (upstream brew/npm)
  3. mempalace CLI (uv tool install) + mempalace-toolkit install.sh
  4. pi settings bootstrap (start without --model, region prefix table)
  5. AWS env verification (git-crypt unlock gotcha)
  6. Opencode MCP registration pointer (if applicable)
  7. First run + wake-up injection smoke test
  + Verification checklist + uninstall

Root README.md adds a short summary box in the Setup section pointing at
the full recipe, so readers coming in from the front door find the pi
path immediately but the details stay with the files they install.

Covers: macOS + Linux. Works for homelab / work-macos / any myconfigs
profile that ships .config/pi/ + pi-env.zsh.
2026-05-05 15:20:47 +02:00
joakimp 79e0692dac docs: update AGENTS.md structure + ARCHITECTURE.md see-also for pi bring-up
AGENTS.md Structure block was stale (listed only 2 bin/ wrappers, no
extensions/, no contrib/). Added full tree + a new 'What install.sh does'
section enumerating all steps, gates, and probes so maintainers see at a
glance what the installer touches.

ARCHITECTURE.md is scoped to the producer side (feeding the palace);
pi extension is consumer side, so out of scope for the main body. Added
a pointer in the See also section so readers can find extensions/pi/README.md.
2026-05-05 14:47:06 +02:00
joakimp 75876e5c41 fix(install): silence AWS probe when pi settings.json absent
check_aws_env was warning about missing AWS_PROFILE/AWS_REGION even on
fresh machines with no settings.json yet \u2014 but at that point we don't
know which provider the user will pick, so the warning is noise.
check_pi_settings already tells the user to bootstrap settings.json;
the AWS probe now stays quiet until it has evidence (amazon-bedrock in
settings.json) that AWS creds are actually needed.
2026-05-05 14:44:49 +02:00
joakimp 854ae41f65 feat(extensions/pi): keybindings symlink + settings template + AWS/pi probes
Round out the pi bring-up story so a fresh machine can reach a working
pi+mempalace install with just `git clone && ./install.sh`:

- extensions/pi/keybindings.json: generic mosh/tmux newline fix
  (shift+enter, ctrl+j, alt+j). Safe on any machine — not
  region/account-specific. Symlinked into ~/.pi/agent/.
- extensions/pi/settings.example.json: template for `settings.json`
  so pi can start without --provider/--model. NOT symlinked — pi
  rewrites settings.json at runtime (lastChangelogVersion bumps),
  which would dirty the repo. Installer prints the cp + edit hint.
- install.sh: new install_pi_keybindings + uninstall mirror; new
  check_pi_settings probe (warns if settings.json missing); new
  check_aws_env probe (warns if AWS_PROFILE/AWS_REGION unset and
  settings.json selects amazon-bedrock). All new steps gated on
  pi being installed (~/.pi/agent/extensions/ exists).
- extensions/pi/README.md: documents keybindings rationale,
  settings bootstrap, and the recommended ~/.config/pi/.env +
  ~/.oh-my-zsh/custom/pi-env.zsh env layout (paired with the
  myconfigs commit 884e329 that split AWS vars out of
  ~/.config/opencode/.env).

Verified on tor-ms22: full install → uninstall → reinstall cycle,
new shell loads AWS_PROFILE/AWS_REGION from the new pi-env.zsh hook.

Works on macOS and Linux (plain ln -s, POSIX bash).
2026-05-05 13:59:20 +02:00
joakimp ef1d022fbc feat(extensions): version-control pi mempalace extension + install.sh symlink
The pi coding-agent extension at ~/.pi/agent/extensions/mempalace.ts was
living only on tor-ms22, including hand-edited fixes (Type.Unsafe
schema-passthrough for MCP tool parameters). One disk wipe away from
losing it, and no way to reproduce the install on a new machine.

- extensions/pi/mempalace.ts: canonical copy (matches tor-ms22 byte-for-byte)
- extensions/pi/README.md: what it does, the schema-passthrough gotcha,
  debugging knobs
- install.sh: new install_pi_extension step — gated on ~/.pi/agent/extensions/
  existing, backs up any real file in the way, idempotent re-runs, mirror
  block in uninstall. Works on macOS and Linux (plain ln -s, readlink -f).
- README.md: mention extensions/pi/ in the repo-contents list and in the
  Setup section

Verified on tor-ms22: install (backs up existing real file) → uninstall
(removes symlink) → reinstall (clean symlink). Re-runs are no-ops.
2026-05-05 13:42:47 +02:00
joakimp 6352373a1f fix(feeders): make post-mine repair opt-in, not default
The three feeder wrappers (mempalace-docs, mempalace-pi-session,
mempalace-session) unconditionally ran 'mempalace repair --yes' after
mining, controllable only via --no-repair opt-out. The contrib launchd
and systemd templates did not pass --no-repair, so every scheduled tick
invoked the destructive in-place HNSW rebuild.

This has bitten us twice:
  - 2026-05-04 09:08: a kickstart triggered repair while an MCP
    subprocess held the DB open; the live collection was wiped (0
    drawers) and had to be restored from the palace.backup snapshot.
  - 2026-05-05 10:00: post-mine repair crashed mid-rebuild with
    'NotFoundError: Collection [<uuid>] does not exist' - chromadb's
    rebuild recreated the collection under a new UUID while the code
    still held the old handle. Live DB survived only by luck (crash
    hit before the swap).

Fix: flip the default.
  - New flag: --repair (opt-in). Prints a warning and sleeps 3s before
    invoking 'mempalace repair --yes'.
  - --no-repair is retained as a deprecated no-op alias for backward
    compatibility with any scripts/units still passing it.
  - Default behavior: no repair. Routine ChromaDB add() keeps HNSW
    consistent; repair is a recovery op, not a maintenance tick.

Docs updated to match: README, SKILL, ARCHITECTURE, AGENTS,
contrib/README. Scheduling guidance now explicitly warns against
enabling --repair on cron/launchd/systemd-timer runs.
2026-05-05 12:35:04 +02:00
27 changed files with 5361 additions and 95 deletions
+100 -7
View File
@@ -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
View File
@@ -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,900100,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.
+209 -6
View File
@@ -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,900100,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)).
+25 -3
View File
@@ -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 ~510 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.
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env bash
# mempalace-backup — consistent, verified backups of a file-backed MemPalace palace.
#
# WHY THIS EXISTS
# The fleet primary (RFC-001) had no backups at all. A palace is not one file:
# it is two SQLite databases (one of them in WAL mode), a set of chromadb HNSW
# segment directories, and a few JSON sidecars. `cp -r` on a live palace can
# capture a torn WAL and an index that disagrees with the sqlite.
#
# TWO MODES
# hot (default) Zero downtime. Both SQLite files are copied with the sqlite
# ONLINE BACKUP API (a transactionally consistent snapshot,
# WAL included). The HNSW segment dirs are rsynced and may be
# slightly skewed relative to the sqlite — which is RECOVERABLE,
# because the sqlite is the source of truth and the index can be
# rebuilt: `mempalace repair --mode from-sqlite`.
# cold (--cold) Stops the server, copies everything byte-for-byte, restarts.
# Guaranteed self-consistent including the index. The restart is
# trapped so an error still brings the server back.
#
# BONUS: restoring the hot backup via `repair --mode from-sqlite --archive-existing`
# re-CREATES the collections, which is also the only path that applies the modern
# chromadb HNSW defaults (batch_size=100 / sync_threshold=1000). Palaces created
# under mempalace <=3.6 are stuck on 2/2 and sync the index every 2 records.
#
# NOTE: synlig has no sqlite3 CLI, so every SQLite operation here goes through
# python3's stdlib sqlite3 module. Do not "simplify" this to `VACUUM INTO`.
set -euo pipefail
PALACE="${MEMPALACE_PALACE_PATH:-$HOME/.mempalace/palace}"
DEST="${MEMPALACE_BACKUP_DIR:-$HOME/backups/mempalace}"
UNIT="mempalace-serve"
KEEP=7
COLD=0
DRY=0
VERIFY=""
usage() {
cat <<'USAGE'
usage: mempalace-backup [--cold] [--dest DIR] [--keep N] [--palace DIR] [--dry-run]
mempalace-backup --verify BACKUP_DIR
--cold quiesce the server (stop/copy/start) for a byte-consistent copy
--dest DIR backup root (default: $HOME/backups/mempalace)
--keep N how many backups to retain (default: 7; 0 = keep all)
--palace DIR palace to back up (default: $MEMPALACE_PALACE_PATH or ~/.mempalace/palace)
--dry-run report what would happen, touch nothing
--verify DIR integrity-check an existing backup and print its counts
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--cold) COLD=1 ;;
--dest) DEST="$2"; shift ;;
--keep) KEEP="$2"; shift ;;
--palace) PALACE="$2"; shift ;;
--dry-run) DRY=1 ;;
--verify) VERIFY="$2"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "mempalace-backup: unknown option $1" >&2; usage >&2; exit 2 ;;
esac
shift
done
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"; }
die() { log "ERROR: $*"; exit 1; }
# ── shared python helpers ────────────────────────────────────────────────────
# check_db: PRAGMA quick_check + row counts, on a copy. Prints JSON.
py_check() {
python3 - "$1" <<'PY'
import json, os, sqlite3, sys
path = sys.argv[1]
out = {"file": os.path.basename(path), "bytes": os.path.getsize(path)}
try:
c = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
out["quick_check"] = c.execute("PRAGMA quick_check").fetchone()[0]
tables = [r[0] for r in c.execute(
"SELECT name FROM sqlite_master WHERE type='table'")]
out["tables"] = len(tables)
counts = {}
if "embedding_metadata" in tables:
counts["drawers"] = c.execute(
"SELECT count(*) FROM embedding_metadata "
"WHERE key='chroma:document'").fetchone()[0]
for t in ("facts", "triples", "entities"):
if t in tables:
counts[t] = c.execute(f"SELECT count(*) FROM {t}").fetchone()[0]
out["counts"] = counts
c.close()
except Exception as exc: # noqa: BLE001 — report, never mask
out["error"] = repr(exc)
print(json.dumps(out))
PY
}
# ── --verify: inspect an existing backup, then exit ──────────────────────────
if [ -n "$VERIFY" ]; then
[ -d "$VERIFY" ] || die "no such backup dir: $VERIFY"
log "verifying $VERIFY"
rc=0
for db in "$VERIFY"/chroma.sqlite3 "$VERIFY"/knowledge_graph.sqlite3; do
[ -f "$db" ] || { log " MISSING $(basename "$db")"; rc=1; continue; }
res=$(py_check "$db")
log " $res"
printf '%s' "$res" | grep -q '"quick_check": "ok"' || rc=1
done
if [ -f "$VERIFY/MANIFEST.json" ]; then
python3 - "$VERIFY/MANIFEST.json" <<'PY'
import json, sys
m = json.load(open(sys.argv[1]))
print(" manifest: created={created_utc} mode={mode} bytes={bytes_total} "
"mempalace={mempalace_version}".format(**m))
print(" index :", m.get("index_consistency", "?"))
PY
fi
[ "$rc" -eq 0 ] && log "VERIFY OK" || log "VERIFY FAILED"
exit "$rc"
fi
# ── preflight ───────────────────────────────────────────────────────────────
[ -d "$PALACE" ] || die "palace not found: $PALACE"
[ -f "$PALACE/chroma.sqlite3" ] || die "no chroma.sqlite3 in $PALACE — wrong dir?"
command -v rsync >/dev/null || die "rsync not found"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
MODE=$([ "$COLD" -eq 1 ] && echo cold || echo hot)
OUT="$DEST/mp-$STAMP-$MODE"
TMP="$OUT.incomplete"
log "palace : $PALACE ($(du -sh "$PALACE" | cut -f1))"
log "dest : $OUT"
log "mode : $MODE keep: $KEEP"
if [ "$DRY" -eq 1 ]; then
log "dry-run: would create $OUT"
log "dry-run: would copy chroma.sqlite3 + knowledge_graph.sqlite3 via sqlite backup API"
log "dry-run: would rsync the palace tree ($(find "$PALACE" -mindepth 1 -maxdepth 1 | wc -l) top-level entries, hidden included) minus SQLite"
[ "$COLD" -eq 1 ] && log "dry-run: would stop/start $UNIT"
exit 0
fi
mkdir -p "$TMP"
# ── cold mode: quiesce, with a trap so the server always comes back ─────────
STOPPED=0
restart_if_stopped() {
if [ "$STOPPED" -eq 1 ]; then
log "restarting $UNIT"
systemctl --user start "$UNIT" || log "WARNING: failed to start $UNIT — CHECK MANUALLY"
STOPPED=0
fi
}
trap restart_if_stopped EXIT INT TERM
if [ "$COLD" -eq 1 ]; then
log "stopping $UNIT (quiesce)"
systemctl --user stop "$UNIT"
STOPPED=1
# Give an in-flight mine a moment to release the writer lease.
sleep 2
fi
# ── the copy ────────────────────────────────────────────────────────────────
# Order matters: rsync the tree FIRST, then write the two SQLite files as
# consistent snapshots, so the databases are the freshest thing in the backup.
#
# rsync takes everything EXCEPT the SQLite files (and their -wal/-shm): the HNSW
# segment dirs, the JSON sidecars, and hidden dirs such as .mempalace/origin.json
# — a `"$PALACE"/*/` glob silently skips those, which is exactly the bug this
# structure removes. The payoff: a backup dir IS a palace image, so restore is a
# copy rather than a procedure.
log "rsyncing palace tree (excluding SQLite)"
rsync -a \
--exclude 'chroma.sqlite3' --exclude 'chroma.sqlite3-*' \
--exclude 'knowledge_graph.sqlite3' --exclude 'knowledge_graph.sqlite3-*' \
"$PALACE"/ "$TMP"/
# SQLite: online backup API. Consistent even mid-write, and WAL-aware — which
# matters because knowledge_graph.sqlite3 runs in WAL mode (-wal/-shm present).
for db in chroma.sqlite3 knowledge_graph.sqlite3; do
[ -f "$PALACE/$db" ] || { log "skip $db (absent)"; continue; }
log "sqlite-backup $db"
python3 - "$PALACE/$db" "$TMP/$db" <<'PY'
import sqlite3, sys
src_path, dst_path = sys.argv[1], sys.argv[2]
try:
src = sqlite3.connect(f"file:{src_path}?mode=ro", uri=True)
except sqlite3.OperationalError:
src = sqlite3.connect(src_path) # fall back if ro open is refused
dst = sqlite3.connect(dst_path)
with dst:
src.backup(dst) # atomic, consistent snapshot
dst.close(); src.close()
PY
done
restart_if_stopped
trap - EXIT INT TERM
# ── verify the COPY, not the original ───────────────────────────────────────
log "verifying copies"
CHROMA_JSON=$(py_check "$TMP/chroma.sqlite3")
log " $CHROMA_JSON"
printf '%s' "$CHROMA_JSON" | grep -q '"quick_check": "ok"' \
|| die "chroma.sqlite3 copy failed quick_check — backup NOT committed"
KG_JSON='{}'
if [ -f "$TMP/knowledge_graph.sqlite3" ]; then
KG_JSON=$(py_check "$TMP/knowledge_graph.sqlite3")
log " $KG_JSON"
printf '%s' "$KG_JSON" | grep -q '"quick_check": "ok"' \
|| die "knowledge_graph.sqlite3 copy failed quick_check — backup NOT committed"
fi
# ── manifest ────────────────────────────────────────────────────────────────
MEMPALACE_VER=$("$HOME/.local/bin/mempalace" --version 2>/dev/null | head -1 || echo unknown)
python3 - "$TMP/MANIFEST.json" "$STAMP" "$MODE" "$PALACE" "$MEMPALACE_VER" \
"$CHROMA_JSON" "$KG_JSON" <<'PY'
import json, os, socket, subprocess, sys
out, stamp, mode, palace, ver, chroma, kg = sys.argv[1:8]
def size(p):
t = 0
for root, _dirs, files in os.walk(p):
for f in files:
try: t += os.path.getsize(os.path.join(root, f))
except OSError: pass
return t
man = {
"created_utc": stamp,
"mode": mode,
"host": socket.gethostname(),
"palace_path": palace,
"mempalace_version": ver.strip(),
"bytes_total": size(os.path.dirname(out)),
"chroma": json.loads(chroma),
"knowledge_graph": json.loads(kg) if kg.strip() != "{}" else None,
"index_consistency": (
"byte-consistent (server quiesced)" if mode == "cold" else
"sqlite is authoritative; HNSW segments may lag a few writes — "
"restore with `mempalace repair --mode from-sqlite` if search misbehaves"
),
"restore": [
"# a backup dir IS a palace image — restore is a copy, not a procedure",
"systemctl --user stop mempalace-serve",
"mv ~/.mempalace/palace ~/.mempalace/palace.broken-$(date -u +%Y%m%dT%H%M%SZ)",
"mkdir -p ~/.mempalace/palace",
"rsync -a --exclude MANIFEST.json <backup>/ ~/.mempalace/palace/",
"systemctl --user start mempalace-serve",
"# hot backups only: if search misbehaves or the index count looks stale,",
"# rebuild the HNSW index from the authoritative sqlite -- this also",
"# re-creates collections with modern HNSW defaults (100/1000):",
"mempalace repair --mode from-sqlite --archive-existing --yes",
],
}
with open(out, "w") as fh:
json.dump(man, fh, indent=2)
print(json.dumps({k: man[k] for k in ("created_utc", "mode", "bytes_total")}))
PY
mv "$TMP" "$OUT"
log "committed $OUT ($(du -sh "$OUT" | cut -f1))"
# ── retention: only ever prune AFTER a committed, verified backup ───────────
if [ "$KEEP" -gt 0 ]; then
mapfile -t all < <(find "$DEST" -maxdepth 1 -type d -name 'mp-*' | sort)
n=${#all[@]}
if [ "$n" -gt "$KEEP" ]; then
for old in "${all[@]:0:$((n - KEEP))}"; do
log "pruning $old"
rm -rf "$old"
done
fi
log "retained $(find "$DEST" -maxdepth 1 -type d -name 'mp-*' | wc -l)/$KEEP"
fi
# Leftover .incomplete dirs mean a previous run died — surface, don't hide.
for stale in "$DEST"/mp-*.incomplete; do
[ -e "$stale" ] && log "NOTE: stale incomplete backup present: $stale"
done
log "done"
+332
View File
@@ -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
View File
@@ -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
+507 -38
View File
@@ -10,22 +10,98 @@
# 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 ~/.cache/mempalace-pi-session/<wing>/.
# 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, so re-runs are
# idempotent until session content actually changes.
# 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.
#
# Session filter: sessions with fewer than --min-messages *user+assistant*
# messages (default 3) are skipped to avoid filing single-prompt throwaways.
# 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
@@ -34,26 +110,80 @@
# mempalace-pi-session --help
#
# Exit codes:
# 0 success
# 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
# 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+)
# 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=3
MIN_MESSAGES=4
MIN_ASSISTANT_CHARS=1000
DRY_RUN=0
NO_REPAIR=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'
@@ -67,14 +197,43 @@ Options:
--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: 3)
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.
--no-repair Skip `mempalace repair` after mining
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:
@@ -96,17 +255,164 @@ Transcript shape per session:
Dedup:
- source_file = absolute staging path (deterministic per pi session UUID)
- Re-runs skip unchanged sessions. To force re-mining, delete the staging
dir: rm -rf ~/.cache/mempalace-pi-session/<wing>/
- 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:
Pi's extension ecosystem could, in principle, stream sessions into the
palace as they happen, but that requires a resident MCP connection during
every pi session. This wrapper is the batch, harness-agnostic alternative:
it reads the durable on-disk JSONL and mines it on a schedule.
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
@@ -115,48 +421,114 @@ while [[ $# -gt 0 ]]; do
--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 ;;
--no-repair) NO_REPAIR=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
if ! command -v mempalace >/dev/null 2>&1; then
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 ! [[ "$MIN_MESSAGES" =~ ^[0-9]+$ ]]; then
echo "error: --min-messages must be an integer" >&2
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 ──────────────────────────────────────────────────────
CACHE_ROOT="${XDG_CACHE_HOME:-$HOME/.cache}/mempalace-pi-session"
STAGE="$CACHE_ROOT/$WING"
# 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" <<'PY'
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 = sys.argv[1:6]
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)
@@ -170,10 +542,23 @@ if since:
sys.exit(1)
# ── Load palace's already-filed source_files (best-effort, read-only) ──
already_filed = set()
palace_path = os.environ.get("MEMPALACE_PATH", os.path.expanduser("~/.mempalace/palace"))
# 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 chroma_db.is_file():
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(
@@ -275,6 +660,7 @@ if session_filter:
exported = 0
skipped_short = 0
skipped_quiet = 0
skipped_malformed = 0
skipped_already_filed = 0
@@ -311,6 +697,7 @@ for path in paths:
continue
title = derive_title(entries, fallback=session_uuid[:8])
assistant_chars = 0
out_lines = []
out_lines.append({
"type": "user",
@@ -330,6 +717,7 @@ for path in paths:
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})
@@ -399,6 +787,17 @@ for path in paths:
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:
@@ -411,6 +810,10 @@ for path in paths:
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
@@ -418,9 +821,11 @@ for path in paths:
print(f" [{status}] {out_path.name} ({turn_count} turns)", file=sys.stderr)
print(f"EXPORTED {exported}")
print(f"ALREADY_FILED {skipped_already_filed}")
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
@@ -431,7 +836,8 @@ 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}"
to_file=$(( count - already_filed ))
# -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"
@@ -440,31 +846,94 @@ fi
echo ""
echo "Exported $count session(s) to $STAGE"
echo " $to_file new → will be filed on mine"
echo " $already_filed already filed → will be skipped (dedup by source_file)"
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
if [[ "$to_file" -eq 0 ]]; 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 ""
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 ! mempalace mine "$STAGE" --mode convos --wing "$WING" --agent "$AGENT"; then
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 [[ $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
+67 -11
View File
@@ -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,7 +100,7 @@ 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:
@@ -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:
@@ -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
+98 -7
View File
@@ -1,6 +1,23 @@
# contrib/ — automation recipes for `mempalace-session` and `mempalace-pi-session`
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.
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` (and/or `mempalace-pi-session --dry-run`) should list qualifying sessions. If that errors, fix the install before scheduling.
@@ -19,11 +36,80 @@ Pick **one scheduler** (systemd *or* launchd *or* cron). The opencode and pi job
| `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 |
| `systemd/mempalace-backup.{service,timer}` | **not a mining job** — hot palace backup (zero downtime) | daily 04:00 |
| `systemd/mempalace-backup-cold.{service,timer}` | **not a mining job** — cold palace backup (~5 s downtime, byte-consistent) | Sun 04:30 |
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.
@@ -171,17 +257,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
@@ -282,11 +373,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:**
@@ -318,7 +409,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.
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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,20 @@
[Unit]
Description=MemPalace backup (cold — quiesces the server for a byte-consistent copy)
Documentation=file:%h/mempalace-toolkit/docs/backup-and-recovery.md
ConditionPathExists=%h/.mempalace/palace
[Service]
Type=oneshot
# Cold mode stops mempalace-serve, copies the palace at rest (so the HNSW index
# is byte-consistent with the SQLite, not merely repairable from it), and starts
# it again. The restart is trapped on EXIT/INT/TERM inside the script, so an
# interrupted or failed run still brings the server back up.
#
# Measured downtime on a 223 MB palace: ~5 seconds.
#
# NOTE: mempalace-serve.service ships a drop-in with Restart=always. That is
# correct for crash recovery but means an explicit `systemctl --user stop` is the
# only way to keep it down for the duration of the copy — do not expect a plain
# SIGTERM to hold it.
ExecStart=%h/.local/bin/mempalace-backup --cold --keep 7
Nice=10
@@ -0,0 +1,12 @@
[Unit]
Description=Weekly cold MemPalace backup
Documentation=file:%h/mempalace-toolkit/docs/backup-and-recovery.md
[Timer]
# Half an hour after the daily hot backup, so the two never overlap on the
# palace lock even if the hot run is unusually slow.
OnCalendar=Sun 04:30:00
Persistent=true
[Install]
WantedBy=timers.target
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=MemPalace backup (hot — zero downtime, SQLite online-backup API)
Documentation=file:%h/mempalace-toolkit/docs/backup-and-recovery.md
# Better a skipped run than a run that creates an empty palace somewhere and
# then dutifully backs it up.
ConditionPathExists=%h/.mempalace/palace
[Service]
Type=oneshot
# Hot mode never stops mempalace-serve. Both SQLite files are copied through the
# online-backup API (transactionally consistent, WAL-aware); the HNSW index
# segments are copied live and may lag a few records. That is acceptable because
# SQLite is authoritative — see docs/backup-and-recovery.md §3.
ExecStart=%h/.local/bin/mempalace-backup --keep 7
# Backups are not urgent; the palace server and any running mine are. Yielding
# CPU and I/O keeps a 4-second backup from lengthening someone's interactive
# search.
Nice=10
IOSchedulingClass=idle
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Daily hot MemPalace backup
Documentation=file:%h/mempalace-toolkit/docs/backup-and-recovery.md
[Timer]
OnCalendar=*-*-* 04:00:00
# If the host was powered off at 04:00, run shortly after the next boot instead
# of silently skipping that day. Without this, a laptop or an occasionally-off
# server quietly accumulates days with no backup.
Persistent=true
# Avoid colliding with other maintenance that also picked the top of the hour.
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
+67
View File
@@ -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.
+1 -1
View File
@@ -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
+275
View File
@@ -0,0 +1,275 @@
# Backing up and recovering a palace
A palace is not one file, so `cp -r` is not a backup. This document explains what
a palace is made of, why the obvious approaches silently corrupt it, and how
`bin/mempalace-backup` avoids that — plus how to actually restore one and prove
the restore worked.
Host-specific details (which machine is primary, addresses, users, offsite
targets) deliberately live outside this repo. Substitute `<PRIMARY_HOST>` and
`<USER>` throughout.
## 1. What is actually in a palace
Run `ls -a` on a palace directory and you get something like this — sizes are
from a real ~25k-drawer palace, for a sense of proportion:
| Path | Size | What it is |
|---|---|---|
| `chroma.sqlite3` | 167 MB | **The authoritative store.** Drawers, chunks, metadata, embeddings. |
| `<uuid>/` (one per segment) | 50 MB, 2.8 MB | chromadb HNSW index segments (`data_level0.bin`, `index_metadata.pickle`). Derived data. |
| `knowledge_graph.sqlite3` | 64 KB | KG triples + entities. **Usually in WAL mode** — see §2. |
| `knowledge_graph.sqlite3-wal` / `-shm` | varies | Live write-ahead log. Their existence is the whole problem. |
| `hallways.json` | 10.8 MB | Within-wing co-occurrence links, rebuilt by `mempalace hallways`. |
| `known_entities.json`, `mempalace_embedder.json` | < 1 KB | Entity list; **which embedder model this palace was built with**. |
| `.mempalace/origin.json` | small | Provenance. **Hidden** — see the glob trap in §3. |
| `.collection_type_fixed`, `.blob_seq_ids_migrated` | 0 B | Migration marker files. Presence is the signal. |
Two consequences worth internalising:
- **The SQLite files are the truth; the index segments are derived.** If the
index is stale or damaged you can rebuild it from SQLite. The reverse is not
true. This asymmetry is what makes a zero-downtime backup possible at all.
- **`mempalace_embedder.json` is not optional.** Restoring drawers next to a
*different* embedder model gives you a palace whose stored vectors and future
queries live in different spaces — search silently degrades rather than
failing. Back it up, and check it after a restore.
## 2. Why `cp` is unsafe, specifically
SQLite in WAL mode keeps recent commits in `-wal` and shared state in `-shm`.
Copying those three files with `cp` gives you no guarantee they are from the same
instant: the copier can read `chroma.sqlite3` before a checkpoint and `-wal`
after it. The result usually *opens* — which is the dangerous part — and then
misbehaves later, or fails `PRAGMA integrity_check` under load.
`rsync` has exactly the same problem: it is not a snapshot, it is a sequence of
reads.
The supported approach is SQLite's **online backup API**, which takes a
transactionally consistent copy of a live database while writers continue:
```python
import sqlite3
src = sqlite3.connect(f"file:{src_path}?mode=ro", uri=True)
dst = sqlite3.connect(dst_path)
with dst:
src.backup(dst) # WAL-aware, consistent, no writer downtime
```
Two practical notes:
- **`VACUUM INTO` is a fine alternative in principle**, but it needs either the
`sqlite3` CLI or a Python build new enough for it. Do not assume the CLI is
present: a stock Ubuntu server often has the Python `sqlite3` *module* and no
`sqlite3` *binary*. Check with `command -v sqlite3` before writing tooling
that depends on it.
- Open the source with `mode=ro` so a backup can never be the thing that
creates a `-wal` file in a directory you meant only to read.
## 3. Two backup modes, and the honest tradeoff
`bin/mempalace-backup` implements both. Neither is strictly better.
### hot (default) — zero downtime
1. `rsync` the whole palace tree **excluding** the SQLite files.
2. Write both SQLite files via the online-backup API.
The server never stops. The index segments are copied while they may be being
written, so in principle they can lag the SQLite by a few records. That is
acceptable *because the SQLite is authoritative*: if search misbehaves after a
restore, rebuild the index (§5).
Measured on a 223 MB palace: **~4 s**.
### cold (`--cold`) — byte-consistent
1. Stop the server (`systemctl --user stop mempalace-serve`).
2. Copy everything, including the index, at rest.
3. Restart the server.
The restart is in a `trap … EXIT INT TERM`, so an interruption mid-copy still
brings the server back. Measured downtime: **~5 s**.
Recommended schedule: **hot daily, cold weekly**. The cold copy is your
byte-exact reference; the hot copies bound your data loss between them.
### The glob trap that cost two rewrites
Two bugs are worth naming because both produce a backup that looks fine:
- **`"$PALACE"/*/` skips hidden directories.** `.mempalace/origin.json` is
silently omitted, so the restored palace loses its provenance. Shell globs do
not match dotfiles without `dotglob`/`GLOBIGNORE` fiddling.
- **Per-directory rsync collides identically named files.** Every HNSW segment
contains a `data_level0.bin`. Copying segment dirs into one destination
merges them and the last write wins — a corrupt index that still loads.
The fix for both is to treat the palace as **one tree**: a single
`rsync -a --exclude` of the SQLite files, so a backup directory is a faithful
*image* of the palace. That makes restore a copy rather than a procedure.
## 4. Verify the copy, not the original
A backup you have not read back is a hypothesis. After copying, and **before**
committing the backup or pruning old ones, the tool runs against the *copy*:
- `PRAGMA quick_check` on both databases
- row counts (`drawers`, `triples`, `entities`)
- a `MANIFEST.json` recording counts, sizes, mode, tool version, and the
index-consistency caveat for hot backups
Sequencing matters as much as the checks:
1. write into `<dest>.incomplete/`
2. verify
3. `mv` into place (atomic commit)
4. **only now** prune old backups to the retention limit
Retention pruning that runs before verification can delete your last good
backup on the night the new one is broken. A leftover `.incomplete` directory is
a visible symptom, not silent data loss, so the tool reports stale ones.
## 5. Restoring
Because a backup is a palace image, restore is a copy:
```bash
systemctl --user stop mempalace-serve
mv ~/.mempalace/palace ~/.mempalace/palace.broken-$(date -u +%Y%m%dT%H%M%SZ)
mkdir -p ~/.mempalace/palace
rsync -a --exclude MANIFEST.json <backup-dir>/ ~/.mempalace/palace/
systemctl --user start mempalace-serve
```
Keep the old directory until the restore is proven. It costs disk and buys you a
second attempt.
**If the restore came from a hot backup and search behaves oddly**, rebuild the
derived index from the authoritative SQLite:
```bash
mempalace repair --mode from-sqlite
```
Note that a full `repair --mode from-sqlite --archive-existing` re-creates the
chromadb collections, which means new collections pick up **current** HNSW
tuning defaults. On palaces created by older versions this is also the only way
to escape legacy `hnsw:batch_size=2 / hnsw:sync_threshold=2` settings, which sync
the index every two records. Check yours with:
```sql
SELECT c.name, k.key, k.str_value FROM collections c
JOIN collection_metadata k ON k.collection_id = c.id
WHERE k.key LIKE 'hnsw%';
```
## 6. Prove the restore, don't assume it
"The files copied" is not a tested restore. Restore to a throwaway directory and
make the palace *answer questions*:
```bash
rsync -a --exclude MANIFEST.json <backup-dir>/ /tmp/restore-test/
export MEMPALACE_PALACE_PATH=/tmp/restore-test
mempalace status # drawer count — compare to the source
mempalace search "some phrase you know is in there"
```
`status` proves the schema and metadata survived. **`search` is the stronger
test**: it exercises the HNSW segments, so it is what tells you whether a
*hot* backup's index came across usable. On the reference run above it returned
correctly ranked results (`cosine_sim=0.398 bm25=2.992`) from a zero-downtime
backup — the theoretical index skew did not materialise.
Do this at least once when you set backups up, and again after any version
upgrade that touches storage.
## 7. Scheduling that survives a reboot
Use **systemd user timers** with lingering enabled, so backups run without
anyone logging in:
```bash
loginctl enable-linger <USER> # required, or user units die at logout
systemctl --user enable --now mempalace-backup.timer mempalace-backup-cold.timer
systemctl --user list-timers 'mempalace*'
```
Units are in [`contrib/systemd/`](../contrib/systemd/). Two settings earn their
keep:
- **`Persistent=true`** — a timer whose window was missed (host powered off)
runs shortly after boot instead of skipping the day.
- **`RandomizedDelaySec`** — avoids a thundering herd if other maintenance
shares the hour.
`mempalace-serve` is itself a **user** unit in the reference deployment. That is
worth stating explicitly because `systemctl status mempalace-serve` as root
reports "not found" and invites the conclusion that the server is not installed.
Always `systemctl --user`.
### The interpreter is part of your durability story
If the tool is installed against the **system** Python (`/usr/bin/python3`), then
a distribution release upgrade — which can remove or replace that interpreter —
breaks the server and its backups together, at the moment you least want it.
Install against a **managed** interpreter instead:
```bash
uv python install 3.12
uv tool install --force --python "$HOME/.local/bin/python3.12" 'mempalace==<version>'
systemctl --user restart mempalace-serve
```
Now an OS upgrade cannot take the interpreter away. Two gotchas:
- `uv` is typically in `~/.local/bin`, which is **not** on a non-interactive
ssh PATH. Scripted upgrades must `export PATH="$HOME/.local/bin:$PATH"`.
- `uv python list` prints `$HOME`-relative paths, so a regex anchored on a
leading `/` matches inside `.local/share` and yields a bogus absolute path.
Resolve the shim and assert on it:
`readlink -f ~/.local/bin/python3.12 | grep share/uv/python`.
**Restart the server after any upgrade.** Recent versions refuse mutating tools
when the served library drifts from what is installed on disk, and
`mempalace_reconnect` cannot clear that — it reopens the database but cannot
reload Python modules.
## 8. Sizing and offsite
At ~225 MB per copy, 7 hot + 7 cold is ~3 GB — trivial against any modern disk,
so prefer generous retention over clever incremental schemes. If space is
genuinely tight, `zstd -T0` on the SQLite files compresses well, at the cost of
making a backup no longer a directly-mountable palace image.
**Local backups protect against corruption and mistakes, not against losing the
host.** Until a copy leaves the machine, a single disk or a single `rm` ends the
palace. Whatever offsite target you choose (object store, NAS, another host),
two rules matter more than the choice:
- **Put a copy of the restore procedure with the backups.** Documentation that
lives only inside the thing you are restoring is not available at the moment
you need it.
- **Verify the offsite copy by restoring from it**, not by checking that the
upload exited 0.
## 9. Usage
```
mempalace-backup [--cold] [--dest DIR] [--keep N] [--palace DIR] [--dry-run]
mempalace-backup --verify <backup-dir>
```
| Flag | Meaning |
|---|---|
| `--cold` | Quiesce the server for a byte-consistent copy (default: hot) |
| `--dest DIR` | Backup root (default `~/backups/mempalace`) |
| `--keep N` | Retention count, pruned only after a verified commit (default 7) |
| `--palace DIR` | Palace to back up (default `~/.mempalace/palace`) |
| `--verify DIR` | Re-verify an existing backup and print its manifest |
| `--dry-run` | Report what would happen, write nothing |
Backups are named `mp-<UTC timestamp>-<hot|cold>`, so they sort
chronologically and their mode is visible without opening the manifest.
+463
View File
@@ -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).
+950
View File
@@ -0,0 +1,950 @@
# RFC 001 — Global palace with local fallback (`mempalace-edge`)
| | |
| --- | --- |
| **Status** | **Phases 01 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 02 + 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 1223 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)
+224
View File
@@ -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` |
+21
View File
@@ -0,0 +1,21 @@
# (moved) primary-host runbook
This file used to contain the deployment runbook for one specific primary host —
its hostname, addresses, user, service wiring and rollback steps.
**That content now lives in a private repository**, because a host inventory is
operator data for one deployment, not part of the toolkit. This repository is
public and keeps only host-agnostic *mechanism*.
What lives where:
| Content | Home |
|---|---|
| How to expose a palace over HTTP, and the Host/Origin pin | `docs/phase-1-exposure-runbook.md` (here) |
| Why a palace needs a special backup, and how to restore one | `docs/backup-and-recovery.md` (here) |
| Unit/timer/plist templates | `contrib/` (here) |
| Which machine is primary, its addresses, users, tunnels, offsite target | private fleet repository |
| Per-host feeder device names and schedules | private fleet repository |
If you are looking for the mechanism, the two runbooks above are the same
procedures with `<PRIMARY_HOST>` and `<USER>` in place of one site's specifics.
+442
View File
@@ -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.
+933
View File
@@ -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
View File
@@ -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"
+77
View File
@@ -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"