Files
pi-devbox/rootfs/usr/local/share/pi-devbox/skills/mempalace/SKILL.md
T
joakimp f561acc89a
Lint / hadolint (push) Successful in 9s
Lint / actionlint (push) Failing after 31m52s
skills: refresh vendored mempalace snapshot a12fe5e -> e9e09d9, re-pin the canary
Folded into v1.8.13 at zero marginal cost: the snapshot is hashed into
base_tag, but Dockerfile.base already changed this release, so the ~67 min base
rebuild was already being paid. vendor-mempalace-skill.sh --check reported exit
0 (stale-but-truthful) beforehand, so skipping was sanctioned -- this is the
deliberate call the release checklist asks for. Upstream content: the bare
project-name wing convention and the <harness>@<device> added_by rule, both
downstream of the attribution defect measured on this device 2026-09-06.

The canary re-pin matters more than the refresh. Its old pair ("Provenance is
stamped for you" present / "Attribute what you file yourself" absent) still
PASSED against the new snapshot, so leaving it would have yielded a canary
green on both old and new bytes -- blind to exactly the refresh it exists to
witness, the same false-green family as the pre-v1.8.5 canary. New pair chosen
by measuring direction against both files rather than reading the diff
("Diaries self-heal; plain drawers do not" new=1/old=0; "Agent diaries live in"
new=0/old=1), then tested two-sided: PASS on refreshed bytes, FAIL on the old
bytes recovered from git.

Gates after the change: smoke-test.sh parses, vendor --check exit 0,
check-base-hash exit 0.
2026-09-06 22:31:47 +02:00

43 KiB
Raw Blame History

name, description
name description
mempalace MemPalace agent memory protocol. Use on every session to maintain continuity across conversations — search before answering about past work, write diary entries before session ends, and mine new projects into the palace. Load this skill at session start.

MemPalace Agent Memory Protocol

Overview

MemPalace gives you persistent memory across sessions via an MCP server. It stores project knowledge (mined from files), conversation summaries (diary entries), and entity relationships (knowledge graph). Without this protocol, you have tools but no habits — and memory without habits is just storage.

Core principle: Storage is not memory. Storage + protocol = memory.

When to Load This Skill

  • At the start of every session (proactively, before the user asks)
  • When the user mentions past conversations, decisions, or work
  • When working on a new project or repository for the first time
  • When the user asks about people, projects, or relationships

Session Lifecycle

Phase 1: Wake Up (session start)

Run these immediately when a session begins, before responding to the user:

  1. Load palace overview:

    mempalace_status
    

    This returns wing/room counts, the AAAK spec, and the memory protocol reminder.

  2. Read your recent diary:

    mempalace_diary_read(agent_name="<your_agent_name>", last_n=5)
    

    Scan for context about recent sessions — what was worked on, what matters, what's pending.

  3. Check the knowledge graph for the user or active project if relevant:

    mempalace_kg_query(entity="<project_or_person>")
    
  4. Check your mailbox. Just run it — an empty result is a fine answer and costs one call. Do not try to decide first whether coordination "applies to you"; that test is what used to be wrong here (see Cross-Machine Coordination below):

    mempalace_event_list(to_agent="<harness>@<device>", status="open")
    

    This is a candidate list, not a to-do list — status never changes after an event is written, so finished asks keep matching. Subtract the ones you have already answered using the rule in What you actually owe, below. Another machine may have asked you something, or corrected something you are about to rely on. This costs one call and is the only way you will find out: nothing pushes an event into your session unless your bridge delivers it for you, and if it does you will already have seen it before reading this.

Do NOT announce this to the user. Just do it silently to orient yourself.

Temporal grounding — compute time deltas, don't guess

Diary entries and drawers carry real timestamps (timestamp, created_at). Before describing when something happened — "yesterday", "earlier today", "last week", "a while back" — establish the current date/time first and compute the delta against the actual timestamp. Get "now" from the injected session date or by running date in a shell; never infer it.

A container recreate or a fresh session is NOT a day boundary. A devbox container (pi-devbox or opencode-devbox) is frequently restarted — often several times within the same day — and each restart begins a new session with a fresh wake-up. Do not reason "new session ⇒ last session was yesterday": two diary entries 90 minutes apart can straddle a container recreate. The only authoritative clock is the timestamp on the memory, not the session/container boundary.

Practical rule: prefer explicit, checkable phrasing — e.g. "earlier today, ~8h ago (both 2026-06-25)" — over a vague relative term. If you catch yourself about to write "yesterday" / "last week", subtract now − entry.timestamp and state the computed result. (Remember timestamps may be UTC while the wall clock is local — reconcile the offset before computing the delta.) Note too that session feeders can lag up to a week (see Multi-harness palace), so a recent absence in wing_conversations is not proof nothing happened.

Phase 2: Active Session (during work)

Search Before You Speak

Before answering questions about past work, decisions, people, or projects:

mempalace_search(query="<keywords>", wing="<project>")

Never guess about facts that might be in the palace. Wrong is worse than slow. Say "let me check" and query.

Search Before You Probe

The rule above covers questions. This one covers actions — and it is the one that actually gets skipped, because mid-task the impulse is to go and look rather than to remember. The palace is a fleet record: another machine's agent has usually already paid the cost of discovering how this environment is wired, and its notes include the corrections that came afterwards, which a fresh probe cannot show you.

Before you SSH somewhere to find out how it is set up, enumerate infrastructure, or derive a deployment — search. Concrete triggers, all meaning search first:

  • about to run ssh <host> …, docker ps, systemctl list-units, ip addr to discover how something is deployed or connected
  • about to establish topology: which hosts/runners/services exist, where they live, which of them can reach which
  • about to conclude "this isn't documented anywhere" or "there's no way to know"
  • about to assert an environment fact you learned earlier in this same session

That last trigger is the sharp edge. A compacted session summary is lossy by design, and a belief you formed 40 turns ago may already be retracted in the palace by another machine. Trusting your own context over the shared record is how a withdrawn claim gets re-published as fact.

Search broadly before narrowing — fleet knowledge often sits in another machine's wing, or inside a mined conversation, not where you would file it yourself:

mempalace_search(query="<topic> <host> <mechanism>")     # no wing filter first
mempalace_search(query="…", wing="<likely-wing>")        # then narrow

Two or three searches cost seconds. Re-deriving infrastructure costs minutes and can be wrong: a probe shows one host's present state, while the palace records intent, history, and what was already disproved.

Worked example (real, 2026-08-25). An agent evaluating whether to add an ARM CI runner probed hosts directly instead of searching. It concluded "the runner lives on synlig" — there are four — and that "synlig is on the home LAN" — it is an OpenStack VM with a public floating IP that cannot reach the home LAN at all. Both facts were already in the palace, the second one as an explicit retraction of the very same mistake made weeks earlier. The palace also held the runner labels and the deliberate capacity: 1 setting, which the probe never revealed. Cost: a wrong recommendation written into the palace twice, then corrected twice.

A search that comes back empty is not an answer — least of all about recent work. Semantic search is weakest exactly where the fleet record is freshest: a drawer filed minutes ago is unranked against a keyword-shaped query, and the drawer you most need is by construction the newest one, because the other machine files its release, handoff and correction drawers at the end of its session. So a single miss proves nothing. If the work is 0-2 days old and the first search looks stale or empty, enumerate before concluding:

mempalace_list_drawers(wing="<wing>", since="<today>")   # or room=, or no filter
mempalace_diary_read(agent_name="<you>", wing="<wing>")  # the other machine's handoff

Enumeration is exact where embeddings are probabilistic. Treat "I searched and found nothing" as a hypothesis you have not yet tested, and never as licence to go probing.

Worked example (real, 2026-08-25, same fleet as above). An agent asked to orient on an in-flight release did search first — "v1.8.6 release run 579 Docker Hub verification" — and got back only v1.6.4 / v0.78.0 era hits, because the release drawer it needed was 58 seconds old. It accepted the miss and went off to probe Docker Hub and the Gitea API. The user had to prompt "maybe there is a note in mempalace"; list_drawers(wing="pi-devbox", since=<today>) then returned the drawer immediately, along with the diary entry naming the exact open item. The rule above was present and correct in this very file at the time — the failure was not knowing to retry differently after a bad first hit.

Mine New Projects

When working on a new codebase for the first time:

  1. Check if it's already mined:

    mempalace_list_wings
    
  2. Decide what to mine — docs first, code never (by default).

    The palace is for context and intent, not code recall. Code is better read from the working tree via Read/Grep/glob — always authoritative, never stale. Embedding source code produces thousands of low-signal drawers (e.g. def __init__(self, ...) across every class) that pollute search for years.

    Mine by default:

    • *.md, *.rst, *.txt — docs, READMEs, CHANGELOGs, architecture notes
    • AGENTS.md, CLAUDE.md, CONTRIBUTING.md, design/decision docs — highest signal per byte
    • *.sh, Dockerfile, Makefile, entrypoints — small, intent-bearing
    • *.yml, *.yaml, *.toml, selective *.json (docker-compose, pyproject, mkdocs.yml, CI workflows) — skip lockfiles

    Do NOT mine by default:

    • *.py, *.ts, *.tsx, *.js, *.go, *.rs, *.java, *.cpp, *.c, *.rb — raw source code
    • Test files, fixtures, generated code
    • node_modules/, .venv/, __pycache__/, .mypy_cache/, .pytest_cache/, .ruff_cache/ (the miner respects .gitignore but double-check)

    Exception: if a code file is the documentation (e.g. a heavily-commented reference script, or a protocol definition), file it manually via mempalace_add_drawer.

  3. Before mining, inspect the repo to estimate drawer count:

    # Quick audit — what will actually get mined?
    find <dir> -type f \
      -not -path '*/.git/*' -not -path '*/node_modules/*' \
      -not -path '*/.venv/*' -not -path '*/__pycache__/*' \
      \( -name '*.md' -o -name '*.sh' -o -name '*.yml' -o -name '*.yaml' \
         -o -name '*.toml' -o -name 'Dockerfile*' -o -name 'Makefile' \) | wc -l
    

    A docs-heavy repo should produce ~5–10 drawers per file. If a mine produces >15 drawers/file on average, code leaked in — investigate.

  4. Run the mine:

    mempalace init --yes <directory>
    mempalace mine <directory> --agent <your_agent_name>
    

    The miner currently lacks a --docs-only or --exclude-ext flag (as of v3.3.3). Until it does, either:

    • (a) Add a mempalace.yaml at the repo root with explicit include globs, OR
    • (b) Mine everything, then surgically remove code-sourced drawers via SQL on ~/.mempalace/palace/chroma.sqlite3 (delete by embedding_metadata.source_file LIKE '%.py'), followed by mempalace repair --yes.
  5. If the CLI miner misses a file you do want (e.g., .zsh, an undocumented extension), file it manually:

    mempalace_add_drawer(wing="<project>", room="<aspect>", content="<verbatim content>", source_file="<path>")
    
  6. After mining, reconnect to pick up the new embeddings:

    mempalace_reconnect
    

    If search errors occur after mining ("Error finding id"), repair the index:

    mempalace repair --yes
    

Track Facts in the Knowledge Graph

When you learn new facts about people, projects, or relationships:

mempalace_kg_add(subject="ProjectX", predicate="uses", object="PostgreSQL")
mempalace_kg_add(subject="Alice", predicate="owns", object="ProjectX", valid_from="2026-01-15")

When facts change (ended, no longer true):

mempalace_kg_invalidate(subject="Alice", predicate="works_at", object="OldCorp", ended="2026-03-01")

Cross-Reference with Tunnels

When content in one project relates to another, create a tunnel:

mempalace_create_tunnel(
  source_wing="project_api", source_room="endpoints",
  target_wing="project_db", target_room="schema",
  label="API endpoints map to these DB tables"
)

Feeding opencode session history (opencode + mempalace-toolkit only)

MemPalace has no upstream integration with opencode as of v3.3.3 — hooks_cli.py only supports claude-code and codex harnesses. Opencode persists every turn in a local SQLite DB at ~/.local/share/opencode/opencode.db, but nothing moves that data into the palace automatically.

On a machine with opencode + the mempalace-toolkit installed, session history is fed into wing_conversations via mempalace-session — either manually, or on a weekly systemd user timer / cron schedule shipped in mempalace-toolkit/contrib/. If this is missing, opencode conversations exist only in the local SQLite DB and are invisible to mempalace_search.

How to tell if it's set up:

mempalace_list_wings

If wing_conversations exists and has a drawer count comparable to the user's opencode session count, session feeding is working. If it's empty or suspiciously small, suggest:

  1. Check if the toolkit is installed: which mempalace-session.
  2. If installed, suggest running mempalace-session --dry-run to preview and mempalace-session to file.
  3. If not installed, point the user at gitea.jordbo.se/joakimp/mempalace-toolkit for setup.

Don't try to paper over the gap by dumping turn-level content into the palace manually via mempalace_add_drawer — that reinvents what mempalace-session does with normalization and dedup. Use the tool.

Full routine (triggers, cadence, automation) is in the opencode-mempalace-bridge skill and the toolkit's ARCHITECTURE.md §5. The two skills pair: this one (mempalace) covers using the palace; that one (opencode-mempalace-bridge) covers feeding it from opencode.

Phase 3: Wind Down (session end)

Always write a diary entry before the session ends. This is the most important habit.

mempalace_diary_write(
  agent_name="<your_agent_name>",
  entry="<AAAK compressed summary>",
  topic="session-summary"
)

Why still write diaries when sessions may be mined automatically?

On machines running opencode + mempalace-toolkit, every session is mined into wing_conversations on a weekly (or user-defined) schedule. A common and incorrect conclusion: "since every turn is captured automatically, writing a diary entry is redundant." It isn't.

Session mining captures what was said (every turn, verbatim). A diary captures what the session meant — editorial judgment by the agent who lived it:

  • Lessons learned, patterns noticed, pending items rolled forward
  • Meta-observations that were never said aloud during the session
  • Aggregate counts (commits shipped, bugs fixed, hours spent)
  • A compressed, recency-scannable summary for the next agent's wake-up

Mining raw turns cannot surface these because the words don't exist verbatim — they're the agent's reflection at wind-down. Think of the split as release notes (diary) vs. git log with diffs (session mine): a repo keeps both because they answer different questions. So does the palace.

Practical rule: automated mining does not replace Phase 3. Both systems cover each other's failure modes — a skipped diary is recovered from the raw turns; a missed mine is recovered from the diary summary. For the full treatment (comparison table, retrieval patterns, token economics), see mempalace-toolkit/ARCHITECTURE.md §5 → "Diary vs session mine: why keep both?".

AAAK Diary Format

Write diary entries in compressed AAAK format for efficiency. Structure:

SESSION:<date>|<what.you.worked.on>|
TASKS:
1.<task.description>→<outcome>|
2.<task.description>→<outcome>|
DISCOVERED:<unexpected.findings>|
ENTITIES:<people.or.projects.encountered>|
<importance: one to five stars>

Example:

SESSION:2026-04-28|api.refactor+db.migration|
TASKS:
1.refactored.auth.endpoints→split.into.3.modules|
2.added.user.roles.migration→postgres.enum.type|
DISCOVERED:legacy.session.table.unused.since.v2|
ENTITIES:ProjectX;Alice(reviewer)|
***

Rules:

  • Use dots instead of spaces within phrases
  • Use pipes as field separators
  • Use arrows for cause/effect or transitions
  • Stars indicate session importance (one to five)
  • Keep it tight — a future agent should get the gist in seconds

What to Capture

Prioritize recording:

  • Decisions made and their rationale
  • Discoveries — things that surprised you or that a future session needs to know
  • Unfinished work — what's pending, what was deferred
  • User preferences observed during the session
  • Entities encountered — people, projects, tools, services

Phase 4: Fact Updates

If facts changed during the session, update the knowledge graph before writing the diary:

mempalace_kg_invalidate(subject="...", predicate="...", object="...", ended="<today>")
mempalace_kg_add(subject="...", predicate="...", object="...", valid_from="<today>")

Cross-Machine Coordination — the logstream

The palace stores what you know. The logstream (mempalace_event_*, mempalace_artifact_*) carries what you want to say to another agent — delegation, review, patch handoff, retraction. It is the only channel on which another machine can reach you.

Does this apply to you at all? Do not use mempalace_mesh_peers to decide. It answers a different question than it appears to. A shared palace can be hub-and-spoke — many machines as thin clients of one central replica — and then mesh_peers reports peers: [] because there are no peer replicas, even while four machines are actively writing to the same log. Measured on this fleet: peers: [], one replica authoring every event from every machine. An earlier version of this section told you to read mesh_peers and skip the mailbox when it came back empty, which disabled the mailbox on precisely the fleet it was written for.

The honest discriminators, cheapest first: just run the mailbox query (empty is a fine answer); check whether MEMPALACE_REMOTE_URL is set, which is what actually selects a shared palace; or look for any event whose from_agent is not you. On a solitary palace the event tools still work — you are writing to yourself and your mailbox stays empty. That is not a fault to debug.

It is a durable log, not a bus — nobody is "listening". Events are appended and persist; there is no subscription, no delivery window, and nothing is lost by being offline when one is written. A message waits indefinitely for you, and your reply waits just as patiently for a sender who has since gone away. Machines in a fleet are rarely awake at the same time, which is exactly why this is a log and not a chat.

Agent name is the only identity the log has. Depending on deployment, every client may share one origin_replica — on the fleet this skill was written for, all machines are thin MCP clients of a single central replica, so origin_replica is identical for every event and cannot tell two machines apart. from_agent / to_agent carry the whole distinction, which is why the <harness>@<device> stamping in Provenance is stamped for you is load-bearing here and not mere tidiness.

Reading your mailbox

mempalace_event_list(to_agent="<harness>@<device>", status="open")
  • to_agent=<you> also matches * broadcasts, so one call covers both. No second query needed.
  • status="open" narrows the mailbox to what a sender said was an ask at the time of writing — that is all it can do. It is a good first filter (on a real stream it cut 5 events to 2), but it is not a list of what you owe, and it never shrinks as you work. Treating it as owed-ness is the mistake this section previously made: an earlier draft cited "5 unfiltered, exactly 1 filtered — the one that needed a reply" as proof the filter tracked obligation. It did not. That single result was an event which had already been acked half an hour earlier; the filter looked decisive only because the stream happened to contain one directed open event. Unfiltered mailboxes train you to ignore them — and so does a filter that keeps showing you finished work.
  • To resume where you left off, use since_event_id, never since_created_at. A timestamp cursor permanently skips an event that synced in late — it is a time window ("what happened today"), not a cursor.
  • Read metadata before acting: senders put the load-bearing specifics there (which host verified what, which run failed, what a change retracts).

The ack contract — the sender declares whether a reply is owed

An obligation you never agreed to is noise, so the sender states it:

Sender writes Means Recipient owes
to_agent="<specific agent>" + status="open" an ask an ack or a reply (the event itself keeps matching forever — see below)
to_agent="*" (any status) broadcast FYI nothing
any other status (ready, applied, blocked, …) a statement of fact nothing

That table says what you owe. Delivery is stricter, and the difference bites: the mailbox is an obligation channel, not a news channel. Mailbox candidates are drawn with status="open", so an event carrying any terminal status (applied, superseded, failed, blocked) is never a candidate — whoever it is addressed to. A task.reply written to a named machine to share a finding is delivered to nobody, ever, and neither is any event_ack. It sits in the log until somebody reads the log.

So the most natural inter-machine message — "here is something you should know" — is exactly the shape that gets no delivery. Pick deliberately:

You want the peer to… Write
do something, and you need it tracked until done directed status="open" ask, with a correlation_id
know something, no response needed terminal-status event plus a drawer — the drawer is what actually reaches them, via search

What does not work is a terminal report plus an expectation of attention. Measured 2026-08-26: a detailed report addressed to pi@<peer> with status="applied" went unread for two and a half hours until the operator quoted the event id by hand, with the mailbox working correctly the whole time. Full mechanism in the toolkit's docs/rfc-003-coordination-log.md §7.12.

One more timing fact, because it looks like negligence and is not: a delivered ask is queued into the agent's next turn (deliverAs: "steer", deliberately no triggerTurn), and the poll fires when the agent is idle. Between delivery and the next turn no inference runs, so a human starting a turn is the trigger (§7.11). An agent that "has not reacted" has usually not been running.

Ack with mempalace_event_ack(event_id=…, from_agent="<you>", status=…). It appends a new event and never mutates the original; the correlation id is copied for you, and metadata.ack_of is set to the event you answered.

Claiming, and what it does not do. status="claimed" announces that you have picked work up. Nothing requires it — a directed open ask owes "an ack or a reply", and finishing the work is a complete answer. Do it anyway when the work is long or the machine is unreliable, because it is the only thing that later distinguishes nobody started this from someone started and their container died mid-task. Be clear about its limits, both of which follow from candidacy requiring exactly status="open":

  • It does not notify the requester. claimed is not open, so a claim is no more deliverable than a finished report is (see the delivery table above). Its reader is whoever pulls the log.
  • It does not quiet your own mailbox. The ask stays owed until a terminal event of yours joins it, so a claimed-then-silent thread keeps resurfacing — correctly.

Prefer a prompt terminal reply over a claim plus a long silence; claim in addition, when the gap between pickup and finish is where a machine might die.

What you actually owe — derive it, do not read it off status

The log is append-only and status is written once, so it is an honest statement about an item at the moment it was written and nothing more. It is not mutable state, and asking it to carry mutable state is what breaks: acking appends a new event and changes nothing about the old one, so a directed open event matches your mailbox query forever, answered or not. Nothing is ever "dismissed" — which also means a deferred ask cannot be accidentally lost, only that you must compute what is outstanding:

candidates = mempalace_event_list(to_agent="<you>", status="open")
mine       = mempalace_event_list(from_agent="<you>")

A candidate is answered when one of your own events

  1. has a higher seq than the candidate, and
  2. joins to it — metadata.ack_of == candidate.id (exact, written for you by event_ack) or the same correlation_id (the fallback), and
  3. carries a terminal status: applied, superseded, failed, blocked.

Everything else is still owed. Two calls, constant cost.

Compare seq, never created_at — the same reason you resume with since_event_id. Without the ordering test, one terminal reply would suppress every later ask on the same correlation_id for good; verified on a live thread where a ready reply at seq 16 sits before the request at seq 17 that it obviously cannot have answered.

On a real mesh, compare hlc instead. seq is replica-local: it equals origin_seq today only because a single replica authors events for every machine. Enrol a second replica and a late-syncing peer event gets a late local seq, so two replicas can order the same pair differently and derive different owed-sets from the same log. Every event already carries hlc (<millis>-<counter>-<replica_id>), which is total and causally consistent. So: compare seq while mempalace_mesh_peers reports no peers, hlc once it reports any, and created_at never. (This is a legitimate use of mesh_peers — choosing an ordering key — not the discredited gate on whether to read your mailbox at all.)

The failure directions are not symmetric, which is why this is safe to get slightly wrong. Local-seq skew can make an already-answered item resurface as owed: noise, self-correcting, and visible. A timestamp comparison can suppress an unanswered ask forever: silent and permanent. So if you ever see an item you know you answered come back, do not "fix" it by reaching for created_at — you would be trading the safe failure for the dangerous one.

This also supplies the "taken, not finished" state that looked missing: claimed and ready are deliberately not terminal, so work you have picked up keeps resurfacing until you close it out. No extra convention, no new field.

Two consequences worth internalising:

  • "Seen, not doing it" is a legitimate ack — status="blocked" or "superseded" plus the reason. Silence is not, and it is not merely rude: with no terminal event of yours to join to, the ask stays in the owed set indefinitely and there is nothing anyone can do about it from the other end.
  • Nothing expires, and it should not. An open with no terminal reply is still live by definition, and the finished threads are valuable history. If content is genuinely perishable ("do not push to main for the next hour"), say so in metadata.expires_at — metadata is stored verbatim — and honour it as a hint when reading. An old open that the derivation still counts as owed is a signal, not garbage: it means somebody asked and nobody answered.

Writing to another machine

  • Address the stamped name you actually saw in a from_agent field, e.g. pi@tor-ms22. A bare pi reaches nobody's mailbox once stamping is live, and older events in the log still carry bare names — do not copy them.
  • The rule runs in reverse too: what you put in YOUR OWN from_agent decides where every reply to your event goes. Nothing stops you writing a synthetic or borrowed identity there, and a reply is always addressed back to exactly that string — so if no live session ever runs as it, the reply is stored, searchable, and delivered to no one. Measured cost: a directed ask sent under a synthetic sender got two correct replies, one of them an urgent security finding, and both sat unread for ~2h20m because nobody's mailbox was that identity (RFC 003 §7.13). Authoring under a synthetic name is fine for a deliberate control experiment — this fleet does it on purpose — but then name the real identity to reply to inside the body, because the address line is not a safe place to also carry provenance.
  • Use status="open" only when you truly need an answer. It places an obligation on another machine.
  • Never broadcast an ask. to_agent="*" + status="open" obliges everyone and therefore no one.
  • Always set a correlation_id on a directed open, and reply with the same one. It is not just for reconstructing a conversation later: it is the join the owed-set derivation depends on. An uncorrelated ask can only ever be closed by an event_ack (which sets ack_of for you) — a plain reply cannot be matched to it at all.
  • Corrections are new events, never edits. Say explicitly what you retract and name the id — drawer or event — that carried the withdrawn claim.
  • Put a retraction where the reader will look. A directed open ask reaches a live agent's mailbox; a terminal-status event reaches no mailbox at all, and a drawer is what a future semantic search finds. If you filed advice as a drawer and later withdraw it, file the withdrawal as a drawer too — otherwise the next agent finds your original confident advice and no trace of the correction. (This is a real incident, not a hypothetical.)
  • Hand over exact content as an artifact, not prose: mempalace_artifact_put or mempalace_patch_submit store bytes with a sha256, and the event references the id. Never paste a diff into a body and hope it survives.
  • Waiting on a specific reply? mempalace_event_wait blocks with backoff — do not poll event_list in a loop. A timeout there is a normal result, not an error.

Palace Structure

Wings

Wings are top-level categories, typically one per project or domain.

NAMING CONVENTION — decided 2026-09-06 by Joakim: bare project names, no wing_ prefix. home-network, pi-devbox, mempalace-toolkit — not wing_pi-devbox. The mass is already there (pi-devbox 2061 drawers vs wing_pi-devbox 25), and a prefix present on some wings and absent on others turns every read into a guess about which spelling holds the content.

  • Named after the project directory or domain (e.g., cli_utils, home-network)
  • Always pass wing explicitly to diary_write. Omitting it defaults to wing_{agent_name}, which mints or feeds a parallel wing — this tool default, not anyone's sloppiness, is the mechanism that produced the drift. Measured harm (2026-09-06, pi@mbp-m1-2020): a diary entry written with agent_name=pi and no wing landed in wing_pi while that agent's history lives in pi-devbox, so a diary_read scoped to pi-devbox showed no trace of it. A wing-scoped read that silently returns an incomplete history is the worst failure mode a memory store has.
  • Legacy wing_* wings are frozen and documented, not renamed. wing_conversations (written by the session feeders), wing_pi, wing_pi-devbox, wing_pi-tor-ms22, wing_pi-devbox-emb7kj, wing_mempalace, wing_orchestrator, wing_code all still hold real content. When searching for history, check both spellings — this is the practical cost of the drift and it does not go away by decree.
  • If a migration is ever done, the acceptance criterion must be at the relationship level: chunk ids still resolve to their parent, and diary_read returns the same entry set before and after. Per-wing drawer counts can look correct while the relationships underneath are broken, because a count query never touches them.

Shared palace: multiple harnesses, and possibly multiple machines

A single palace can be fed by multiple coding-agent harnesses, and — when MEMPALACE_REMOTE_URL points at a central palace — by multiple machines. On this machine the palace is shared between opencode and pi (Mario Zechner's pi-coding-agent). Implications:

  • wing_conversations mixes sources. Both harnesses' session feeders write into the same wing. To tell them apart, look at the source_file metadata on each drawer:
    • pi_<uuid>.jsonl → pi session
    • <slug>_ses_<id>.jsonl → opencode session
    • The first chunk of each session also carries a | source: opencode or | source: pi marker in the synthetic header line.
  • Other wings may belong to other harnesses. For example wing_pi is pi's diary, not opencode's. Don't assume every diary entry was written by you — check agent_name on the entry.
  • Session feeders run on different schedules. Pi sessions are fed Tue 03:00, opencode sessions Mon 03:00 (launchd Weekday: 0/7=Sunday, 1=Monday, 2=Tuesday — misreading this by one day is easy). Recent sessions from either harness can lag the palace by up to a week, so absence-of-evidence in wing_conversations is not evidence-of-absence for recent work.
  • Reading another harness's diary is useful. When orienting after a gap, mempalace_diary_read agent_name=pi (or whichever sibling agent has been active) often gives a fresher picture than waiting for the conversations feeder to catch up.

When the palace is central (shared across machines), these further things apply:

  • Check which machine a conversation came from. Transcripts are fed per device, so source_path reads …/mempalace-feed/<device>/pi_<uuid>.jsonl while the displayed source_file is only the basename. One search can legitimately return hits from several machines at once — look at the device segment before attributing a decision to this project.
  • Provenance is stamped for you — leave it alone. Drawers carry device and agent_kind metadata (plus device_source/agent_kind_source recording how each was determined, so an inference is never mistaken for a fact). You do not set these, and you no longer set added_by either: the pi bridge defaults the writer field to <harness>@<device> on add_drawer/checkpoint/mine/event_append/artifact_put, and prefixes diary entries with HOST:<device>|, from host-supplied $MEMPALACE_PI_DEVICE. RFC 001 §7.3.2 ranks "agent stamps it via a skill instruction" as the worst possible place for exactly the reason you would expect — it is per-call boilerplate that gets forgotten, and it did: the agent who wrote the previous version of this bullet then filed its own provenance drawer as added_by=checkpoint. Confirm the bridge in your image actually stamps before trusting it: the extension is baked at image build time, so a container on an image older than the stamping commit (pi-devbox < v1.8.7) stamps nothing while still satisfying both gates — the env vars are set and the code is simply absent. Check with grep -c MEMPALACE_PI_DEVICE "$(readlink -f ~/.pi/agent/extensions/mempalace.ts)"; zero means keep passing added_by="<harness>@<device>" and a manual HOST:<device>| diary prefix until the container is recreated on a newer image. Two things remain yours: pass source_drawer_id on kg_add (triples have no provenance field, so that pointer is the only path back to a device), and pass an explicit added_by only when deliberately filing on behalf of another device — and when you do, it must be <harness>@<device>. A bare nickname (pi-devbox-claude) has no @device to parse, so agent_at_device cannot attribute it and the drawer is unattributable by rule, not by lag: it survives every future stamp run with no device, and on a shared palace a device-less drawer is one nobody can later scope, audit or clean up per machine. Measured 2026-09-06: 11 drawers on tor-ms22 were filed this way — including the credential rows, i.e. exactly where "which machine measured this?" matters most — by an agent that had passed its own chosen nickname on every call. Its diary entries escaped, because HOST:<device>| in the AAAK text recovers the device. Diaries self-heal; plain drawers do not. The safest habit is the one above: pass nothing and let the bridge stamp. Never invent values for device/agent_kind/origin_device — a fabricated value is worse than a blank, because it silently corrupts a future merge.
  • Metadata is invisible to search — so check the text, not the fields. search results are built from a fixed key list and diary_read returns content, so neither ever shows device/added_by. Only mempalace_get_drawer reveals them. This is why diary entries carry an in-text HOST:<device> marker: it is the only attribution a reader actually sees. A diary entry with no HOST: marker predates the convention and may be from any machine — do not assume it is this one's history.
  • Mined drawers carry the MINE date, not the session date. When history is imported, or re-mined on the palace host, filed_at/created_at is the import time — so sorting by them does not give chronological order. Real session time is recoverable from the UUIDv7 in pi_<uuid>.jsonl: the first 12 hex digits are milliseconds since the epoch (and UUIDv7 sorts lexicographically in time order, so a plain filename sort is already chronological). Agent-authored drawers and diaries have no such backdoor — for those filed_at is the only chronology, which is why it must never be restamped.
  • Beware the timezone mismatch when you combine those. Palace filed_at/created_at are naive timestamps in the palace host's local time, while a UUIDv7 decodes to UTC. Comparing them directly introduces a silent offset (2 h for a CEST host). Normalise before drawing conclusions about ordering.
  • agent_name is not device-scoped. mempalace_diary_read(agent_name="pi") returns every machine's pi diary, interleaved. Read the entry before assuming it is your own history — and note that a container cannot tell you which machine it is on (hostname is a docker hash, $DEVBOX_HOST_ALIAS is generic). $MEMPALACE_PI_DEVICE is the cheap answer; ssh -F ~/.ssh-local/config host hostname is the independent one.
  • One writer, no queue. A concurrent mine returns a structured already-running error rather than waiting its turn, and one large mine can make the palace unresponsive to every client for minutes. After another client's mine, call mempalace_reconnect to see the new drawers. A client-side timeout is not evidence of failure — verify before retrying, or you file a duplicate.

Rooms

Rooms are aspects within a wing:

  • fzf, scripts, configuration, general — whatever the miner detects
  • Diary entries go into rooms by topic tag

Drawers

Drawers hold verbatim content — never summarized, always searchable.

Tunnels

Cross-wing connections linking related content across projects.

Knowledge Graph

Entity-relationship triples with temporal validity. Query with mempalace_kg_query, browse with mempalace_kg_timeline.

Troubleshooting

Problem Fix
"No palace found" Run mempalace init <dir> then mempalace mine <dir>
"Error finding id" after mining Run mempalace repair --yes then mempalace_reconnect
Search returns irrelevant results Use max_distance=1.0 for stricter matching; add wing filter
Miner skips file types File manually with mempalace_add_drawer or use --no-gitignore
Stale results after external changes Call mempalace_reconnect

Anti-Patterns

  • Don't guess when you can search. If a question touches past work, search first.
  • Don't probe what the fleet already knows. Before SSH-ing into a host, enumerating infrastructure, or deriving how something is deployed, search the palace. A probe reveals one host's present state; the palace holds intent, history and prior corrections — including the ones that contradict what you are about to conclude.
  • Don't trust this session's context over the palace. A compacted summary is lossy, and another machine may have corrected the fact since. Verify load-bearing environment claims against the shared record before acting on them.
  • Don't take one empty search as proof the palace is silent. Fresh drawers rank worst, and the drawer that matters is usually the newest one. For anything 0-2 days old, enumerate with mempalace_list_drawers(since=…) and read the other machine's diary before you go and probe.
  • Don't infer elapsed time from session or container boundaries. A restart isn't a new day. Compare the actual timestamp (timestamp / created_at) against the current date/time before saying "yesterday", "last week", etc.
  • Don't skip the diary. A session without a diary entry is a session forgotten.
  • Don't summarize drawer content. File verbatim — the embedding model needs the original words.
  • Don't mine .git directories or node_modules. The CLI miner respects .gitignore by default.
  • Don't create duplicate drawers. Use mempalace_check_duplicate before adding manually.
  • Don't treat the palace as a task list. It's for knowledge and context, not todos.
  • Don't broadcast an ask, and don't leave one unanswered. On a shared palace, to_agent="*" + status="open" obliges every machine and therefore none of them. And don't expect acking to tidy your mailbox: status is immutable, so the event keeps matching either way — what a terminal reply buys you is that the derived owed set (see What you actually owe) stops counting it. Leave asks unanswered and that set only grows, until everyone learns to stop looking. "Seen, not doing it" is a complete answer — silence is not.
  • Don't assume you would have heard. Nothing pushes another machine's message into your session. If you did not run the mailbox query at wake-up, a correction addressed to you by name can sit unread while you confidently rebuild the thing it warned you about.
  • Don't author an ask under an identity nobody runs as, including your own throwaway labels. The failure is symmetric to the one above: it is not that you missed a message, it is that nothing could ever have delivered the reply to you, because you addressed it at a name instead of an agent. If you must use a synthetic sender for a control or an experiment, say inside the body who should actually receive the reply.
  • Don't invent provenance metadata, and don't hand-stamp it either. An earlier version of this list told you to set added_by="<harness>@<device>" by hand; that instruction has been withdrawn, because RFC 001 §7.3.2 places provenance at the client/server boundary and the pi bridge now does it uniformly (see Provenance is stamped for you above) — but the withdrawal only holds where the bridge is live, so run the one-line check in that bullet first; on an older image hand-stamping is still the only signal a hand-filed drawer gets. DO NOT invent values for the palace's own metadata fields (device, agent_kind, origin_device): those are stamped by infrastructure that also records how each was determined, and a fabricated value is worse than none because it silently corrupts a future merge. DO pass source_drawer_id on kg_add. And never put a machine name in a diary's agent_name — it becomes the wing name and hides your entries from diary_read.