Compare commits

...

21 Commits

Author SHA1 Message Date
joakimp 93f986e90e v1.8.6: adopt pi 0.84.3 + mempalace 3.8.0, close the v1.8.5 doc/observability gaps
Lint / hadolint (push) Successful in 9s
Publish Docker Image / resolve-versions (push) Successful in 15s
Publish Docker Image / base-decide (push) Successful in 8s
Lint / actionlint (push) Successful in 1m9s
Publish Docker Image / build-base (push) Successful in 41m23s
Publish Docker Image / smoke (push) Successful in 4m50s
Publish Docker Image / smoke-studio (push) Successful in 5m5s
Publish Docker Image / build-variant (push) Successful in 15m46s
Publish Docker Image / update-description (push) Successful in 7s
Publish Docker Image / promote-base-latest (push) Successful in 15s
Publish Docker Image / build-variant-studio (push) Successful in 19m59s
Three coupled pieces of work, all of which ride on the base rebuild that the
mempalace bump forces anyway.

DRIFT ADOPTED
- pi 0.84.2 -> 0.84.3. Its release notes carry a "Breaking Changes" line
  (GoogleThinkingLevel -> GoogleApiThinkingLevel). Audited before adopting:
  zero references across all four vendored companions (pi-fork,
  pi-observational-memory, pi-atelier, pi-studio), so it is inert for us. The
  reason to adopt is two skill-discovery fixes that land directly on v1.8.5's
  vendored-skill work: nested Markdown skills inside grouping directories were
  not discovered, and root README.md/AGENTS.md in skill dirs were reported as
  broken skills.
- mempalace core 3.7.1 -> 3.8.0. Additive/reliability only. Its sync fix
  (#2320/#2322) stops sync --apply deleting drawers whose source_file was
  unreachable *at that moment* -- which does NOT relax the standing landmine
  against sync on the shared palace, because that landmine is about paths
  permanently absent from whichever host runs the sync. Different failure
  shape; the caution stands.

DOCS -- three defects, one of them public
- DOCKER_HUB.md advertised "neovim (LazyVim defaults)". Nothing in the image
  installs LazyVim; the only nvim config is a 19-line sysinit.vim. CI PATCHes
  this file into the Docker Hub description on every release, so this was a
  false claim published to the world. Removed.
- agent-browser + Playwright + Chromium is the single largest addition in the
  image (~625 MB) and had zero mentions in README, DOCKER_HUB or THIRD_PARTY --
  it was documented only to agents, in the AGENTS.md managed block. Now
  documented to humans, including the Chromium licence dimension.
- typst and socat appeared in README prose but not in the "What's inside"
  inventory. Added.

OBSERVABILITY -- the three gaps v1.8.5 listed as still open
- build-manifest.json now records mempalace core, read from the live binary
  (ground truth, not the build ARG). Placed as a sibling of pi_version rather
  than inside components{}, because pi-devbox-version renders that map through
  [0:12] and would truncate a version string.
- smoke asserts the pi-observational-memory clone actually CONTAINS the ce9fc98
  auth fix, pinned to src/runtime.ts. Deliberately not a repo-wide grep: two of
  the three markers also live under tests/, so the repo-wide form stays green
  with the fix site reverted. That is the third false-green of this exact family
  in this repo (canary phrase in both snapshots; reconciler fixture using a
  non-owned name; now this) -- pin containment checks to the fix site.
- smoke asserts the feeder's pi@<device> agent default behaviourally. The
  earlier audit concluded this needed a --print-config added upstream; it does
  not. AGENT is assigned before arg parsing, so `bash -x mempalace-pi-session
  --help` observes the real resolution with no toolkit change. Two-sided:
  device set => pi@<device>, unset => must not be pi@*.
- pi-devbox-version now prints a palace: line with the same live-vs-baked drift
  detection pi already had. This matters more than it looks: mempalace is the
  one component that is both client (here) and server (synlig), so skew between
  them is a real failure mode. Degrades quietly on pre-v1.8.6 images.

Deferred deliberately: a native arm64 act_runner on tor-ms22 (the current
runner is on synlig, x86_64, so every arm64 layer ships QEMU-emulated).
Analysis and caveats filed to the palace rather than actioned here.
2026-08-25 15:29:22 +02:00
joakimp 26f223568d .env.example: document MEMPALACE_PALACE_PATH and why nothing exports it
Lint / hadolint (push) Successful in 10s
Lint / actionlint (push) Successful in 50s
The only MemPalace variable the template never mentioned, and the one that
moves the feeders' stage as a side effect: the palace root resolves as
$MEMPALACE_PALACE_PATH -> $MEMPAL_PALACE_PATH -> ~/.mempalace/config.json ->
~/.mempalace/palace, and the stage is derived from it (<palace-root>/pi-stage).

The comment states that precedence, records why neither the image nor the
entrypoint exports it (pinning the palace without carrying the stage along
re-creates the split a shared root removed, v1.8.2), warns that a stage whose
persistence differs from the palace makes a scoped `mempalace sync` prune
conversation drawers whose dedup key is the staged path, and notes it is a
path INSIDE the container unlike the host-side WORKSPACE_PATH/SSH_KEY_PATH
above it.

Found while auditing a live host whose .env sets it redundantly to the
default value.
2026-08-23 23:27:42 +02:00
joakimp 01abda3456 v1.8.5: the skillset owns its skills, and a dangling link no longer kills boot
Publish Docker Image / base-decide (push) Successful in 18s
Publish Docker Image / build-base (push) Successful in 41m59s
Publish Docker Image / smoke (push) Successful in 7m31s
Publish Docker Image / smoke-studio (push) Successful in 20m56s
Publish Docker Image / build-variant (push) Successful in 16m11s
Publish Docker Image / promote-base-latest (push) Successful in 15s
Publish Docker Image / update-description (push) Successful in 14s
Publish Docker Image / build-variant-studio (push) Successful in 17m32s
Lint / hadolint (push) Successful in 8s
Publish Docker Image / resolve-versions (push) Successful in 15s
Lint / actionlint (push) Successful in 20s
No pin moved except mempalace-toolkit fd8b15f5 -> 0fe64c4 (feeder defaults
--agent to pi@$MEMPALACE_PI_DEVICE, so hand-filed palace writes carry
provenance; $USER remains the fallback when the var is unset). pi 0.84.2 and
mempalace 3.7.1 are still upstream-latest, atelier v0.8.2 keeps the >=0.7.1
floor for pi >=0.84, and om master is still ce9fc982 -- nothing landed after the
merge that fixed the eight-week silent-observation bug.

Expect a full multi-arch base rebuild: entrypoint-user.sh, rootfs/** and the
resolved toolkit SHA all feed the base hash.
2026-08-23 21:00:21 +02:00
joakimp b5810654f6 skills: let the skillset own the skills it owns, and stop a dangling link from killing boot
Lint / actionlint (push) Successful in 16s
Lint / hadolint (push) Successful in 20s
Baked skill links won over the live skillset clone for all three vendored
skills, so a pushed edit to skills/mempalace/SKILL.md was invisible in every
container until the next image build -- measured on two hosts (live md5
129bcc4752 vs baked 5236024fef). Cause was ordering, not intent: the baked links
are created early with a create-only-when-absent guard to close a smoke
readiness race, and the skillset deploy runs last and treats them as foreign.
The comment claimed the opposite of the behaviour.

The fix is not "skillset always wins". Ownership is per-skill: pi-extensions is
owned by its package repo and copied over the snapshot at build time, so the
skillset's lagging duplicate must keep losing; pi-devbox-environment is authored
here. Only mempalace is skillset-owned. devbox-skill-reconcile therefore runs
after the deploy and repoints only the names in skills/skillset-owned.txt,
replacing a link solely when it points into the baked tree, so a real directory
or a link pointing elsewhere is never disturbed. Precedence is now user override
-> live clone (owned names) -> baked snapshot, with the early links intact as
the fallback so the readiness race stays closed.

Reviewing that turned up a latent boot-abort in the pre-existing baked-link
block: `[ ! -e "$link" ]` is TRUE for a dangling symlink, so once a link can
point into /workspace/skillset, a vanished mount makes plain `ln -s` fail with
"File exists" -- and under `set -euo pipefail` that aborts container start
before `exec "$@"`. Reachable on `docker restart` or a host reboot, not on a
recreate, since ~/.agents is not a volume on any host. Now `ln -sfn`, which
heals the link back to the baked fallback.

Smoke additions cover what let this ship: the stale-snapshot canary grepped a
phrase present in BOTH the stale and fresh copies, so it passed throughout;
it now pins the newest section. Link targets are asserted, not just `test -L`;
the owned-list content is asserted both ways; and the reconciler's replace path
-- which no CI container exercises, since none mounts a skillset -- is covered by
fabricating one. A mutation test showed the obvious three assertions still pass
with the "is this link ours?" guard deleted, so a discriminating case was added:
an owned name whose link is a user override outside the baked tree.

Also refreshes the mempalace snapshot to skillset 670f7f1 (without it the fix
helps only hosts that mount skillset) and corrects README, which documented the
old, wrong precedence in three places.

Verified with 12 fixture cases plus 2 mutants: ownership respected against the
real trees, user overrides preserved, relative/trailing-slash/CRLF/space/glob
inputs handled, dangling link healed, read-only skills dir exits 0, idempotent.
2026-08-23 20:59:14 +02:00
Joakim Persson 4f6f470518 changelog: file the vendored-skill shadowing bug for v1.8.5
Lint / actionlint (push) Successful in 21s
Lint / hadolint (push) Successful in 1m24s
~/.agents/skills is asymmetric: the three pi-devbox-specific skills resolve to
the baked copies while all others resolve to the live skillset clone. Root cause
is ordering in entrypoint-user.sh -- baked links are created early (line 65) with
a create-only-when-absent guard, and the skillset deploy runs last (line 387) and
leaves them alone as foreign links. The comment at line 61 states the intent as
protecting the skillset skill from being clobbered, but the effect is the
reverse.

Cost measured on two hosts: a pushed edit to the mempalace skill (live md5
129bcc4752) was invisible to both containers, which kept loading the baked copy
(md5 5236024fef). Editing those three skills appears to work and silently does
nothing until a rebuild.

Filed as a known issue with a proposed fix rather than fixed here: changing
symlink precedence is image behaviour and wants its own review plus a smoke
assertion, and the early-link ordering exists to close a readiness race that
must not regress.
2026-08-23 20:05:38 +02:00
Joakim Persson fbc1f86612 docs: a negative result is usually your own filter (skill + AGENTS.md)
Lint / hadolint (push) Successful in 13s
Lint / actionlint (push) Successful in 16s
Three false negatives in one session, all self-inflicted, all convincing
because the command "succeeded": a `| head -20` proved an SSH peer absent
that sits at line 454 of a ~500-line config; `ssh mac 'docker ps'` proved
the host had no Docker, when the non-interactive PATH simply lacks
/usr/local/bin; and `grep 'ssh '` proved no ControlMaster was running,
when those processes rename themselves to `ssh: <path> [mux]`. Same root
cause each time, so it goes in the skill rather than in a commit message:
a positive result carries its own evidence, absence has to be earned.

The skill (rootfs/, symlinked into ~/.agents/skills) is BAKED, so this is
an image change and is logged in CHANGELOG Unreleased accordingly. Its §3
also now records that a live ControlMaster socket makes later commands
authenticate not at all -- after editing a peer's authorized_keys, "it
still works" proves nothing; prove it with -o ControlPath=none, or the
breakage waits for a future session that has no memory of the edit.

AGENTS.md: corrected a stale CI claim while placing the pointer. It said a
tag push produces two runs including lint; lint.yml has since been scoped
to branches: ['**'], which excludes tag refs, and refs/tags/v1.8.4 duly
produced run 571 (publish) and nothing else. Kept the head_sha + workflow
path filter advice, which is cheap and guards against a future v*-triggered
workflow. Added a short section on verifying this repo from inside a
container, including that docker-compose.yml here is a TEMPLATE pinning
:latest while a real host runs its own per-machine file -- recreating from
the repo copy can silently move a host off :latest-studio.

Placement note: AGENTS.md is only auto-read when the cwd is this repo, so
the durable rule lives in the skill, which loads by description match in
any pi-devbox session.
2026-08-22 22:56:41 +02:00
Joakim Persson 2ebf00d6d4 v1.8.4: the om fix lands upstream, pi-atelier v0.8.2, todo edit
Lint / hadolint (push) Successful in 9s
Lint / actionlint (push) Successful in 22s
Publish Docker Image / resolve-versions (push) Successful in 19s
Publish Docker Image / base-decide (push) Successful in 11s
Publish Docker Image / build-base (push) Successful in 42m16s
Publish Docker Image / smoke-studio (push) Successful in 5m0s
Publish Docker Image / smoke (push) Successful in 19m0s
Publish Docker Image / build-variant-studio (push) Successful in 17m38s
Publish Docker Image / build-variant (push) Successful in 28m36s
Publish Docker Image / update-description (push) Successful in 8s
Publish Docker Image / promote-base-latest (push) Successful in 10s
Headline: pi-observational-memory 37986b6 -> ce9fc98. The ambient-credential
gate fix (6f694e6 + 699ccc7) was merged upstream as PR #52 on 2026-08-22,
closing issue #51, so this release picks it up through the ordinary
PI_OBSMEM_REF=master path with nothing carried locally. Every image up to
and including v1.8.3 silently recorded zero observations on a Bedrock host
using ambient AWS credentials; from this one on, /opt is the fix and the
settings.json packages[] workaround should be deleted (verify against
/etc/pi-devbox/build-manifest.json first). npm still ships the broken 3.0.4,
which does not matter here because the image clones the ref instead.

Pin bump: PI_ATELIER_REF / PI_ATELIER_VERSION v0.8.1 -> v0.8.2, audited per
the floor note above the ARG. The only version in between is 0.8.2 itself and
both its entries are Workspace-Pulse-internal (inspection coalescing and
serialization; fresh inspection guaranteed at Turn end, retired sessions can
no longer publish stale results). Nothing touches pi's private TUI renderer,
which is the coupling behind the 0.6.0/0.7.0-under-pi-0.84 startup hang, and
pi is unchanged at 0.84.2 - so the bump stays outside that risk class.

Also baked by this build, no pin needed: pi-extensions 98eb07b -> 2022887
(the todo edit action), pi-fork 4a09af4 -> f1ff808, pi-studio 0.9.44 ->
v0.9.48, mempalace-toolkit b609cf5 -> fd8b15f (docs only - the pi-session
false-success guard was already baked in v1.8.3, confirmed by ancestry),
aws-cli 2.36.24 -> 2.36.29 and the other *_VERSION=latest tools.

README: the pin table also said mempalace 3.6.0, stale since v1.8.3 bumped it
to 3.7.1. Fixed in passing.

Unchanged and verified current: pi 0.84.2 (npm latest, published 2026-08-14),
MEMPALACE_VERSION 3.7.1 (PyPI latest), pi-toolkit 0e1369e.
2026-08-22 21:19:48 +02:00
joakimp c3b6d36778 docs(CHANGELOG): Unreleased — the todo extension gains an edit action
Lint / hadolint (push) Successful in 9s
Lint / actionlint (push) Successful in 23s
The commit itself lives in pi-extensions (2022887), but /opt/pi-extensions
is baked into this image, so "which todo behaviour does this image have" is
an image question. PI_EXTENSIONS_REF=main means the next build picks it up
with no pin to bump -- worth stating explicitly, since a reader who expects
a version bump will otherwise go looking for one.

Also recorded, because it confused a session today: pi-atelier's
tool_result hook replaces todo output with "N/M done - see sidebar"
whenever the sidebar panel is visible, so an agent sees the counter and not
the item text. Upstream's list returns every item; the terseness is
atelier's deliberate context saving, not a limitation of the tool.
2026-08-17 22:49:13 +02:00
Joakim Persson 3a509077c2 v1.8.3: mempalace 3.7.1, refreshed skill snapshot, census on PATH
Lint / hadolint (push) Successful in 8s
Lint / actionlint (push) Successful in 23s
Publish Docker Image / resolve-versions (push) Successful in 13s
Publish Docker Image / base-decide (push) Successful in 11s
Publish Docker Image / build-base (push) Successful in 53m49s
Publish Docker Image / smoke (push) Successful in 7m22s
Publish Docker Image / smoke-studio (push) Successful in 18m6s
Publish Docker Image / build-variant (push) Successful in 19m6s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 11s
Publish Docker Image / build-variant-studio (push) Successful in 27m5s
mempalace 3.6.0 -> 3.7.1. Verified against the 3.7.1 source rather than its
changelog, because the risk lands on palaces users cannot reconstruct: legacy
drawers lack the new chunk_total marker and both decision sites trust them, so
no mass re-mine; NORMALIZE_VERSION is 2 in both; chromadb stays <2 so no
index-format migration; no auto-migration exists; logstream.sqlite3 is created
lazily. Downgrade remains possible (3.6.0 has zero references to chunk_total).

Two behaviour changes documented in the CHANGELOG: ALLOW_PEER_WRITER no longer
works on local/chroma palaces, and writer-lock setup failures fail closed.
Neither affects this image's MCP-server-plus-CLI-feeder pattern, which already
serialised on the same lock under 3.6.0 -- the upstream "process-lifetime
single-writer" entry describes tightened escape hatches, not a new lease.

The motivation is the shared central palace: 3.7.1 drops the stale chromadb
SharedSystemClient cache on reconnect (3.6.0 could let a stale in-memory HNSW
segment overwrite a peer's writes, "index count going backwards"), releases the
writer lease on SIGTERM/SIGHUP, and stops treating an interrupted mine as
complete. The fleet primary was upgraded to 3.7.1 and restarted before this tag,
because 3.7.1 refuses writes when the served library drifts and reconnect cannot
clear that. opencode-devbox still pins 3.6.0, so the lockstep is broken until it
cuts its own release.

Vendored mempalace skill snapshot refreshed to skillset 936fed8 (was 63f3bf5).
This closes a gap that had been invisible for two commits: ~/.agents/skills/
mempalace symlinks to the IMAGE-BAKED copy, entrypoint-user.sh creates that link
first, and the skillset deploy never clobbers an existing name -- so in a devbox
container the vendored snapshot always wins and editing skillset alone changes
nothing a container reads. Brings the multi-machine shared-palace section and
the hand-crafted-provenance guard.

pi-global-AGENTS.append.md already carried the three shared-palace damage rules
(a55f636); this tags them into a release.

mempalace-census gets the /usr/local/bin symlink its three siblings have had all
along, plus chmod and a build-time --help smoke check, so RFC-002 Phase A
censuses no longer need an absolute path.

Two new smoke assertions, both confirmed to FAIL against a v1.8.2 container so
they are not tautological: the vendored snapshot must contain the multi-machine
section (a stale manual snapshot is otherwise invisible), and mempalace-census
must be on PATH.

No other pins move: pi stays 0.84.2 (npm latest), pi-atelier v0.8.1, and every
git-ref component was checked against its upstream head and is unchanged.
2026-08-16 23:30:01 +02:00
Joakim Persson a55f6369b3 AGENTS.md: three damage-prevention rules for a shared central palace
Lint / hadolint (push) Successful in 13s
Lint / actionlint (push) Successful in 28s
The managed block already tells agents to load the mempalace skill, but said
nothing about the palace being shared with other machines. Three failure modes
observed tonight while onboarding tor-ms22's feeders, all of which do damage
rather than merely confuse:

- `mempalace sync` prunes drawers whose source files look gitignored, deleted
  or moved. On a central palace that describes most of the content, including
  every other machine's. Compounded by RFC-001 7.2: feeders now stage inside
  the palace root, so a scoped sync can delete the drawers it just filed.
- A client-side timeout is not a failure. The palace is single-writer and one
  large mine blocks every client for minutes, so
  `[mempalace ext] feed (tick) failed: mine timed out after 30000ms` usually
  means the mine COMPLETED. Verified: drawers from a timed-out tick were
  present 35 s after the client gave up. A blind retry files a duplicate.
- The `mempalace` CLI has zero references to MEMPALACE_REMOTE_URL, so it always
  opens a palace on local disk and can silently disagree with the MCP tools.

Orientation depth stays in the skill; only damage-prevention belongs here,
because this file is always read and the skill's later sections often are not.
2026-08-16 22:32:09 +02:00
Joakim Persson ffd54750b9 docs: CHANGELOG for v1.8.2 — the silent transcript-feed failure and its guard
Publish Docker Image / resolve-versions (push) Successful in 25s
Lint / actionlint (push) Successful in 30s
Publish Docker Image / base-decide (push) Successful in 8s
Lint / hadolint (push) Successful in 46s
Publish Docker Image / build-base (push) Successful in 41m26s
Publish Docker Image / smoke (push) Successful in 4m45s
Publish Docker Image / smoke-studio (push) Successful in 5m11s
Publish Docker Image / build-variant-studio (push) Successful in 17m44s
Publish Docker Image / build-variant (push) Successful in 23m41s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 10s
2026-08-16 00:53:46 +02:00
Joakim Persson d8b745c164 smoke+docs: pin the feeder's failed-remote-mine detection; REMOTE_PATH is server-visible
Lint / actionlint (push) Successful in 30s
Lint / hadolint (push) Successful in 40s
First boot of the 2026-08-15 image on EMB-7KJ4VR4G shipped 7 transcripts to the
palace host and filed none. Two causes, neither visible in the log:

- MEMPALACE_PI_REMOTE_PATH was unset, so the feeder used its /data/feed default,
  which assumes a CONTAINERIZED palace server. That fleet's primary runs
  natively (systemd user unit + uv tool), so it only sees host paths and the
  mine died with "source directory not found". rsync had already succeeded.
- The feeder decided success with `'"error"' in body`, but MCP escapes the
  tool's JSON inside result.content[].text, so the check was blind and the
  catch-up log said "Done. Wing updated." Fixed in mempalace-toolkit 6e1f4f3,
  which ships `--self-test` with fixtures pinning that exact response body.

- smoke-test.sh: run `mempalace-pi-session --self-test` against the BAKED
  toolkit, so a stale or reverted MEMPALACE_TOOLKIT_REF cannot reintroduce a
  feeder that mines nothing while reporting success.
- .env.example: spell out that MEMPALACE_PI_REMOTE_PATH is the path the SERVER
  PROCESS can open — container path for a dockerized server, and identical to
  the ssh-target path for a native one — and that a mismatch fails quietly.
2026-08-16 00:30:51 +02:00
Joakim Persson ae13c2264e ci: survive a revoked GITEA_BUILD_TOKEN on public commit reads
Lint / actionlint (push) Successful in 14s
Lint / hadolint (push) Successful in 13s
Follow-up to a2f0a4a, which documented the hazard; this removes it.

resolve-versions read three PUBLIC Gitea repos with `curl -sf -H "$AUTH_HEADER"`.
Gitea rejects an invalid token rather than ignoring it, so the token turned a
read that works anonymously into a hard failure:

  no Authorization header    200
  empty token (secret unset) 200   <- absent secret was always safe
  garbage/revoked token      401   <- stale secret broke the release

A revoked GITEA_BUILD_TOKEN therefore failed resolve-versions via require_sha,
presenting as connectivity or an API fault, on data any anonymous client could
fetch. Hit exactly that failure mode today with an expired PAT.

New gitea_sha() helper tries authed, and on 401/403 retries anonymously with a
loud stderr warning naming the token as the cause. Deliberate choices:

- non-200 after the retry emits nothing and returns 0, so require_sha still
  raises the explicit abort — the helper never invents a fallback ref, which is
  the property the surrounding code exists to guarantee
- warnings go to stderr, NOT as ::warning:: annotations: the function's stdout
  IS the SHA, so an annotation there would be captured into the ref
- the header is still sent first, so a private repo keeps working

Verified by extracting the function from the YAML step body (so the test ran the
committed text, not a copy) and calling it against live Gitea:

  valid token        -> 0e1369e6b496 (pi-toolkit)
  REVOKED token      -> warns, retries anon, f60cf9c73205 (mempalace-toolkit)
  unset secret       -> 98eb07bce60a (pi-extensions)
  nonexistent repo   -> empty + HTTP 404 warning, so require_sha aborts

Behaviour unchanged on the happy path: all three SHAs are byte-identical to the
ones the v1.8.1 release run resolved with the old curl code. `bash -n` clean on
the extracted step body; YAML re-parsed.
2026-08-15 14:27:18 +02:00
Joakim Persson a2f0a4a441 ci: correct the false "Gitea requires auth for public reads" comment
Lint / hadolint (push) Successful in 13s
Lint / actionlint (push) Successful in 15s
resolve-versions claimed "Gitea API requires auth even for public-repo commit
listing" above the pi-toolkit / pi-extensions curls. Measurably false for the
repos it guards. Verified 2026-08-15, unauthenticated vs authenticated GET of
/api/v1/repos/joakimp/<repo>/commits?limit=1&sha=main:

  pi-toolkit         private=false  unauth=200 auth=200  sha 0e1369e6b496 identical
  pi-extensions      private=false  unauth=200 auth=200  sha 98eb07bce60a identical
  mempalace-toolkit  private=false  unauth=200 auth=200  sha f60cf9c73205 identical

Only /api/v1/repos/*/actions/* refuses anonymous reads with 401 — almost
certainly what the claim was over-generalised from. (Same over-generalisation I
nearly committed to opencode-devbox's AGENTS.md today; 69fc80a there narrowed it
to the actions endpoints for the same reason.)

Checked the three repos actually queried rather than reusing the pi-devbox
result — if any had been private the comment would have been TRUE, and the
correction wrong.

Behaviour deliberately unchanged: the header still gets passed. It survives a
repo being flipped private, and an unset secret degrades cleanly because Gitea
ignores an empty `token ` value and serves anonymously:

  no header                 200
  empty token (secret unset) 200
  garbage token             401

That last row is the fragility now documented: a REVOKED or malformed token
returns 401 where anonymous returns 200, so a stale GITEA_BUILD_TOKEN converts a
healthy public read into a require_sha failure that presents as an API or
network fault. Encountered exactly that today with an expired PAT on the actions
endpoints, so the note tells the next reader to suspect the token first.

Comment-only: no non-comment line changed, YAML re-parsed.
2026-08-15 14:21:25 +02:00
Joakim Persson 53b41cd76b smoke: assert the pi stage $HOME-relative, and add a smoke_only dispatch
Lint / actionlint (push) Successful in 16s
Lint / hadolint (push) Successful in 13s
Publish Docker Image / resolve-versions (push) Successful in 10s
Publish Docker Image / base-decide (push) Successful in 16s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke-studio (push) Successful in 5m20s
Publish Docker Image / smoke (push) Successful in 14m40s
Publish Docker Image / build-variant-studio (push) Successful in 21m51s
Publish Docker Image / build-variant (push) Successful in 15m59s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 17s
v1.8.0 never shipped: smoke (67/68) and smoke-studio (70/71) each failed the
same single assertion, so build-variant and everything downstream skipped and
latest stayed on v1.7.0.

The assertion was wrong, not the product. It grepped for a literal
stage=/home/developer/.mempalace/pi-stage/, but run() invokes

  docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd"

and neither Dockerfile sets USER or ENV HOME — the published base image config
has no HOME at all; it is normally set by entrypoint-user.sh, which
--entrypoint="" skips on purpose. So the assertion executed as root with
HOME=/root, mempalace-pi-session correctly resolved
stage=/root/.mempalace/pi-stage/... (the stage is $HOME-relative by design), and
the literal grep could never match under any circumstances.

The tell was one line below in the log: the sibling assertion "pi stage follows
MEMPALACE_PALACE_PATH" PASSED, because it sets the variable explicitly and never
consults HOME. Default fails + explicit passes = wrong HOME, not broken staging.

Now asserts the invariant actually intended — the stage sits beside the resolved
palace, sharing its lifetime — which is user-independent:

  case "$stage" in "stage=$HOME/.mempalace/pi-stage/"*) exit 0 ;; *) exit 1 ;; esac

$HOME is expanded by the container's own shell, so it holds as root, as
developer, or under any future user. Verified all four cases against the real
bin/mempalace-pi-session by extracting the committed assertion bodies and
running them under sh -c: virgin HOME -> exit 0; HOME=/home/developer -> exit 0;
MEMPALACE_PI_STAGE pinned to a .cache path -> exit 1 (the regression this
assertion exists to catch still fails it); developer-identity companion -> 0.

Added that companion assertion, "pi stage is palace-adjacent for the developer
user", which covers the deployment-specific path properly by SUPPLYING
HOME=/home/developer rather than assuming it.

Why this took a release to surface: docker-publish.yml triggers on push tags v*
only. The assertion was added on a push to main (7c00dd6), where only lint.yml
runs, so v1.8.0 was its first execution ever. Any smoke assertion written
outside a release was unvalidated until a release consumed it.

New workflow_dispatch input smoke_only probes/builds the base, runs both smoke
jobs against HEAD, and stops before publishing. Implemented as
`if: inputs.smoke_only != 'true'` on build-variant and build-variant-studio,
deliberately WITHOUT always() so the implicit needs-succeeded gate survives and
a red smoke still blocks a release. promote-base-latest and update-description
already require build-variant success, so they skip on their own. On a tag push
inputs is unset and null != 'true' is true, so releases are unaffected.

Finally, run() no longer discards output. A red ❌ carried zero diagnostic
weight: explaining this one-line failure needed a CI-log dig plus a registry
image-config inspection, when the container had already printed the answer.
Failures now show the last lines of output (guarded with `if`, not a trailing
`&&`, which would abort under set -e), and the stage assertions echo the
resolved stage and the HOME they saw to stderr — invisible while they pass.
2026-08-15 12:29:32 +02:00
Joakim Persson 29b62093f0 v1.8.0: bump pi 0.84.1 → 0.84.2 and pi-atelier v0.8.0 → v0.8.1, audited
Publish Docker Image / resolve-versions (push) Successful in 12s
Lint / actionlint (push) Successful in 23s
Publish Docker Image / base-decide (push) Successful in 8s
Lint / hadolint (push) Successful in 1m20s
Publish Docker Image / build-base (push) Successful in 41m34s
Publish Docker Image / smoke (push) Failing after 4m41s
Publish Docker Image / build-variant (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
Publish Docker Image / smoke-studio (push) Failing after 8m22s
Publish Docker Image / build-variant-studio (push) Has been skipped
pi 0.84.2 closes the Amazon Bedrock tool-argument poison pill that v1.6.4
recorded as "Not fixed upstream". pi-ai 0.84.2 adds a recursive
sanitizeBedrockDocument() and applies it at exactly the site that entry named
(dist/api/bedrock-converse-stream.js, line 692 -> 704; upstream PR #7882):

  - toolUse: { ..., input: c.arguments },
  + toolUse: { ..., input: sanitizeBedrockDocument(c.arguments) },

It strips object members whose key is the empty string, recursing through arrays
and nested objects. It runs at request-build time, so it covers the live turn and
a resume alike: a session already bricked by an empty-key tool argument now
replays instead of dying on a Bedrock ValidationException. pi-session-repair is
therefore no longer the recovery path on this image -- it stays useful for older
images and for inspection, since the fix sanitises what is sent, not what was
recorded.

Bumping PI_VERSION is the ONLY way to get that fix: pi publishes an
npm-shrinkwrap.json, so pi 0.84.1 pins pi-ai to exactly 0.84.1 even though its
package.json range (^0.84.1) would admit 0.84.2. Transitive upstream fixes never
leak into this image.

pi-atelier v0.8.1 is the matching companion -- both sides changed fullscreen
input handling within three days. Its only code change (src/split-pane.ts) stops
atelier writing its own 1002h/1006h pair around a sidebar resize under pi's
fullscreen renderer, which had been tearing down the mouse reporting pi itself
enabled and leaving the wheel dead.

Audited against the surfaces the pin policies name:
  - session .jsonl: identical migrateV1ToV2/migrateV2ToV3 ladder
  - node engine floor: unchanged >=22.19.0 (image ships 22.23.2)
  - atelier's three private couplings all intact in pi-tui 0.84.2 --
    class TuiAltScreen extends TuiBase (detected by constructor NAME, so a
    rename would fail silently), inputListeners still `new Set()` at the same
    line 103, own render(width) descriptor still present
  - pi's mouse sequences byte-identical between 0.84.1 and 0.84.2
  - atelier metadata unchanged: engines >=22.19.0, peerDeps >=0.80.7, zero
    runtime deps, so the "no npm install step" note holds

Not proven by execution: CI smoke does not drive the TUI, and 0.84.2 adds a
focused fullscreen search overlay that also participates in input handling. The
pairing is reasoned from the diffs. Worth an alt+a plus a sidebar resize and
wheel scroll in fullscreen on first use.
2026-08-15 00:22:08 +02:00
Joakim Persson cbd7cf5c67 entrypoint: announce the "remote palace, no inbox" skip instead of vanishing
Lint / hadolint (push) Successful in 8s
Lint / actionlint (push) Successful in 1m8s
When MEMPALACE_REMOTE_URL is set but MEMPALACE_PI_SSH_TARGET is not, the feeder
has nowhere to ship staged transcripts, so skipping is correct. The problem was
that the branch was a bare `:` AND the skip happens before the subshell that
writes ~/.pi/agent/mempalace-catchup.log -- so a container in that state
contributed nothing to the palace and left no artifact at all, not even an empty
log, to explain why. It is indistinguishable from a healthy run that had nothing
to file, which is the worst property a memory system can have: the failure looks
exactly like success.

Surfaced while flipping the first client onto the shared palace, where this is
the single most likely way to end up quietly memory-less -- the palace is the
only thing that survives a container recreate.

The notice goes to both the container start output (docker logs) and the log
path anyone debugging looks at first. It names both variables, says what still
works (MCP tools read/write the shared palace; only this container's own
conversations go nowhere), and points at MEMPALACE_FEED=0 for anyone who meant
it -- "HTTPS first, mining later" is a documented interim state, so the notice
has to be silenceable without being ignorable.

Guarded against becoming a startup failure. mkdir -p in a branch that previously
touched no filesystem is a new risk: an unwritable ~/.pi (root-owned volume, a
classic Docker accident) fails under set -e and would abort the entire
entrypoint. It now degrades to stdout-only. Verified all five paths by executing
the extracted block under `set -euo pipefail`: the trap prints and writes the
log; unwritable ~/.pi still exits 0 and still prints; MEMPALACE_FEED=0 stays
completely silent (no message, no file); and both normal-remote and local mode
still background the feeder with no notice.

Two smoke assertions guard against a regression to the silent no-op. They test
the entrypoint as shipped in the image rather than behaviour, because this branch
only runs at container start and a `docker run` one-shot cannot reach it.

entrypoint-user.sh is COPY'd in Dockerfile.base, so this rides the base rebuild
the Unreleased feeder work already needs.
2026-08-13 16:30:55 +02:00
Joakim Persson 7c00dd6001 mempalace: drop the stage ENV pin, fix the shared-server compose
Lint / actionlint (push) Successful in 21s
Lint / hadolint (push) Successful in 29s
The feeder now defaults to <palace-root>/pi-stage upstream, so pinning
MEMPALACE_PI_STAGE into ~/.pi here is unnecessary -- and was actively wrong. It
created a second convention that could still diverge from the palace: keep the
devbox-palace volume, drop devbox-pi-config, and a scoped `mempalace sync`
prunes every conversation drawer, because dedup keys on the staged path. Both
the ENV and the entrypoint export are gone; a comment explains why adding one
back re-introduces the split it was meant to fix.

docker-compose.mempalace.yml was broken on mempalace 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`.
  - Supply a token and the healthcheck's unauthenticated `tools/list` POST 401s,
    marking a perfectly healthy server unhealthy forever.
Now the token is required via ${MEMPALACE_REMOTE_TOKEN:?...} so it fails fast at
`docker compose up` with a readable message, and the healthcheck probes the
deliberately token-free /healthz. The "no authentication of its own" security
note has been stale since 3.6.0 and is replaced with the actual posture
(bearer token + Host pin + Origin allowlist), including why browser-shaped auth
must not be put in front of it.

Dockerfile.base: mempalace-pi-session symlinked onto PATH, with a build-time
`--help` check so a broken feeder fails the image build rather than the first
session.

smoke-test: assert the stage resolves beside the palace (default, and following
$MEMPALACE_PALACE_PATH) instead of asserting the removed ENV pin. The two
behavioural guards -- a synthetic session that must be captured, an abandoned
one that must not be -- are unchanged.

.env.example: recommend `mempalace serve` on the docker0 gateway rather than
`mempalace-mcp --transport http --host 0.0.0.0`, with the two binds to avoid.
2026-08-12 17:04:14 +02:00
Joakim Persson 7649d53f3b .env.example: say why GIT_USER_* must be set
Lint / hadolint (push) Successful in 30s
Lint / actionlint (push) Successful in 59s
Both keys shipped empty with no explanation, so they read as optional. They
are not: ~/.gitconfig is not on a persistent mount, so with these unset every
repo in the container fails "Author identity unknown" on first commit after
every recreate — and an agent asked to commit then infers an identity from
git log and picks the wrong one. Observed today across three repos on one
machine (two wrong-address commits, plus older `pi <pi@devbox>` fossils from
earlier sessions guessing the same way).

Also records that the address is per-machine (corporate vs private), so it
belongs in the per-machine .env rather than a skill or a repo-local override.
2026-08-09 15:42:29 +02:00
joakimp ade58131d6 docs(readme): use mkdir -p instead of install -d for the peer's ~/.ssh
Lint / hadolint (push) Successful in 13s
Lint / actionlint (push) Successful in 23s
`install -d -m 700 ~/.ssh` was correct but wrong for the audience. This snippet
gets pasted onto an arbitrary peer — a NAS, a router, a BSD box — and `install`
is not in POSIX, so it is not guaranteed to be there. `mkdir -p` + `chmod` is
POSIX, present everywhere, and self-evidently idempotent to a reader deciding
whether it is safe to run on a machine that already has keys.

Behaviour was checked rather than assumed, on both coreutils and BSD/macOS
`install`: on an existing ~/.ssh it exits 0 and leaves authorized_keys intact
in content and mode, but it also silently chmods the directory (755 -> 700).
Desirable here, yet invisible in a doc — which is the second reason to prefer
the explicit two-step form, and why the surrounding text now states outright
that re-running is safe on an already-configured peer: mkdir -p is a no-op, the
chmods only tighten, and appending never touches keys already listed.

Verified the whole block end-to-end against a fresh HOME and one with a
pre-existing 755 ~/.ssh and an older key: 700/600 in both cases, older key
preserved, new line appended.
2026-08-08 01:24:51 +02:00
joakimp ffd44ad9cf docs(readme): say where the authorized_keys line actually goes
Lint / actionlint (push) Successful in 13s
Lint / hadolint (push) Successful in 13s
Step 2 of "Giving the container its own key for a peer" showed a correctly
narrowed authorized_keys line but never named the file it belongs in, and never
said which account's — the only mention of authorized_keys was an aside 35
lines further down about revoking one key per machine. A reader following the
steps had a public key, a line to construct, and nowhere to put it.

Now explicit: append to ~/.ssh/authorized_keys of the account named as `User`
in step 3, via a heredoc that shows `>>` rather than `>` (the truncation that
revokes every other key on that account), with `install -d -m 700 ~/.ssh` and
`chmod 600` so the file is created correctly the first time.

Adds the three failure modes that make a good key look broken, none of which
announce themselves on the client: the options prefix must be on the same
physical line as the key (a wrapped paste is the usual culprit), `ssh-copy-id`
cannot add that prefix at all so the line must be appended by hand, and sshd's
StrictModes silently ignores authorized_keys when the home directory, ~/.ssh or
the file is group- or world-writable — reporting it only in the peer's own log.
2026-08-08 01:17:48 +02:00
19 changed files with 1928 additions and 57 deletions
+75 -3
View File
@@ -12,16 +12,81 @@ SSH_KEY_PATH=~/.ssh
# ── MemPalace memory (local by default) ───────────────────────────
# By default the mempalace.ts extension spawns a LOCAL mempalace-mcp stdio
# server (palace at ~/.mempalace). Uncomment the devbox-palace volume in
# docker-compose.yml to persist it across container recreation.
# docker-compose.yml to persist it across container recreation — that one
# volume now covers the mined conversation transcripts too, since the pi and
# opencode feeders stage inside the palace root (<palace-root>/pi-stage), so
# the staged files and the palace dedup keys pointing at them cannot be
# separated.
#
# That palace root is resolved with mempalace's own precedence
# ($MEMPALACE_PALACE_PATH -> $MEMPAL_PALACE_PATH -> ~/.mempalace/config.json ->
# ~/.mempalace/palace), and the feeders derive their stage FROM it
# (<palace-root>/pi-stage). Neither the image nor the entrypoint exports it, by
# design: pinning the palace without carrying the stage along re-creates the
# very split that a shared root removed. Override it only to move the palace off
# the default -- e.g. onto a different mount -- and only to a path with the SAME
# persistence as the palace itself. A stage that outlives its palace (or dies
# first) makes a scoped `mempalace sync` prune conversation drawers, because
# their dedup key is the staged path. Setting it to the default buys nothing.
# Unlike WORKSPACE_PATH/SSH_KEY_PATH above, this is a path INSIDE the container.
# MEMPALACE_PALACE_PATH=/home/developer/.mempalace/palace
#
# To instead share ONE MemPalace across containers/harnesses (pi + opencode
# + native), set the URL below. When set, the extension connects over HTTP
# and NO local mempalace-mcp is spawned; the devbox-palace volume is then
# irrelevant. MEMPALACE_REMOTE_TOKEN, if set, is sent as a bearer token.
# Serve it with: mempalace-mcp --transport http --host 0.0.0.0 --port 8765
# MEMPALACE_REMOTE_URL=http://mempalace.lan:8765/mcp
#
# Serve it with: mempalace serve --host 172.17.0.1 --port 8765
#
# NOT `mempalace-mcp --transport http --host 0.0.0.0`: `serve` is the turnkey
# wrapper that mints/keeps a bearer token (0600, passed via env so it stays out
# of `ps`) and can terminate TLS. Two binds to avoid:
# 0.0.0.0 - exposes the palace to the whole LAN.
# 127.0.0.1 - behind a tunnel this 403s every proxied request (the Host pin
# is only enforced on loopback binds) AND silently starts with
# no token at all, since auto-minting is gated on the bind being
# non-loopback. Bind the docker0 gateway: reachable from the host
# and its containers (so a newt/proxy container works), not from
# the LAN. Set MEMPALACE_MCP_HTTP_TOKEN explicitly server-side.
# MEMPALACE_REMOTE_URL=https://mempalace.example.com/mcp
# MEMPALACE_REMOTE_TOKEN=
# ── MemPalace: automatic capture of pi sessions ───────────────────────
# The mempalace.ts extension feeds this container's pi transcripts into the
# palace by itself: on session_shutdown, and on a debounced agent_settled so a
# crash loses at most one window rather than the whole session. The entrypoint
# also runs a catch-up at container start, which is the only thing that can
# recover transcripts after a hard kill (no handler runs on SIGKILL).
# Nothing below is required for the local-palace case; the defaults work.
#
# MEMPALACE_FEED=0 # disable automatic capture entirely
# MEMPALACE_FEED_DEBOUNCE_MS=600000 # min gap between mid-session feeds (10 min)
# MEMPALACE_FEED_WING=wing_conversations
#
# REMOTE PALACE ONLY (MEMPALACE_REMOTE_URL set above): the palace is on another
# host, and `mempalace_mine` resolves its source path in the SERVER process, so
# the server cannot see this container's transcripts. The feeder therefore
# rsyncs its staged exports into a per-device inbox on the palace host and asks
# the server to mine its own local copy. Without MEMPALACE_PI_SSH_TARGET the
# feeder is skipped (a remote palace with no inbox has nothing to mine).
# MEMPALACE_PI_SSH_TARGET where to rsync to, as user@host:path
# MEMPALACE_PI_REMOTE_PATH what that inbox is called ON THE SERVER — i.e. the
# path the SERVER PROCESS can open. If the palace
# server runs in Docker, that is the container path
# (see docker-compose.mempalace.yml). If it runs
# NATIVELY (systemd unit / uv tool / plain
# `mempalace serve`), it sees host paths, so this
# must equal the path half of
# MEMPALACE_PI_SSH_TARGET. Getting this wrong is
# quiet: rsync still succeeds and only the mine
# fails with "source directory not found", so
# transcripts ship and are filed nowhere. The feeder
# warns in preflight when the two paths disagree.
# MEMPALACE_PI_DEVICE inbox subdirectory for this machine (default: hostname)
# MEMPALACE_PI_SSH_TARGET=user@palace-host:/srv/mempalace-feed
# MEMPALACE_PI_REMOTE_PATH=/data/feed
# MEMPALACE_PI_DEVICE=
# ── LAN access from the container (host-OS-agnostic) ─────────────────
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
# reach the host's directly-attached LAN peers by default. The entrypoint
@@ -52,6 +117,13 @@ SSH_KEY_PATH=~/.ssh
# DEVBOX_ATELIER=1
# ── Git Configuration ────────────────────────────────────────────────
# Set BOTH. If unset, every repo inside the container fails with
# "Author identity unknown" on first commit, and an agent asked to commit
# will guess an identity from git log — often the wrong one. The e-mail is
# per-machine (work machines use the corporate address, personal machines the
# private one), so it belongs in this per-machine .env, never in a skill or a
# repo-local override. Consumed by entrypoint-user.sh -> ~/.gitconfig, which is
# NOT persistent across container recreate — this file is the source of truth.
GIT_USER_NAME=
GIT_USER_EMAIL=
+76 -11
View File
@@ -18,6 +18,14 @@ name: Publish Docker Image
# 5. build-variant multi-arch push of latest + vX.Y.Z tags.
# 6. promote-base-latest re-tag base-<hash> → base-latest with `crane copy`.
# 7. update-description patch Docker Hub description.
#
# Note the trigger: `push: tags: v*` (plus workflow_dispatch). Nothing here runs
# on a push to main, so a smoke assertion added outside a release is UNVALIDATED
# until the next tag — which is exactly how v1.8.0 shipped a broken assertion
# written three days earlier (it asserted a literal /home/developer stage path,
# while `run` executes `docker run --entrypoint=""` as root with HOME=/root).
# The `smoke_only` dispatch input exists to close that gap: it runs steps 1-4
# against HEAD and stops before anything is published.
on:
push:
@@ -33,6 +41,10 @@ on:
description: 'Update latest aliases (default true for tag-push, false for manual test runs)'
required: false
default: 'false'
smoke_only:
description: 'Build base + run both smoke jobs against HEAD, then stop. Publishes nothing. Use to validate smoke assertions without cutting a tag.'
required: false
default: 'false'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -165,6 +177,40 @@ jobs:
fi
}
# Read a commit SHA from Gitea, surviving a bad build token.
#
# These repos are public (see the note at the call sites), so auth is
# a convenience, not a requirement — but Gitea REJECTS an invalid
# token (401) rather than ignoring it, so a revoked or malformed
# GITEA_BUILD_TOKEN could fail an entire release on reads that work
# fine anonymously. An ABSENT secret was always safe (Gitea ignores an
# empty `token ` value and serves the request, 200); a STALE one was
# not. So: try authed, and on 401/403 retry anonymously.
#
# A non-200 after that emits nothing and returns 0 deliberately, so
# require_sha raises the loud explicit abort rather than this helper
# inventing a fallback ref.
#
# Messages go to STDERR, not as ::warning:: annotations: this
# function's stdout IS the SHA, so anything written there would be
# captured into the ref by the command substitution.
gitea_sha() { # $1=repo
local repo="$1" url resp code
url="https://gitea.jordbo.se/api/v1/repos/joakimp/${repo}/commits?limit=1&sha=main"
resp=$(curl -s -w '\n%{http_code}' -H "$AUTH_HEADER" "$url" || printf '\n000')
code=${resp##*$'\n'}
if [ "$code" = "401" ] || [ "$code" = "403" ]; then
printf 'WARNING: Gitea rejected the build token for %s (HTTP %s); retrying anonymously. The read should succeed (public repo), but GITEA_BUILD_TOKEN is stale or malformed and should be rotated.\n' "$repo" "$code" >&2
resp=$(curl -s -w '\n%{http_code}' "$url" || printf '\n000')
code=${resp##*$'\n'}
fi
if [ "$code" != "200" ]; then
printf 'WARNING: Gitea commit lookup for %s returned HTTP %s\n' "$repo" "$code" >&2
return 0
fi
printf '%s' "${resp%$'\n'*}" | jq -r '.[0].sha // empty' 2>/dev/null || true
}
# ── pi version: from the PIN, not from npm `latest` ───────────
# Until v1.7.0 this followed npm `latest`, which meant every release
# silently adopted whatever pi had shipped that morning — unaudited —
@@ -226,15 +272,27 @@ jobs:
echo "atelier_ref=${ATELIER_REF}" >> "$GITHUB_OUTPUT"
echo "atelier_tag=${ATELIER_TAG}" >> "$GITHUB_OUTPUT"
# pi-toolkit / pi-extensions (Gitea) → commit SHAs. Gitea API
# requires auth even for public-repo commit listing.
TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-toolkit/commits?limit=1&sha=main" \
| jq -r '.[0].sha // empty' 2>/dev/null || true)
# pi-toolkit / pi-extensions (Gitea) → commit SHAs. All three Gitea
# repos read in this step are PUBLIC: an unauthenticated GET of these
# commit endpoints returns 200 with the IDENTICAL sha (verified
# 2026-08-15 for pi-toolkit, pi-extensions and mempalace-toolkit).
# The comment that used to sit here claimed the Gitea API "requires
# auth even for public-repo commit listing" — it does not. Only
# /api/v1/repos/*/actions/* refuses anonymous reads (401), which is
# what that claim was almost certainly generalised from.
#
# The header is still passed on purpose: it keeps working if a repo is
# ever flipped private, and an ABSENT secret degrades cleanly, because
# Gitea ignores an empty `token ` value and serves the request
# anonymously (200). The real hazard is the opposite one — a REVOKED or
# malformed token returns 401 where anonymous would have returned 200,
# so a stale GITEA_BUILD_TOKEN turns a healthy public read into a
# require_sha failure that reads like an API or network fault. If this
# step ever fails on a repo you can browse anonymously, suspect the
# token before you suspect Gitea.
TOOLKIT_REF=$(gitea_sha pi-toolkit)
require_sha PI_TOOLKIT_REF "$TOOLKIT_REF"
EXTENSIONS_REF=$(curl -sf -H "$AUTH_HEADER" \
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-extensions/commits?limit=1&sha=main" \
| jq -r '.[0].sha // empty' 2>/dev/null || true)
EXTENSIONS_REF=$(gitea_sha pi-extensions)
require_sha PI_EXTENSIONS_REF "$EXTENSIONS_REF"
echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT"
@@ -244,9 +302,7 @@ jobs:
# into the base-decide hash (see that job) to force a base rebuild
# when the toolkit moves — otherwise a toolkit-only fix silently
# fails to land unless Dockerfile.base itself changes.
MEMPALACE_TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
"https://gitea.jordbo.se/api/v1/repos/joakimp/mempalace-toolkit/commits?limit=1&sha=main" \
| jq -r '.[0].sha // empty' 2>/dev/null || true)
MEMPALACE_TOOLKIT_REF=$(gitea_sha mempalace-toolkit)
require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF"
echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
@@ -482,6 +538,14 @@ jobs:
# ── Phase 4: multi-arch publish ─────────────────────────────────────
build-variant:
needs: [base-decide, smoke, resolve-versions]
# A `smoke_only` dispatch stops the pipeline here: base is probed/built and
# both smoke jobs run, but nothing is published. Deliberately NOT wrapped in
# always() — specifying `if:` keeps the implicit "all needs succeeded" gate,
# so a failing smoke still blocks the release. On a tag push `inputs` is
# unset, and `null != 'true'` is true, so releases are unaffected.
# promote-base-latest and update-description need build-variant to have
# succeeded, so they skip on their own — no extra guard required.
if: inputs.smoke_only != 'true'
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
@@ -574,6 +638,7 @@ jobs:
# or fail independently of the core release.
build-variant-studio:
needs: [base-decide, smoke-studio, resolve-versions]
if: inputs.smoke_only != 'true'
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
+37 -4
View File
@@ -76,10 +76,15 @@ re-brand of opencode-devbox's `pi-only` variant.
4. Push tag: `git tag vX.Y.Z && git push origin vX.Y.Z`.
5. Watch CI: smoke job builds amd64 only and asserts size + extensions +
pi version + new-base-tooling presence. Variant build is multi-arch
(amd64 + arm64) only after smoke passes. **A tag push produces two runs, not
one** — `lint.yml` fires on every push (including tag refs) and
`docker-publish.yml` fires on `v*` tags. Watch the **publish** run; see
*Gitea API access* below for how to find it without picking lint by mistake.
(amd64 + arm64) only after smoke passes. A tag push fires **only**
`docker-publish.yml` — `lint.yml` is scoped to `branches: ['**']`, which
excludes tag refs on purpose (the tagged tree was already linted when the
commit hit `main`, and a fast lint run sorting above the slow publish run
made releases look finished before anything shipped). Verified on v1.8.4:
`refs/tags/v1.8.4` produced run 571 (publish) and nothing else. Still filter
discovery on `head_sha` **and** the workflow `path` — see *Gitea API access*
below — because that guard costs nothing and a future workflow added on `v*`
would silently reintroduce the ambiguity.
6. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
base-latest if the base was rebuilt this run).
7. **Revoke any short-lived Gitea PAT** used during the release at
@@ -87,6 +92,34 @@ re-brand of opencode-devbox's `pi-only` variant.
`GITEA_ACCESS_TOKEN` env var instead (see *Gitea API access* below) —
its lifecycle is managed host-side, nothing to revoke.
## Verifying this repo's reality from inside a container
Most work on this repo happens **inside** a pi-devbox container, inspecting a
host or a peer over SSH. That setup manufactures convincing false negatives, so
when you are about to report that something is **absent, unreachable, or not
running**, suspect your own command first. Recurring instances:
- **`docker` is not on the host's non-interactive SSH `PATH`.** `ssh mac 'docker
ps'` says *command not found* on a host that plainly runs Docker; use
`/usr/local/bin/docker` (or `command -v docker` first). Every step in the
*Release-day checklist* that inspects a running container hits this.
- **Don't `| head -N` a search whose answer you don't already know.** The host's
`~/.ssh/config` is ~500 lines; a `head -20` "proved" a peer absent that was
defined at line 454.
- **The deployment compose file is not this repo's.** `docker-compose.yml` here
is a template pinning `:latest`; a real host runs its own per-machine file
(find it with `docker inspect <container> --format '{{ index .Config.Labels
"com.docker.compose.project.config_files" }}'`). Recreating from the repo copy
can silently move a host off `:latest-studio` onto `:latest`.
- **A live SSH ControlMaster hides remote auth changes** — after editing a
peer's `authorized_keys`, prove access with `-o ControlPath=none -o
ControlMaster=no`, or the breakage surfaces in a later session instead.
Depth and further mechanisms: the repo-authored `pi-devbox-environment` skill
(`rootfs/usr/local/share/pi-devbox/skills/pi-devbox-environment/SKILL.md`) §2
and §3 — that file is the one an agent actually loads mid-session, whereas this
`AGENTS.md` is only auto-read when the cwd *is* this repo.
## Gitea API access (env token)
`GITEA_ACCESS_TOKEN` + `GITEA_HOST` are passed into the container from the
+921
View File
@@ -11,6 +11,927 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
---
## v1.8.6 — 2026-08-25
Patch release. Adopts the drift that accumulated in the ~2 days since v1.8.5
(pi `0.84.3`, mempalace core `3.8.0`), then closes the documentation and
observability gaps that v1.8.5 itself listed as "Still open". No component
was adopted without an audit note recording *why* it is safe.
All moving refs re-resolved immediately before tagging (2026-08-25T13:28Z):
pi-toolkit `0e1369e6`, pi-extensions `20228878`, mempalace-toolkit `0fe64c48`
and pi-observational-memory `ce9fc982` all unchanged since v1.8.5;
pi-fork `f1ff8087` → `bf702b4c`; pi-atelier holds at `v0.8.2` (floor for
pi ≥0.84 satisfied); pi-studio's CI-resolved newest tag has moved again to
`v0.9.51`. Base rebuild is forced (Dockerfile.base changed), so the 16
floating base-tooling ARGs re-roll — expect ~67 min as for v1.8.5.
### Changed
- **`mempalace` core `3.7.1` → `3.8.0`.** Released 2026-08-23T21:19Z, hours
after this project's own v1.8.5 tag the same day. Additive/reliability only
— reviewed for MCP tool-schema changes before bumping, as always: none.
`sync --apply` (PR #2320/#2322) no longer deletes a drawer solely because
its `source_file` was unreachable *at that moment* — it asks for
corroboration first. **This does not relax the standing landmine** against
running `mempalace_sync` / `mempalace_delete_by_source` beyond dry-run on
the shared central palace: that failure mode is paths *permanently* absent
from whichever host runs the sync, not transient unavailability, and 3.8.0
doesn't touch it. Server-side perf fix PR #2307 (long-running Chroma servers
no longer invalidate their own HNSW cache on their own writes) likewise does
not make `mempalace_reconnect` unnecessary — that tool covers *external*
writes bypassing the in-process client, a different scenario. Full reasoning
lives in the `Dockerfile.base` comment above `ARG MEMPALACE_VERSION`.
**Deployment note:** synlig's central palace currently serves `3.7.1`
server-side via `docker-compose.mempalace.yml` (which reuses this image) —
this client bump introduces version skew until that stack is separately
redeployed; sequence accordingly.
- **`pi` `0.84.2` → `0.84.3`.** Published 2026-08-24T11:09Z. Release notes
carry one "Breaking Changes" line — `GoogleThinkingLevel` renamed to
`GoogleApiThinkingLevel` — checked against all four vendored packages
(`pi-fork`, `pi-observational-memory`, `pi-atelier`, `pi-studio`): zero
references, inert here. 0.84.3 also fixes two skill-discovery bugs that
land directly on this repo's own vendored-skill work: nested Markdown
skills inside `.agents/skills/` grouping directories not being discovered,
and root Markdown files (`README.md`/`AGENTS.md`) in skill directories being
wrongly reported as broken skills.
### Added
- **Browser automation is now documented to humans, not just to agents.**
`agent-browser` + Playwright + a headless Chromium (~625 MB — the single
largest addition in the image) previously had zero mentions in `README.md`,
`DOCKER_HUB.md` or `THIRD_PARTY.md`; it existed only in the agent-facing
`AGENTS.md` managed block. Added a `README.md` "Browser automation"
subsection, a `DOCKER_HUB.md` feature entry, and `THIRD_PARTY.md` license
rows for `agent-browser` (Apache-2.0), Playwright (Apache-2.0), and Chromium
(BSD-3-Clause for Chromium's own code plus a large set of bundled
third-party components under their own licenses; the binary here is not
compiled by this repo — it's Playwright's own "Chrome for Testing" download
via `playwright install --with-deps chromium`).
- **`THIRD_PARTY.md` gains rows for `pi-atelier` (MIT) and `mempalace` core
(MIT per the GitHub repo; noted that the PyPI package's own metadata omits
a license classifier, so verify against the repo's `LICENSE` rather than
sdist/wheel metadata if clearance is needed from the artifact alone).**
- **`typst` and `socat` added to `README.md`'s tooling inventory.** Both were
already used in prose (typst as pandoc's `--pdf-engine`, socat by
`studio-expose`) but missing from the "What's inside" lists, so the
inventory didn't match what the image actually ships.
- **`mempalace` core version recorded in `/etc/pi-devbox/build-manifest.json`.**
Previously absent — a published image couldn't answer "which palace version
shipped?", and a palace bug couldn't be correlated to an image version.
Derived from the live installed binary (matching the manifest's existing
ground-truth-not-build-args philosophy), degrading to `null` rather than
failing the build if the binary is missing or its output format changes.
Verified landed: new top-level `"mempalace_version"` key, sibling to
`pi_version` rather than a member of `components{}` (that map is rendered
truncated to 12 chars by `pi-devbox-version`, which would mangle a longer
version string).
- **New smoke assertions**, all landed in `scripts/smoke-test.sh`: (1) the
`pi-observational-memory` clone is checked for the actual `ce9fc98`
auth-fix markers pinned to their fix site, `src/runtime.ts`
(`availability_recheck`, `providerCredentialConfigured`,
`hasConfiguredAuth`) — not merely clone existence, and deliberately not a
repo-wide grep: all three identifiers also appear under `tests/`, so a
repo-wide search would stay green even with the fix reverted in
`src/runtime.ts` alone; (2) the manifest's new `mempalace_version` field is
asserted present, non-null, and equal to what `mempalace --version` reports
live, so the manifest can't silently drift from the installed package —
expected to fail against any pre-v1.8.6 image, by design; (3) a
behavioural check for the mempalace-toolkit feeder's `--agent` default
(see below — this one turned out to be possible after all).
### Fixed
- **A false claim was being published to Docker Hub on every release.**
`DOCKER_HUB.md` advertised "neovim (LazyVim defaults)". Nothing in this
repo installs LazyVim — the only nvim configuration is a 19-line
`sysinit.vim` that sets `termguicolors`. `update-description` pushes this
file verbatim (with `{{PI_VERSION}}` substituted) to the Hub description, so
the error was public, not internal. Corrected to describe what's actually
there.
### Component audit for this release
Checked against upstream 2026-08-25 (two days after v1.8.5's own audit):
`mempalace` core moved `3.7.1` → `3.8.0` (see Changed, above — timing is
notable: released *hours after* v1.8.5 tagged, so v1.8.5 could not have caught
it no matter how carefully it was audited). `pi` moved `0.84.2` → `0.84.3`
(see Changed). `pi-toolkit` `0e1369e6`, `pi-extensions` `20228878`,
`pi-observational-memory` `ce9fc982`, and `pi-atelier` `v0.8.2` are all
**unchanged** from v1.8.5 — in particular `pi-observational-memory` still sits
exactly at the auth-fix commit with nothing landed upstream since, and
`pi-atelier` is still the newest tag with the `≥0.7.1` floor for `pi ≥ 0.84`
trivially satisfied. `pi-fork` has one upstream commit not adopted this
release: `f1ff8087` → `bf702b4c`, a text-only rewording of the fork task
preamble (no code-path change) — **left un-pulled** for this release since it
is a moving ref CI resolves fresh at every build anyway; it will be adopted
automatically on the next build regardless of this entry. `pi-studio` (studio
variant) has drifted two tags upstream, `v0.9.48` (pinned at build time via
CI's newest-semver-tag resolution) → `v0.9.51` at tag time, purely additive
(watched PDF previews, opening PDFs directly in Studio, Studio header
hide) — nothing to bump in this repo since studio-tag resolution happens in
CI, not the Dockerfile, but note it **will** auto-adopt `v0.9.51` on the next
studio-variant build. `mempalace-toolkit` unchanged — this release's manifest
and pi-bump work in `Dockerfile.variant` stayed within that file's ownership
and did not require a toolkit-side change.
### Still open
- **`MEMPALACE_VERSION` has no CI-side audit equivalent to `PI_VERSION`'s.**
`PI_VERSION` is verified published-on-npm and warns (never silently adopts)
on drift; `MEMPALACE_VERSION` is a literal Dockerfile string with zero
references in `.gitea/workflows/docker-publish.yml`. Flagged in v1.8.5's
audit as a gap; still a gap.
- **`pi-devbox-version`'s human-readable output does not display
`mempalace_version`.** Its render path is a fixed sequence
(`release_tag`, `build_date`, `source_revision`, `pi`, then `components{}`)
and the new top-level field isn't in it — only `--json` mode (which `cat`s
the manifest directly) surfaces it today. One line in
`rootfs/usr/local/bin/pi-devbox-version` would fix this; deferred since the
field's stated purpose (correlating a palace bug to an image) is already
served by `--json`, but worth doing in a follow-up if this becomes a
routine manual check.
**Resolved during this release, not left open:** the feeder `--agent`
default behavioural hook initially looked like it might need a
mempalace-toolkit change (a `--print-config` flag that doesn't exist). It
didn't — `mempalace-pi-session` assigns `AGENT` before argument parsing and
`--help` exits 0 with no side effects, so `bash -x mempalace-pi-session
--help` observes the real resolution (env interpolation and fallback)
without needing a source change. The new smoke assertion exploits exactly
that, checked both ways: with `MEMPALACE_PI_DEVICE` set it must resolve to
`pi@<device>`; with it unset it must NOT be `pi@*` (catches a regression to
the old unconditional `$USER`/`mempalace` default).
`mempalace-toolkit` commit `c64ffa1` changed the feeder's `--agent` default
from `$USER` to `pi@<device>`, but there is still no way for smoke to assert
this default is actually in effect from this repo alone, since
`mempalace-toolkit` is a separate repo this release does not modify. If the
concurrent smoke-test work could not find an honest assertion from the
existing `/opt/mempalace-toolkit` surface (help text, `--self-test`), this
remains open pending a toolkit-side `--print-config`-style hook — a
toolkit-repo change, not a pi-devbox one.
- **16 base-tooling `ARG *_VERSION=latest` pins remain unrecorded.** (Corrected
count — v1.8.5's entry said "~14"; the actual count from `Dockerfile.base`
is 16, plus 5 more that float with no ARG at all: `rustup-init`, AWS CLI v2,
Chromium-via-Playwright, Node's minor version via `setup_22.x`, and
`DEBIAN_VERSION=trixie-slim` itself.) None of these are recorded anywhere
once the build completes — not in the manifest, not in a label — so a
published image cannot answer "which nvim/uv/chromium shipped?" without
exec-ing in and asking the binary.
### Documentation
- **`.env.example` documents `MEMPALACE_PALACE_PATH`.** It was the only MemPalace
variable the template never mentioned, while being the one that silently moves
the feeders' stage: the palace root resolves as `$MEMPALACE_PALACE_PATH` →
`$MEMPAL_PALACE_PATH` → `~/.mempalace/config.json` → `~/.mempalace/palace`, and
the stage is derived from it (`<palace-root>/pi-stage`). The comment states the
precedence, says why neither the image nor the entrypoint exports it (pinning
the palace without carrying the stage re-creates the split a shared root
removed — see v1.8.2), warns that a stage whose persistence differs from the
palace makes a scoped `mempalace sync` prune conversation drawers whose dedup
key is the staged path, and notes it is a *container* path unlike the
host-side `WORKSPACE_PATH`/`SSH_KEY_PATH` above it. Found while auditing a live
host whose `.env` sets the variable redundantly to the default.
---
## v1.8.5 — 2026-08-23
Patch release with two fixes in the container's skill wiring — one behavioural,
one a latent crash found while reviewing the first — plus the `mempalace-toolkit`
change that makes palace writes carry provenance. **No component pin moved.**
Every ref was re-resolved at tag time and is byte-identical to what v1.8.4
shipped: `pi` `0.84.2` (still npm latest), `pi-atelier` `v0.8.2` → `159f34cf`
(newest tag; the `≥0.7.1` floor for `pi ≥ 0.84` holds), `pi-studio` `v0.9.48` →
`c3b83680`, `pi-fork` `f1ff8087`, `pi-observational-memory` `ce9fc982`,
`pi-toolkit` `0e1369e6`, `pi-extensions` `20228878`, `MEMPALACE_VERSION` `3.7.1`
(still PyPI latest, and the version the central palace serves — no client/server
skew). The single moving part is `mempalace-toolkit` `fd8b15f5` → `0fe64c4`.
### Fixed
- **Vendored skills no longer silently shadow their live skillset
counterparts.** `~/.agents/skills` was *asymmetric*: `mempalace`,
`pi-devbox-environment` and `pi-extensions` resolved to the baked
`/usr/local/share/pi-devbox/skills/…`, while every other skill resolved to the
live `/workspace/skillset/skills/…`. Root cause was precedence-by-ordering in
`entrypoint-user.sh`: the baked links are created **early** (line 65 in
v1.8.4; the loop moved down as this fix added comments) — deliberately so, to
close a smoke-test readiness race — with `[ ! -e … ]` so they are
"created only when absent", and the skillset deploy runs **last**, where it
classifies the existing links as foreign and leaves them alone. The comment at
line 61 claimed the goal was "a same-named skillset skill … is never
clobbered" — but with baked-first plus create-when-absent, the *skillset* skill
was precisely the one that lost. Comment now describes the actual behaviour.
Observed cost, on two hosts independently: an edit to
`skillset/skills/mempalace/SKILL.md` (adding a drawer-attribution rule) was
pushed and present in the live clone (`md5 129bcc4752`), yet both the
EMB-7KJ4VR4G and tor-ms22 containers kept loading the baked copy
(`md5 5236024fef`) with zero occurrences of the new rule. The tor-ms22 agent
had to fetch the rule from the Gitea API to read it at all. Editing a skillset
skill therefore *appeared* to work and silently did nothing until an image
rebuild — for exactly the three skills most likely to be iterated on.
**The fix is not "the skillset always wins"**, because ownership is per-skill
(`rootfs/usr/local/share/pi-devbox/skills/VENDORED.md`): `pi-extensions`'
authoritative source is the *package* repo, copied over the snapshot at build
time, and `skillset` carries a downstream copy that can lag — handing that one
to the clone would regress the skill. So a new helper
`devbox-skill-reconcile` runs immediately after the skillset deploy and
repoints only the skills named in `skills/skillset-owned.txt` (today:
`mempalace`). Precedence is now user override → live skillset clone (owned
names only) → baked snapshot, with the early links untouched as the fallback,
so the readiness race stays closed. It only ever replaces a symlink that
points into the baked tree, so a real directory or a link pointing elsewhere
is never disturbed. Verify with `readlink -f ~/.agents/skills/mempalace`, not
by reading the entrypoint.
- **A latent boot-abort in the baked-link block, found while reviewing the fix
above and fixed with it.** `[ ! -e "$link" ]` is TRUE for a *dangling* symlink
(`-e` follows the link), so once a link may point into `/workspace/skillset`
— which the fix above makes possible — a vanished mount turns the guard into
"create over a broken link", and plain `ln -s` then fails with `File exists`.
Under the entrypoint's `set -euo pipefail` that **aborts container start**
before `exec "$@"`, with a cryptic `ln` error and no pi. Reachable on a
`docker restart` or a host reboot under `restart: unless-stopped` (the writable
layer survives and `~/.agents` is not a volume on any host), though not on a
`compose up -d` recreate. Now `ln -sfn`, which heals the broken link back to
the baked fallback; the reconciler re-points it in the same boot if the clone
is back. A comment at the call site records why the `-f` must stay.
- **README's skill-precedence documentation was wrong** in the same way the
entrypoint comment was: it claimed baked skills are "created only when absent
so a same-named skillset skill … is never clobbered" and that "a mounted
skillset always overrides them". Rewritten to state the real, per-skill
precedence and to name `skillset-owned.txt` and `devbox-skill-reconcile`.
- **The smoke canary for a stale `mempalace` snapshot could not detect
staleness.** It grepped `"Shared palace: multiple harnesses"` — a phrase
present in *both* the stale and the fresh copy, so it passed throughout the
shadowing bug above. It now pins the newest section
(`"Attribute what you file yourself"`), and `VENDORED.md` records that
updating this string is part of refreshing the snapshot. Three further
assertions close the gaps that let the bug ship: skill link **targets** are
asserted (not merely `test -L`), the `skillset-owned.txt` list is asserted to
contain `mempalace` and *not* `pi-extensions`, and the reconciler's replace
path — which CI never exercises, since no smoke container mounts a skillset —
is covered by fabricating a skillset and asserting all three outcomes (owned
skill repointed, unowned skill left baked, user override untouched) — plus a
second case that a mutation test proved necessary: with the reconciler's
"is this link ours?" guard deleted, all three of those assertions still
passed, so the discriminating case is an *owned* name whose link is a user
override pointing outside the baked tree.
### Changed
- **Vendored `mempalace` skill snapshot refreshed** from `skillset` `936fed8` →
`670f7f1` (`md5 5236024fef` → `129bcc4752`), which adds the "Attribute what
you file yourself" rule: hand-filed drawers should carry
`added_by="<harness>@<device>"`. Without this refresh the symlink fix above
would only help hosts that mount `skillset`; a bare container would still ship
the pre-attribution-rule skill.
- **Component audit for this release — no pin edits needed.** Every component
except `pi`/`pi-atelier` is pinned to a moving ref that CI resolves at build
time, and each was checked against upstream on 2026-08-23: `pi` `0.84.2`
(still npm latest, published 2026-08-14), `pi-atelier` `v0.8.2` (newest tag;
the `≥0.7.1` floor for `pi ≥ 0.84` is satisfied), `pi-fork` `f1ff8087`,
`pi-observational-memory` `ce9fc982`, `pi-toolkit` `0e1369e6`,
`pi-extensions` `20228878`, `pi-studio` `v0.9.48` → `c3b83680` — all
**byte-identical to what v1.8.4 shipped**. `MEMPALACE_VERSION` stays `3.7.1`
(still PyPI latest, and the version the central palace serves, so no
client/server skew). The one component that moved is `mempalace-toolkit`
`fd8b15f5` → `0fe64c4`, which is this release's other payload: the feeder now
defaults `--agent` to `pi@$MEMPALACE_PI_DEVICE` so palace writes carry
provenance, with `$USER` still the fallback when the variable is unset
(`AGENT="${MEMPALACE_PI_DEVICE:+pi@${MEMPALACE_PI_DEVICE}}"`), so un-enrolled
hosts are unaffected. Nothing landed upstream after the
`pi-observational-memory` merge `ce9fc982`, so the eight-week-bug fix in
v1.8.4 is not destabilised. All of the above was re-resolved immediately
before the tag and was unchanged — worth repeating for any future release,
because six of nine components are moving refs that CI resolves at build
time, so the build, not the Dockerfile, decides what ships.
Two notes for whoever runs the build. This release changes
`entrypoint-user.sh`, `rootfs/**` and the resolved toolkit SHA — all three feed
the base-image hash — so expect a **full multi-arch base rebuild** (~95 min,
as on v1.8.3/CI 562), not a fast variant-only publish. And that rebuild
re-resolves the ~14 base-tooling `ARG *_VERSION=latest` pins; measured drift
on 2026-08-23 was one patch (`nvim v0.12.4 → v0.12.5`), so the window is
favourable, but it is not covered by version assertions.
- **`pi-devbox-environment` skill — new §2 subsection "A negative result is
usually your own filter", plus ControlMaster masking in §3.** This is baked
(`rootfs/usr/local/share/pi-devbox/skills/`, symlinked to
`~/.agents/skills/`), so it is an image-behaviour change even though no
package moved. Motivated by three false negatives an agent produced in a
single session, each from its own filter rather than from the world: a
`| head -20` "proved" an SSH peer absent that was defined at **line 454** of a
~500-line config; `ssh mac 'docker ps'` "proved" the host had no Docker, when
the non-interactive SSH `PATH` simply lacks `/usr/local/bin`; and a `grep 'ssh
'` "proved" no ControlMaster was running, when master processes **rename
themselves** to `ssh: <controlpath> [mux]`. The rule now stated: a positive
result carries its own evidence, absence has to be *earned*. §3 additionally
documents that a live master socket makes later commands authenticate **not at
all**, so "it still works" proves nothing after editing a peer's
`authorized_keys` — verify with `-o ControlPath=none -o ControlMaster=no`, or
the breakage surfaces in a future session with no memory of the edit.
- **`AGENTS.md`: a stale CI claim corrected.** It said "a tag push produces two
runs, not one — `lint.yml` fires on every push (including tag refs)". That
stopped being true when lint was scoped to `branches: ['**']`, which excludes
tag refs by design; `refs/tags/v1.8.4` produced run 571 (publish) and nothing
else. The `head_sha` + workflow-`path` filter advice stays, because it costs
nothing and any future `v*`-triggered workflow would reintroduce the
ambiguity. Also adds a short "Verifying this repo's reality from inside a
container" section, including the trap that **this repo's `docker-compose.yml`
is a template pinning `:latest`** while a real host runs its own per-machine
file — so recreating from the repo copy can silently move a host off
`:latest-studio`.
---
### Still open
- `build-manifest.json` records the `mempalace-toolkit` SHA but **not** the
mempalace **core** version, so a palace bug cannot be correlated with an
image. `mempalace --version` prints it; adding it is a one-line change to the
manifest `RUN` in `Dockerfile.variant` plus one smoke assertion, and is
variant-only (no base rebuild cost).
- Smoke asserts the `pi-observational-memory` clone **exists** but not that it
contains the ambient-auth fix. npm still ships pre-fix `3.0.4`, so an
accidental switch from the `/opt` clone to an npm install would be a silent
regression. Cheap guard: `grep -rl availability_recheck` must be ≥1.
- The feeder's new `pi@<device>` default has no behavioural test hook
(`--dry-run` never prints the agent; `--self-test` only covers the remote-mine
response classifier). Cheapest available check is a source-shape grep for
`MEMPALACE_PI_DEVICE:+pi@`.
---
## v1.8.4 — 2026-08-22
Patch release, and the one that ends an eight-week bug: **the baked
`pi-observational-memory` finally records observations on a Bedrock host that
uses ambient AWS credentials.** The fix is ours, but it is no longer a patch —
upstream merged it, so this release picks it up through the ordinary
`PI_OBSMEM_REF=master` path with no local carry. Also **bumps pi-atelier
`v0.8.1` → `v0.8.2`** (audited below) and bakes the `todo` extension's new
`edit` action. `pi` stays `0.84.2` (still the npm latest, published
2026-08-14) and `MEMPALACE_VERSION` stays `3.7.1` (still the PyPI latest).
### Fixed
- **om consolidation on request-time-signed providers — upstream, not patched
(`pi-observational-memory` `37986b6` → `ce9fc98`).** Under `37986b6`, om's
pre-flight gate treated "pi exposes no `apiKey` and no auth header" as
*unauthenticated* and skipped every consolidation. On Bedrock with ambient
AWS credentials that is the normal case — pi signs SigV4 at request time —
so om recorded **nothing for eight weeks** with no error, no cost and no log
line. Every pi-devbox image up to and including v1.8.3 has that behaviour.
Two commits, both authored here and now upstream verbatim: `6f694e6` fixes
the gate itself (it must not require a credential *payload*), and `699ccc7`
adds the second half of pi's own rule — `hasConfiguredAuth` reads an
availability snapshot that stays empty when the startup availability pass was
skipped/aborted/failed, so on the otherwise-fatal path om now asks pi to
re-check the credential live (refresh scoped to the provider, network-free),
rate-limited 60 s per provider, bounded by a raced timeout, logged as
`resolve.availability_recheck`. Filed as upstream issue #51, merged as PR #52
(`ce9fc98`, 2026-08-22T04:46:18Z), which also carries PR #49's `env`/`baseUrl`
forwarding merged four minutes earlier; the maintainer resolved the textual
conflict between them keeping both behaviours. Verified on `ce9fc98` here:
`tsc --noEmit` clean, `vitest` 257 tests / 27 files green.
**Note for anyone carrying the local workaround:** the interim fix was a
`packages[]` override in `~/.pi/agent/settings.json` pointing pi at a patched
clone outside the image. From this release on, delete the override — the
baked `/opt/pi-observational-memory` has the fix. Confirm with
`/etc/pi-devbox/build-manifest.json` → `components.pi-observational-memory`
before removing it. The npm-published `pi-observational-memory` is **still
`3.0.4` and still broken**; the image does not use npm for this component, so
the release cadence there is irrelevant to us.
### Changed
- **`PI_ATELIER_REF` / `PI_ATELIER_VERSION` `v0.8.1` → `v0.8.2`, audited per the
floor note above the ARG.** Only one version sits between old and new and its
changelog is two lines, both Workspace-Pulse-internal: inspection requests are
now coalesced and serialized so short Turns avoid duplicate Git work and
overlapping inspections cannot run concurrently, and live tool-driven Pulse
updates are preserved while a fresh inspection is guaranteed at Turn end and
retired sessions can no longer publish stale results. **Nothing touches pi's
private TUI renderer**, which is the coupling that produced the
0.6.0/0.7.0-under-pi-0.84 startup hang, and `pi` is unchanged at `0.84.2`, so
this bump does not re-enter that risk class. Both the seam and the pin floor
(never pair pi-atelier < 0.7.1 with pi >= 0.84) are unaffected.
- **`pi-studio` `65995fe` (0.9.44) → `v0.9.48`** — 14 commits, four releases.
Studio-side only (`INSTALL_STUDIO=false` by default, so this lands in the
studio variant): open Studio in Muxy's browser, local PDF preview actions,
previews survive Pandoc probe failures, legacy LaTeX styles tolerated in
Pandoc previews, native dialogs replaced in embedded browsers, and file-copy
import fixes with an explicit fallback. CI resolves the highest semver tag,
not `main`, so this is `v0.9.48` exactly.
- **`pi-fork` `4a09af4` → `f1ff808`** — one commit, "Add fork runtime
awareness" (2026-08-19).
- **`mempalace-toolkit` `b609cf5` → `fd8b15f`** — two commits, **docs only**
(backup/recovery + units; the convos-miner mtime correction finished). The
`fix(pi-session)` false-success guard was already baked in v1.8.3 — checked
by ancestry (`git merge-base --is-ancestor 6e1f4f3 b609cf5`), not by reading
the log, because a commit's *date* does not tell you which side of a pin it
fell on.
- **`aws-cli` 2.36.24 → 2.36.29** and the other `*_VERSION=latest` tools
(bat/eza/fzf/gitleaks/nvim/micro/zoxide/yq/typst/tealdeer/agent-browser/
playwright/gosu/git-lfs/uv) refresh implicitly, as designed.
- `pi-toolkit` unchanged (`0e1369e`, local `main` == baked).
### Added
- **`todo` extension: an `edit` action** (`pi-extensions` `98eb07b` →
`2022887`). The tool is a verbatim vendored copy of pi's own
`examples/extensions/todo.ts`, which offers list/add/toggle/clear and no way
to change an item's text. On a long-lived list that forces either a "patch"
item describing a *different* item, or clear-and-re-add of everything — both
hit for real on 2026-08-17 while tracking a 17-item fleet plan, which ended up
with `#18` correcting `#17`. `edit` takes `id` + `text` and keeps the id and
the done status; `nextId` is untouched. Id stability is the point, because ids
are the only handle a palace snapshot of a plan can refer to.
- Verified live in-container before committing, by repointing
`~/.pi/agent/extensions/todo.ts` at a working copy for one session (unknown
id and missing text both error as intended; editing a completed item kept its
id and its `done` state), then reverting the symlink to the image copy.
### Notes
- **No pi-atelier change was needed for the `todo` action.** Its `tool_result`
hook only checks that `details.todos` is a well-shaped array and ignores the
action string, so the new action flows through its normalizer and sidebar
untouched. Worth knowing while reading agent transcripts: that hook
*replaces* todo tool output with `N/M done · see sidebar` whenever the sidebar
todo panel is visible (`showSidebarTodos`), so an agent sees only the counter
and not the item text — upstream's `list` otherwise returns every item. That
is a deliberate context saving, not a tool limitation.
- The vendored copy now carries a numbered **LOCAL DELTAS** list in its header
(the earlier `ctx.mode !== "tui"` → `!ctx.hasUI` API fix, and this action), so
reconciling a future upstream version stays mechanical rather than
archaeological.
- **A second om fix is NOT in this image and will not be.** Upstream PR #24
("advance coverage watermark when observer records nothing", head
`joakimp:fix/observer-empty-coverage-watermark` `b577b29`) is open but
design-rejected by the maintainer on 2026-07-03: an empty observer verdict is
usually a *technical* failure, so advancing the watermark would leave a gap in
the observed session, and in the genuinely-nothing-to-observe case the next
observer simply gets more context. So the observer can still re-fire on a
growing span after an empty verdict — that is upstream's intended behaviour,
not an image defect. Do not "fix" it by rebasing that branch.
---
## v1.8.3 — 2026-08-16
Patch release. **Bumps mempalace to `3.7.1`** and closes the gap that made the
baked mempalace skill go stale for four commits. `pi` stays `0.84.2` (still the
npm latest) and pi-atelier stays `v0.8.1`; every git-ref component
(pi-toolkit, pi-extensions, pi-fork, pi-observational-memory, pi-studio,
mempalace-toolkit) was checked against its upstream head and is unchanged.
- **`MEMPALACE_VERSION` `3.6.0` → `3.7.1`.** Verified against the 3.7.1 source
rather than its changelog, because the risk is to palaces users cannot
reconstruct: legacy drawers lack the new `chunk_total` completion marker and
**both** decision sites trust them (`if chunk_total is None: ... trust the
match as before`), so there is **no mass re-mine**; `NORMALIZE_VERSION` is `2`
in both versions, so the "pre-v2 drawers are stale" gate does not fire either;
`chromadb<2,>=1.5.4` keeps the same major, so no index-format migration; there
is no auto-migration (the source says *"We do NOT auto-migrate"* twice) and
`rebuild_index` has exactly one call site, the explicit `repair rebuild`; the
single new palace file (`logstream.sqlite3`) is created lazily on first
logstream use. Downgrade stays possible — 3.6.0 has zero references to
`chunk_total` and ignores it as unknown metadata.
Two behaviour changes worth knowing, both turning a silent condition into a
hard refusal: `MEMPALACE_MCP_ALLOW_PEER_WRITER` **no longer works on
local/chroma palaces** (it is now gated on `backend_requires_single_writer()`,
and `_MULTI_PROCESS_WRITER_BACKENDS` is `{pgvector, qdrant}`), and writer-lock
*setup* failures now **fail closed** (`refusing this mutating tool`) instead of
proceeding with a warning. Neither affects this image's normal
MCP-server-plus-CLI-feeder pattern, which already serialised on the same
`mine_palace_*.lock` under 3.6.0 — "process-lifetime single-writer ownership"
in the upstream changelog describes tightened escape hatches, not a new lease.
What 3.7.1 buys a **shared central** palace is the real motivation: the stale
chromadb `SharedSystemClient` cache is now dropped on reconnect (under 3.6.0 a
peer's writes could be overwritten by a stale in-memory HNSW segment, *"index
count going backwards"*), the writer lease is released on SIGTERM/SIGHUP
instead of leaking a lock naming a dead PID, and an interrupted mine is no
longer permanently skipped as though complete.
**Upgrading a server requires restarting it** — 3.7.1 refuses mutating tools
when the served library drifts from what is installed, and `mempalace_reconnect`
cannot clear that (it reopens the database but cannot reload Python modules).
The fleet primary was upgraded and restarted before this image was tagged.
Note: opencode-devbox still pins `3.6.0`. The two images are meant to move in
lockstep, so that pin diverges until opencode-devbox cuts its own release.
- **Vendored `mempalace` skill snapshot refreshed** to skillset `936fed8` (was
`63f3bf5`). This is the gap worth naming: `~/.agents/skills/mempalace`
symlinks to the **image-baked** copy under
`/usr/local/share/pi-devbox/skills/`, and `entrypoint-user.sh` creates that
link *first* while the skillset deploy never clobbers an existing name — so in
a devbox container the vendored snapshot always wins, and editing the skillset
repo alone changes nothing a container reads. Two commits' worth of guidance
had been invisible here: the multi-machine shared-palace section (device
provenance in `source_path`, mined drawers carrying the *mine* date with
UUIDv7 recovery, the naive-local vs UTC timestamp mismatch, `agent_name` not
being device-scoped, single-writer/no-queue semantics) and the
hand-crafted-provenance guard.
- **`pi-global-AGENTS.append.md`** gains `### If the palace is central, it is
shared — three rules`: never run `mempalace sync` against a shared palace (it
prunes drawers whose sources look missing, which on a central palace is most
of the content, including other machines' — compounded by RFC-001 §7.2, since
feeders stage *inside* the palace root); a client-side timeout is not a
failure (single writer, one large mine blocks everyone, so
`mine timed out after 30000ms` usually means the mine completed — verify
before retrying or you file a duplicate); and the `mempalace` CLI is not
remote-aware, so it always opens a local-disk palace and can silently
disagree with the MCP tools.
- **`mempalace-census` is now on `PATH`.** It shipped inside the image at
`/opt/mempalace-toolkit/bin/` but was never symlinked into `/usr/local/bin`
like its three siblings, so RFC-002 Phase A censuses had to be invoked by
absolute path. Added to the symlink set, the `chmod +x` set, and the
build-time `--help` smoke chain.
---
## v1.8.2 — 2026-08-16
Patch release. **Ships the fix for a silent transcript-feed failure**, plus the
smoke assertion that stops it coming back. No image pins changed from v1.8.1
(pi `0.84.2`, pi-atelier `v0.8.1`); what moves is the baked `mempalace-toolkit`
ref and one new smoke check.
**The bug this closes** (found on the first boot of the v1.8.1 image, on
EMB-7KJ4VR4G, 2026-08-15): the container-start catch-up rsynced seven pi session
transcripts to the palace host correctly, then asked the server to mine
`/data/feed/<device>` — the feeder's default `MEMPALACE_PI_REMOTE_PATH`, which
assumes a *containerized* palace server. That fleet's primary runs **natively**
(a systemd user unit + uv tool), so it only ever sees host paths and the mine
died with `source directory not found`. rsync had already succeeded, so the
inbox looked healthy.
It stayed invisible because of the second half: the feeder 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** — the bytes are `\"error\"`, so the substring could never
match. `~/.pi/agent/mempalace-catchup.log` printed
`Done. Wing 'wing_conversations' updated.` directly beneath the error JSON and
exited 0. A feeder whose only artifact claims success is worse than one that
crashes: nothing in the container disagreed with it.
Shipped here:
- **`mempalace-toolkit` ≥ `b609cf5`** baked (CI resolves the ref at build time):
`classify()` parses the MCP envelope instead of grepping it (JSON-RPC error,
MCP `isError`, inner `success=false`/`error`), and separates "verified ok"
from "unverified: no JSON tool payload" rather than assuming the good case.
A preflight warning fires when the rsync destination and
`MEMPALACE_PI_REMOTE_PATH` disagree — in *preflight*, so `--dry-run` and
`--prepare` surface it too. Remote mode also stops previewing NEW/SKIP from
the *local* palace, which had been reporting "6 already filed" about a palace
it was not feeding; the tags are now `[?]` and the summary names who decides.
- **New smoke assertion** — `mempalace-pi-session --self-test` run against the
**baked** toolkit. It replays six recorded MCP responses (fixture 1 is the
verbatim 2026-08-15 failure body) plus a regression guard asserting the old
substring check is blind to it. A stale or reverted `MEMPALACE_TOOLKIT_REF`
can therefore no longer ship a feeder that mines nothing while reporting
success.
- **`.env.example`** now spells out that `MEMPALACE_PI_REMOTE_PATH` is the path
the *server process* can open — the container path for a dockerized server,
identical to the ssh-target path for a native one — and that a mismatch fails
quietly, with rsync succeeding and only the mine failing.
**The `--self-test` assertion is deliberately bare** (`mempalace-pi-session
--self-test`, no `HOME=…` prefix). `run()` invokes
`docker run --entrypoint="" $IMAGE sh -c …` and no Dockerfile sets `USER` or
`ENV HOME`, so it executes with **no `HOME` at all** — the same condition that
made v1.8.0's stage assertion unsatisfiable. The feeder is `set -u` with
HOME-anchored defaults, so it used to die with `HOME: unbound variable` there;
`b609cf5` derives `HOME` from the passwd database (what python's `expanduser()`
falls back to) instead. Keeping the call bare means smoke also proves the feeder
runs in a bare container, rather than papering over it with an env prefix.
---
## v1.8.1 — 2026-08-15
Patch release. **Unblocks v1.8.0, which never shipped.** Its `smoke` and
`smoke-studio` jobs each failed exactly one assertion (67/68 and 70/71 passed),
so `build-variant` and everything downstream skipped: no `v1.8.0` tag reached
Docker Hub and `latest` stayed on v1.7.0 from 2026-08-07. Image content is
unchanged from what v1.8.0 intended — the pins here are identical (pi `0.84.2`,
pi-atelier `v0.8.1`).
The failing assertion was `pi stage defaults next to the palace (not a cache
dir)`, added three days earlier in 7c00dd6. **It was a test bug, not a product
regression.** It asserted a literal path:
```sh
echo "$out" | grep -q "stage=/home/developer/.mempalace/pi-stage/"
```
but the `run` helper invokes `docker run --rm --entrypoint="" $IMAGE sh -c …`,
and neither `Dockerfile.base` nor `Dockerfile.variant` sets `USER` or `ENV HOME`
(the published base image config carries no `HOME` at all — `HOME` is normally
set by `entrypoint-user.sh`, which `--entrypoint=""` deliberately skips). So the
assertion ran as **root with `HOME=/root`**, `mempalace-pi-session` correctly
resolved `stage=/root/.mempalace/pi-stage/…` (it is `$HOME`-relative by design:
`$MEMPALACE_PALACE_PATH` → `$MEMPAL_PALACE_PATH` → `~/.mempalace/config.json` →
`~/.mempalace/palace`), and the literal grep could never match under any
circumstances. The tell was one line below it in the log: the sibling assertion
`pi stage follows MEMPALACE_PALACE_PATH` **passed**, because it sets the variable
explicitly and so never consults `HOME`. Default fails while explicit passes is
the signature of a wrong `HOME`, not of broken staging.
Fixed by asserting the invariant that was actually meant — the stage sits beside
the resolved palace, sharing its lifetime — which is user-independent:
```sh
case "$stage" in
"stage=$HOME/.mempalace/pi-stage/"*) exit 0 ;;
*) exit 1 ;;
esac
```
`$HOME` is expanded by the container's own shell, so this holds as root, as
`developer`, or under any future user, while a cache-dir default — the
regression the assertion exists to catch — still fails it (verified against all
three cases plus a simulated `MEMPALACE_PI_STAGE` cache pin). A second
assertion, `pi stage is palace-adjacent for the developer user`, now covers the
deployment-specific path properly, by *supplying* `HOME=/home/developer` instead
of assuming it.
### Why it took a release to notice — and the `smoke_only` input
`docker-publish.yml` triggers on `push: tags: v*` only. 7c00dd6 was a push to
**main**, so only `lint.yml` ran; v1.8.0 was the first tag afterwards and
therefore the assertion's **first execution ever**. Any smoke assertion written
outside a release was unvalidated until the next release consumed it — the
worst possible moment to discover it.
New `workflow_dispatch` input **`smoke_only`** closes that: it probes/builds the
base and runs both smoke jobs against HEAD, then stops before publishing
anything. Implemented as `if: inputs.smoke_only != 'true'` on `build-variant`
and `build-variant-studio`, deliberately *without* `always()` so the implicit
"needs succeeded" gate survives and a red smoke still blocks a release;
`promote-base-latest` and `update-description` already require `build-variant`
success and so skip on their own. On a tag push `inputs` is unset and
`null != 'true'` is true, so releases behave exactly as before. This release was
validated with a `smoke_only` dispatch before the tag was cut.
### Smoke failures now explain themselves
`run` discarded all output (`>/dev/null 2>&1`), so a red ❌ carried zero
diagnostic weight — explaining this one-line failure took a CI-log dig plus a
registry image-config inspection, when the container had already printed the
answer and thrown it away. It now captures output and prints the last few lines
under a failed assertion only. Assertions that want a diagnostic echo it to
stderr (the stage checks now report the resolved stage and the `HOME` they saw),
which stays invisible while they pass.
---
## v1.8.0 — 2026-08-15
Minor release. Headline: **pi sessions now feed MemPalace by themselves.** The
image already shipped `mempalace-toolkit`, but its pi feeder
(`mempalace-pi-session`) was never symlinked onto `PATH`, so nothing ever mined
pi's transcripts — the palace only ever contained what an agent remembered to
file by hand. A container that gets recreated regularly has no other memory, so
a missed wind-down was a permanently lost session.
Also here: **pi 0.84.1 → 0.84.2 and pi-atelier v0.8.0 → v0.8.1**, bumped
together. The pi bump closes the Amazon Bedrock tool-argument poison pill that
v1.6.4 recorded as unfixed upstream; the atelier bump is the matching companion,
since both sides changed fullscreen input handling in the same fortnight. Audits
for both are below.
*Why event-driven and not a timer:* there is nothing schedulable inside the
container — PID 1 is `bash -l`, with no systemd and no cron — and anything
installed would not survive recreate anyway. The triggers therefore live where
the events already are: pi's own lifecycle, plus container start.
### Added
- **`mempalace-pi-session` symlinked onto `PATH`** (`Dockerfile.base`,
alongside its `mempalace-session` / `mempalace-docs` siblings, with the same
`--help` build-time check). `entrypoint-user.sh` also self-heals the symlink
into `~/.local/bin` (already ahead of `/usr/local/bin` on `PATH`, and
writable by `developer`) so the feature works on images whose base predates
this change.
- **Container-start catch-up feed** (`entrypoint-user.sh`, backgrounded). pi's
mempalace extension feeds the palace on `session_shutdown` and on a debounced
`agent_settled`, but a hard kill (`docker kill`, OOM, host reboot) runs no
handler at all; this is the only trigger that can recover the previous life's
transcripts. Skipped when a remote palace is configured without an inbox to
ship to — **and the skip now says so** (see Changed) — and skippable entirely
with `MEMPALACE_FEED=0`.
- **`MEMPALACE_PI_STAGE` no longer needs pinning here — the feeder's default
was fixed upstream instead.** It used to stage under `~/.cache`, which is
disposable in a container; the first cut of this change pinned the env var
into the persisted `~/.pi` volume. That was the wrong fix: it created a second
convention that could still diverge from the palace (keep the palace volume,
drop `devbox-pi-config`, and a scoped `mempalace sync` prunes every
conversation drawer, because dedup keys on the *staged* path). The feeder now
defaults to `<palace-root>/pi-stage`, resolved with mempalace's own
precedence (`$MEMPALACE_PALACE_PATH` → `$MEMPAL_PALACE_PATH` →
`~/.mempalace/config.json` → `~/.mempalace/palace`), so the stage inherits
whatever persistence the palace has and the two cannot be separated by
accident. No `ENV` and no entrypoint export: adding one back would
re-introduce exactly the split it removes.
- **Transcript inbox mount in `docker-compose.mempalace.yml`**
(`${MEMPALACE_FEED_DIR:-./feed}:/data/feed:ro`). A client cannot mine into a
remote palace directly: `mempalace_mine` expands its source path in the
*server* process, so the server can only see paths inside its own container.
Clients rsync their staged exports to a per-device subdirectory and then ask
the server to mine `/data/feed/<device>`. Read-only because mining only reads
sources — all locks live palace-side.
- **Smoke tests** for the above: `mempalace-pi-session` on `PATH`, two
assertions that the stage resolves next to the palace (default, and following
`$MEMPALACE_PALACE_PATH`), and two behavioural guards that feed the exporter a
synthetic pi session — one that must be captured, one abandoned session that
must not be. The second matters because pi expands skills/context into the
user prompt, so an abandoned session can look substantial by byte count while
containing no assistant output; and if pi's JSONL shape ever changes, the
exporter would silently capture nothing.
- **`.env.example`**: documents `MEMPALACE_FEED`,
`MEMPALACE_FEED_DEBOUNCE_MS`, `MEMPALACE_FEED_WING`, and the remote-palace
shipping vars `MEMPALACE_PI_SSH_TARGET`, `MEMPALACE_PI_REMOTE_PATH`,
`MEMPALACE_PI_DEVICE`.
### Changed
- **The "remote palace, no inbox" skip announces itself instead of vanishing**
(`entrypoint-user.sh`). When `MEMPALACE_REMOTE_URL` is set but
`MEMPALACE_PI_SSH_TARGET` is not, there is genuinely nothing the feeder can
ship to, so skipping is correct — but the branch was a bare `:`, and the skip
happens *before* the subshell that writes `~/.pi/agent/mempalace-catchup.log`.
A container in that state therefore contributed nothing to the palace and left
**no artifact at all**, not even an empty log, to explain why — indistinguish-
able from a healthy run that had nothing to file. Found while flipping the
first client onto the shared palace (2026-08-12), where it is the single most
likely way to end up quietly memory-less. The notice now goes to both the
container start output and that log path, names the two variables that fix it,
states that MCP tools still work (only *this* container's transcripts go
nowhere), and points at `MEMPALACE_FEED=0` for anyone who meant it.
Deliberately incapable of breaking startup: an unwritable `~/.pi` — root-owned
volume, a classic Docker accident — would make `mkdir -p` fail under `set -e`
and abort the whole entrypoint, so it degrades to stdout-only. That was a real
new risk, since this branch previously touched no filesystem whatsoever.
Covered by two smoke assertions against the entrypoint as shipped in the image
(the branch only runs at container start, so a `docker run` one-shot cannot
reach it).
### Bumped: pi 0.84.1 → 0.84.2
- **`ARG PI_VERSION=0.84.2`** (`Dockerfile.variant`), with the audit the pin
policy in that file requires.
**Headline for this image: the Bedrock tool-argument poison pill is FIXED
upstream.** The v1.6.4 entry below recorded it as *"Not fixed upstream … still
replayed unsanitised"* — that note is now superseded. pi-ai 0.84.2 adds a
recursive `sanitizeBedrockDocument()` and applies it at exactly the site that
entry named ([#7882](https://github.com/earendil-works/pi/pull/7882)):
```diff
- toolUse: { toolUseId: c.id, name: c.name, input: c.arguments },
+ toolUse: { toolUseId: c.id, name: c.name, input: sanitizeBedrockDocument(c.arguments) },
```
(`dist/api/bedrock-converse-stream.js` — line 692 in pi-ai 0.84.1, 704 in
0.84.2; it was 644 in 0.83.0 and 634 in 0.82.1.) The sanitiser drops object
members whose key is the empty string, recursing through arrays and nested
objects and preserving every valid value. It runs while the request is built,
so it covers the live turn *and* a resume: a session already bricked by an
empty-key tool argument now replays instead of dying on a Bedrock
`ValidationException`. **`pi-session-repair` (in `cli_utils`) is therefore no
longer the recovery path on this image.** It stays useful for older images and
for inspecting a transcript, because the stored `.jsonl` is still malformed —
the fix sanitises what is *sent*, not what was *recorded*.
**Why bumping `PI_VERSION` is the only way to get it:** pi publishes an
`npm-shrinkwrap.json`, which pins transitive dependencies *exactly*. pi
0.84.1's shrinkwrap pins `@earendil-works/pi-ai` to **0.84.1**, so although
0.84.1's `package.json` range is `^0.84.1` — which would otherwise admit
0.84.2 — rebuilding the old pin can never pick the fix up. Transitive upstream
fixes do not leak into this image; `PI_VERSION` is the whole gate.
Rest of the audit, against the integration surface the pin policy names:
- **Session `.jsonl` format — unchanged.** Identical
`migrateV1ToV2`/`migrateV2ToV3` ladder in both versions, so existing sessions
on the named volume load as-is and `pi-session-repair`'s parse target is
untouched.
- **Node engine floor — unchanged** at `>=22.19.0` (image ships 22.23.2).
- **pi-atelier — no change needed.** The pin stays `v0.8.0`: the hard floor is
"never pair < 0.7.1 with pi >= 0.84", this bump does not leave 0.84.x, and
atelier's `peerDependencies` (`>=0.80.7`) are satisfied. pi-atelier **0.8.1**
is published but deliberately NOT adopted here — one variable at a time, and
atelier is the component that has drawn blood at startup.
- **Directly relevant to `pi --ssh` use of this image:** 0.84.2 fixes split
`Alt+Enter` over SSH being misread as Escape, and adds `PI_TUI_ESC_TIMEOUT`
for high-latency terminals.
- **Keybindings — one surface worth knowing.** `pi-toolkit` ships exactly one
override, `tui.input.newLine: [shift+enter, ctrl+j, alt+j]`. 0.84.2's new
fullscreen transcript search (`Ctrl+Shift+F`) binds `Shift+Enter` to
*previous match* while its overlay is focused. Different context, so no
conflict is expected — but it is the one place the override meets a new
default, and the first place to look if "shift+enter stopped inserting a
newline" is ever reported.
- **New `defaultTools` setting** (choose startup built-in tools globally or per
project) is additive; `pi-toolkit`'s `settings.example.json` does not set it,
so the bootstrap template needs no change.
### Bumped: pi-atelier v0.8.0 → v0.8.1
- **`ARG PI_ATELIER_REF` / `ARG PI_ATELIER_VERSION` = `v0.8.1`**
(`Dockerfile.variant`), bumped *together* with `PI_VERSION` as that pin's
comment requires — and this pairing is a good advert for the rule, because both
sides touched fullscreen input handling within three days of each other.
atelier 0.8.1 (2026-08-12) is two changes, only one of them code: *"Preserve
fullscreen transcript mouse-wheel scrolling after Sidebar resize and visibility
changes by leaving Pi's persistent mouse reporting enabled"*, plus a README
simplification. The single source file that differs from 0.8.0 is
`src/split-pane.ts`. It extracts an `isPiFullscreenRenderer()` predicate and,
under pi's fullscreen renderer, stops writing its own
`\e[?1002h\e[?1006h` / `\e[?1006l\e[?1002l` pair around a sidebar resize —
previously it enabled mouse reporting on grab and disabled it on release, which
tore down the reporting **pi itself** had switched on and left the wheel dead
afterwards. Outside fullscreen it manages mouse mode exactly as before. It also
now captures the terminal it enabled mouse on and writes the disable sequence to
*that* terminal instead of to whatever `tui` currently points at.
**The audit that matters is the private-internals coupling**, since that is what
hung startup at 0.6.0/0.7.0. atelier reaches into three pi internals; all three
are unchanged in pi 0.84.2:
- **`TuiAltScreen`** — detected *by constructor name*, so a rename would
silently disable both the resize-input prioritisation and the new mouse
behaviour, with no error. Still
`class TuiAltScreen extends TuiBase implements ViewportTUI`.
- **`tui.inputListeners`** — a private `Set` that atelier deletes from and
re-adds to, to get its resize handler ahead of pi's viewport listener (which
"consumes every mouse event for text selection"). Still `inputListeners = new
Set()`, at the identical line 103 of `pi-tui/dist/tui.js` in both versions,
and still a `Set` — atelier guards with `instanceof Set`.
- **the prototype `render` descriptor** it wraps via `findPrototypeRender`.
Still an own `render(width)` on `TuiAltScreen`.
pi's mouse sequences are byte-identical between 0.84.1 and 0.84.2 (same
`1002h`/`1006h`/`1002l`/`1006l`/`1003h` occurrence counts), so atelier's
assumption about what pi leaves enabled still holds. `pi-tui`'s base class
changed additively only (one new `isOverlayFocused()`), and `TuiAltScreen`'s own
changes are the new search feature (`activeSearch`, `openSearch`/`closeSearch`,
the two search match styles, `copySelection`).
**Caveat, stated plainly:** pi 0.84.2 adds a focused fullscreen *search overlay*
that participates in input handling, while atelier reorders input listeners
around pi's viewport listener. The two look convergent — 0.84.2 separately fixes
*"focused fullscreen overlays not receiving mouse wheel or viewport scroll
keys"* — but this pairing is reasoned from the diffs, **not proven by
execution**: the CI smoke test does not drive the TUI, so a fullscreen
interaction regression would not be caught before pull. Worth an `alt+a` plus a
sidebar resize and a wheel scroll in fullscreen on first use of this image.
Version metadata is unchanged: `engines.node >=22.19.0`, `peerDependencies`
still the uninformative `>=0.80.7` on both pi packages (so still nothing in npm
metadata encodes the real floor), and still zero runtime dependencies — so the
"no `npm install` step" note above stays true. The GitHub tag `v0.8.1` exists
(commit `c31d7439`), which is what CI resolves to a SHA.
### Notes
- The `Dockerfile.base` change moves the base hash, so this needs a base
rebuild; the `~/.local/bin` self-heal exists so the feature does not have to
wait for one. The skip-notice change is in `entrypoint-user.sh`, which is
`COPY`d in `Dockerfile.base` too, so it rides the same rebuild — until then,
older images keep skipping silently and the two commands in the toolkit's
`phase-1-exposure-runbook.md` §3.7 are the way to tell.
- Requires the matching `mempalace-toolkit` change (`--prepare` two-phase
split, remote transport, and the auto-feed triggers in
`extensions/pi/mempalace.ts`). The split exists because the palace is
single-writer: a live pi session holds it through the extension's own
`mempalace-mcp`, so a CLI `mempalace mine` during a session fails with
"palace ... is held by PID". Staging is therefore done by the CLI and the
mine itself by whichever process already holds the palace.
---
## v1.7.0 — 2026-08-07
Minor release. Headline: **pi-atelier is now part of the image** — the TUI
+8 -1
View File
@@ -65,12 +65,19 @@ The entrypoint deploys/registers all of these on first container start. Re-runni
### Document and image tooling
- **pandoc** — universal Markdown↔HTML/Org/RST/etc. conversion. Useful well beyond pi: agent-driven doc exports, format conversion, etc.
- **Typst** — markup-based typesetting, used as pandoc's `--pdf-engine`
- **graphviz** (`dot`) — diagram rendering pipelines
- **imagemagick** (`magick`) — image conversion / resizing
### Browser automation
- **agent-browser** — CLI for driving a real browser (open pages, click/fill/`eval`, snapshot the DOM, screenshots) so agents can verify front-end work instead of guessing
- **Playwright** + a headless **Chromium** are pre-installed and pinned together; `AGENT_BROWSER_EXECUTABLE_PATH` is preset to the baked browser, so `agent-browser open <url>` works out of the box with no setup
- **socat** — TCP bridge used to expose the pi-studio server outside the container's loopback
### Modern CLI tooling
- **Editor**: neovim (LazyVim defaults), tmux (configured for 0-indexed sessions)
- **Editor**: neovim (system-wide `termguicolors` default; bring your own config/plugins), tmux (configured for 0-indexed sessions)
- **Search/nav**: ripgrep, fd, fzf, zoxide
- **Display**: bat, eza, htop, tree
- **Data**: jq, yq
+59 -2
View File
@@ -384,7 +384,56 @@ ARG INSTALL_MEMPALACE=true
# mempalace_checkpoint (#2023/#2034).
#
# Keep in lockstep with opencode-devbox when bumping.
ARG MEMPALACE_VERSION=3.6.0
#
# 3.7.1 (from 3.6.0) is safe for anyone with an EXISTING LOCAL palace: verified
# against the 3.7.1 source, not the changelog. Legacy drawers lack the new
# `chunk_total` marker and both decision sites trust them ("trust the match as
# before"), NORMALIZE_VERSION is 2 in both, chromadb stays <2 (no index-format
# migration), there is no auto-migration ("We do NOT auto-migrate"), and the one
# new palace file (logstream.sqlite3) is created lazily on first logstream use.
# Two behaviour changes to know: MEMPALACE_MCP_ALLOW_PEER_WRITER no longer works
# on local/chroma palaces, and writer-lock setup failures now fail CLOSED
# (refuse the write) rather than fail open. Neither affects the container's
# normal MCP-server-plus-CLI-feeder pattern, which already serialised on the
# same lock under 3.6.0.
#
# 3.8.0 (2026-08-23, PyPI, released hours after this project's own v1.8.5 tag
# the same day) is additive/reliability only — reviewed for MCP tool-schema
# changes before bumping, as always: there are NONE. Two PRs matter:
# - PR #2320/#2322: `sync --apply` no longer deletes a drawer solely because
# its source_file was unreachable AT THAT MOMENT — it now asks for
# corroboration first. This fixes losing a whole mined project to one
# `sync --apply` while its volume happened to be unmounted.
# IMPORTANT — do not over-read this fix: it addresses TRANSIENT
# unreachability, not the standing landmine (documented in the operator's
# global AGENTS.md) against running `mempalace_sync` / `mempalace_delete_by_source`
# beyond dry-run on the SHARED central palace. On that palace most
# source_file paths are PERMANENTLY absent from whichever host runs the
# sync — a different machine's paths simply do not exist here, ever, not
# merely "right now". That is a different failure shape than #2320/#2322
# fixes. The landmine still stands; this bump does not relax it.
# - PR #2307: long-running Chroma servers no longer invalidate their own
# HNSW cache on their own writes (server-side perf fix). This does NOT
# make `mempalace_reconnect` unnecessary — that tool exists for EXTERNAL
# writes bypassing the in-process client (e.g. direct sqlite backfills,
# CLI commands against a running server), a different scenario #2307
# does not touch.
#
# Known gap, carried forward (flagged in prior release notes, not fixed here):
# unlike PI_VERSION, which CI's resolve-versions job verifies is published and
# warns — never silently adopts — on npm drift, MEMPALACE_VERSION has NO
# equivalent CI-side audit (confirmed: zero references to MEMPALACE_VERSION in
# .gitea/workflows/docker-publish.yml). This is a literal Dockerfile string
# with no automated freshness or publish check.
#
# Deployment sequencing note for whoever ships this bump: synlig (the shared
# central palace host) currently serves mempalace 3.7.1 SERVER-SIDE via
# docker-compose.mempalace.yml, which reuses this same devbox image. Bumping
# this ARG changes only the CLIENT version baked into pi-devbox images: it
# introduces client/server skew until synlig's compose stack is separately
# rebuilt/redeployed with the new pin. Not something to code around here —
# just sequence the redeploy.
ARG MEMPALACE_VERSION=3.8.0
ENV UV_TOOL_DIR=/opt/uv-tools
ENV UV_TOOL_BIN_DIR=/usr/local/bin
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
@@ -424,9 +473,15 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" =
[ "$ok" = "1" ] && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-pi-session /usr/local/bin/mempalace-pi-session && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-census /usr/local/bin/mempalace-census && \
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs \
/opt/mempalace-toolkit/bin/mempalace-pi-session \
/opt/mempalace-toolkit/bin/mempalace-census && \
mempalace-session --help >/dev/null && \
mempalace-docs --help >/dev/null && \
mempalace-pi-session --help >/dev/null && \
mempalace-census --help >/dev/null && \
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
fi
@@ -668,12 +723,14 @@ COPY rootfs/usr/local/share/pi-devbox/ /usr/local/share/pi-devbox/
COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose
COPY rootfs/usr/local/bin/dot-watch /usr/local/bin/dot-watch
COPY rootfs/usr/local/bin/pi-devbox-version /usr/local/bin/pi-devbox-version
COPY rootfs/usr/local/bin/devbox-skill-reconcile /usr/local/bin/devbox-skill-reconcile
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
/usr/local/bin/studio-expose \
/usr/local/bin/dot-watch \
/usr/local/bin/pi-devbox-version \
/usr/local/bin/devbox-skill-reconcile \
/usr/local/lib/pi-devbox/*.sh 2>/dev/null || true
# Start as root — entrypoint adjusts UID/GID then drops to developer
+35 -3
View File
@@ -56,7 +56,22 @@ ARG USER_NAME=developer
# current when it was first populated (shipped the same bytes for pi-devbox
# v0.74.0..v0.75.5; discovered + fixed in v0.75.5b, 2026-05-23). The `latest`
# branch below is kept only for a deliberate local `docker build` override.
ARG PI_VERSION=0.84.1
#
# AUDITED AT 0.84.3 (2026-08-25, was 0.84.2): upstream's notes carry a
# "Breaking Changes" heading — `GoogleThinkingLevel` renamed to
# `GoogleApiThinkingLevel`. INERT FOR THIS IMAGE: all four vendored companions
# (/opt/pi-fork, /opt/pi-observational-memory, /opt/pi-atelier, /opt/pi-studio)
# were grepped for that symbol and reference it ZERO times, so nothing here
# couples to the renamed type. Recorded because the heading will look alarming
# to the next reader doing step 1 above — the audit is done, don't redo it.
# Adopted for two fixes that land squarely on this repo's own vendored-skill
# wiring (see devbox-skill-reconcile, v1.8.5): nested Markdown skills inside
# `.agents/skills/<group>/` directories were not discovered, and root Markdown
# files such as README.md / AGENTS.md inside a skill dir were reported as
# broken skills unless they declared valid skill frontmatter.
# pi-atelier needs no companion bump: v0.8.2 clears the >=0.7.1 floor that
# pi >= 0.84 requires (see PI_ATELIER_REF below).
ARG PI_VERSION=0.84.3
ARG PI_TOOLKIT_REF=main
ARG PI_EXTENSIONS_REF=main
# Repo URLs default to the canonical gitea origin but are overridable so a
@@ -92,9 +107,9 @@ ARG PI_OBSMEM_REF=master
# the /opt checkout. Adding an install here would be a no-op that only costs
# build time.
ARG PI_ATELIER_REPO=https://github.com/michaelmjhhhh/pi-atelier.git
ARG PI_ATELIER_REF=v0.8.0
ARG PI_ATELIER_REF=v0.8.2
# Human-readable tag PI_ATELIER_REF was resolved from; recorded as a label.
ARG PI_ATELIER_VERSION=v0.8.0
ARG PI_ATELIER_VERSION=v0.8.2
RUN set -e && \
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
@@ -297,6 +312,19 @@ RUN set -e; \
mkdir -p /etc/pi-devbox; \
rev() { git -C "$1" rev-parse HEAD 2>/dev/null || echo "unknown"; }; \
PI_V="$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')"; \
# mempalace CORE (the PyPI package behind the MCP tools) is installed in
# Dockerfile.base via `uv tool install`, so no /opt clone reveals it and
# until v1.8.6 the manifest could not answer "which palace shipped here?" —
# a palace bug could not be correlated to an image, which is precisely the
# correlation this file exists to provide. Read from the INSTALLED BINARY,
# not from ARG MEMPALACE_VERSION, per the ground-truth rule above: that is
# what catches an install which resolved to something other than the pin.
# `mempalace --version` prints "MemPalace 3.7.1" — NAME-PREFIXED, unlike
# pi's bare "0.84.2" — hence the $NF pick rather than a straight read. The
# leading-digit test then rejects usage/error text (a renamed flag prints a
# usage block) and degrades to JSON null, so this can never fail the build.
MP_V="$(mempalace --version 2>/dev/null | head -n1 | tr -d '\r' | awk '{print $NF}')"; \
case "$MP_V" in [0-9]*) MP_CORE="\"${MP_V}\"" ;; *) MP_CORE='null' ;; esac; \
STUDIO_REV='null'; \
if [ -d /opt/pi-studio/.git ]; then STUDIO_REV="\"$(rev /opt/pi-studio)\""; fi; \
{ \
@@ -305,6 +333,10 @@ RUN set -e; \
echo " \"build_date\": \"${BUILD_DATE}\","; \
echo " \"source_revision\": \"${SOURCE_REVISION}\","; \
echo " \"pi_version\": \"${PI_V}\","; \
# Sibling of pi_version, NOT a member of components{}: that map holds git
# SHAs and `pi-devbox-version` renders it with .value[0:12], which would
# silently truncate a longer version string.
echo " \"mempalace_version\": ${MP_CORE},"; \
echo " \"components\": {"; \
echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \
echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \
+62 -11
View File
@@ -70,9 +70,27 @@ so `TERM=xterm-kitty` is understood. Override either in your own
### Document and image tooling
- `pandoc` — universal Markdown↔HTML/Org/RST/etc. converter
- `typst` — markup-based typesetting, wired up as pandoc's `--pdf-engine` (see
[Generating a PDF with pandoc + typst](#generating-a-pdf-with-pandoc--typst))
- `graphviz` — `dot` rendering for diagram pipelines
- `imagemagick` — image conversion / resizing (invoked as `magick`)
### Browser automation
- `agent-browser` — CLI for driving a real headless browser: open pages,
click/fill/`eval`, snapshot the DOM, take screenshots. Useful whenever a task
involves a web UI or verifying how a page actually renders (live DOM, WebGL,
layout, popup positioning) instead of guessing from source.
- `playwright` + a pre-installed headless **Chromium** back it.
`AGENT_BROWSER_EXECUTABLE_PATH` is preset to the baked browser via a stable
`/usr/local/bin/agent-chrome` symlink (insulated from Playwright's
per-version/arch install directory), so `agent-browser open <url>` works
out of the box with no setup. Run `agent-browser skills get core --full`
for the command set and workflow patterns.
- `socat` — TCP bridge used by `studio-expose` to reach pi-studio's
loopback-bound server from outside the container (see
[Using pi-studio](#using-pi-studio--studio-variant))
### Language toolchains
- `python3` + `python3-venv` + `python3-pip` (system Python)
@@ -547,7 +565,8 @@ directory, and they compose:
`~/.agents/skills/` by `entrypoint-user.sh` on every start. They need no
external mount, survive volume recreate (the source is an image path, not a
home dir a named volume would shadow), and are created only when absent so a
same-named skillset skill or user override is never clobbered. The bundled
user override is never clobbered. Precedence against a mounted `skillset` repo
is per-skill, not blanket — see *Skillset repo* below. The bundled
**`pi-devbox-environment`** skill is delivered this way — it teaches agents
the container's persistence model, host/LAN SSH reachability, split-DNS
mechanisms, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`),
@@ -558,8 +577,11 @@ directory, and they compose:
pi session to read `~/.agents/skills/pi-extensions/SKILL.md` at start (to fix
fork/recall under-utilisation). That pointer would dangle in a container
started *without* the private `skillset` repo, so the image also bakes
fallback copies of **`pi-extensions`** and **`mempalace`**. They are
symlinked only when absent, so a mounted skillset always overrides them. The
fallback copies of **`pi-extensions`** and **`mempalace`**. Whether a mounted
skillset overrides them depends on who *owns* the skill (see *Skillset repo*):
`mempalace` is skillset-owned, so the live clone wins; `pi-extensions` is
owned by its package repo, so the baked copy keeps winning — the skillset's
copy of it is a downstream duplicate that can lag. The
`pi-extensions` skill is *layered*: a committed snapshot in `rootfs/` is the
floor, and `Dockerfile.variant` copies the canonical, package-owned copy from
the pinned `pi-extensions` clone (`/opt/pi-extensions/skill/`) over it at
@@ -573,7 +595,16 @@ directory, and they compose:
- **Skillset repo (optional).** If a `skillset` repo is mounted (at
`$HOME/skillset` or `/workspace/skillset`, or via `SKILLSET_CONTAINER_PATH`),
`deploy-skills.sh` symlinks its skills in too. Image-baked skills are
classified as foreign-links by its `--prune-stale` pass and left untouched.
classified as foreign-links by its `--prune-stale` pass and left untouched —
which through v1.8.4 meant the baked copy *always* won, so an edit pushed to a
skillset-owned skill was invisible until the next image build. Since v1.8.5
`devbox-skill-reconcile` runs right after the deploy and repoints the links for
skills the skillset owns, listed in
`/usr/local/share/pi-devbox/skills/skillset-owned.txt` (today: `mempalace`).
Effective precedence, highest first: **user override** (a real directory, or a
symlink pointing outside the baked tree) → **live skillset clone** (owned names
only) → **baked snapshot** (everything else, and every skill when no skillset
is mounted). Check with `readlink -f ~/.agents/skills/<skill>`.
To make agents *proactively* load a baked skill at session start (rather than
only on description match), the image appends a short, gated pointer to the
@@ -774,8 +805,9 @@ docker inspect --format '{{json .Config.Labels}}' joakimp/pi-devbox:latest | jq
`org.opencontainers.image.{version,revision,created}` plus
`se.jordbo.pi-devbox.*-ref` record the intended pi version and companion
refs. The on-disk `/etc/pi-devbox/build-manifest.json` records **ground
truth** — the actual checked-out commit of each `/opt` clone and the live
`pi --version` — so a tag is reconstructable after CI logs rotate:
truth** — the actual checked-out commit of each `/opt` clone, the live
`pi --version`, and (from v1.8.6) the live `mempalace --version` of the
installed palace core — so a tag is reconstructable after CI logs rotate:
```bash
docker run --rm --entrypoint= joakimp/pi-devbox:latest cat /etc/pi-devbox/build-manifest.json
@@ -882,12 +914,31 @@ cat ~/.ssh-local/mypeer_devbox_ed25519.pub
# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE0000EXAMPLE0000EXAMPLE0000ex devbox-0d11ec7731c7
```
**2. On the peer** — authorize it narrowly rather than bare:
**2. On the peer** — append that public key to `~/.ssh/authorized_keys` **of
the account you will log in as** (the `User` from step 3), narrowly authorized
rather than bare:
```
```bash
mkdir -p ~/.ssh && chmod 700 ~/.ssh
cat >> ~/.ssh/authorized_keys <<'KEY'
from="192.168.1.0/24,192.168.4.0/24,10.8.0.7",restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE0000EXAMPLE0000EXAMPLE0000ex devbox-mymachine
KEY
chmod 600 ~/.ssh/authorized_keys
```
Both lines are safe on a peer that is already set up: `mkdir -p` is a no-op
when the directory exists, the `chmod`s only tighten, and appending never
touches keys already listed. Use `>>`, never `>` — one stray truncation
revokes every other key on that account. The options prefix must sit on the
**same physical line** as the key, comma-separated with no spaces: a paste
that wrapped is the likeliest reason a key that looks right is refused.
`ssh-copy-id` cannot add that prefix, so append by hand (or let it copy the
bare key and edit the line afterwards). If authentication still fails with no
clear reason, suspect permissions — sshd's `StrictModes` silently ignores
`authorized_keys` when the home directory, `~/.ssh` or the file itself is
group- or world-writable, and says why only in the peer's own log
(`journalctl -u ssh`, `/var/log/auth.log`).
`restrict` disables pty, agent/X11 and port forwarding; append
`port-forwarding` and `permitopen="127.0.0.1:<port>"` after it if you need one
specific tunnel. `from=` must list the **host's** addresses, not the
@@ -970,9 +1021,9 @@ resolved to `latest` at build time:
| Component | Pin | Where |
|---|---|---|
| pi | `0.84.1` | `ARG PI_VERSION` — `Dockerfile.variant` |
| pi-atelier | `v0.8.0` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
| mempalace | `3.6.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` |
| pi | `0.84.2` | `ARG PI_VERSION` — `Dockerfile.variant` |
| pi-atelier | `v0.8.2` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
| mempalace | `3.7.1` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` |
The objective is **not** to freeze versions. Bumping is routine — usually one
line plus a changelog note. The objective is that adopting a new upstream
+15
View File
@@ -18,8 +18,23 @@ for OS packages, the per-package copyright files inside the image at
| pi-fork | github.com/elpapi42/pi-fork | MIT |
| pi-observational-memory | github.com/elpapi42/pi-observational-memory | MIT |
| pi-studio *(`-studio` variant only)* | github.com/omaclaren/pi-studio | MIT |
| pi-atelier | github.com/michaelmjhhhh/pi-atelier | MIT |
| pi-toolkit, pi-extensions, mempalace-toolkit | authored by the maintainer (Joakim Persson) | MIT |
## MemPalace (AI memory)
| Component | Upstream | License |
| --- | --- | --- |
| mempalace (core, MCP server) | github.com/MemPalace/mempalace (PyPI: `mempalace`) | MIT — the GitHub repo declares MIT; the PyPI package's own metadata omits a license classifier, so if you need clearance from the package artifact alone, verify against the repo's `LICENSE` file rather than the sdist/wheel metadata |
## Browser automation
| Component | Upstream | License |
| --- | --- | --- |
| agent-browser | github.com/vercel-labs/agent-browser (npm: `agent-browser`) | Apache-2.0 |
| Playwright | github.com/microsoft/playwright (npm: `playwright`) | Apache-2.0 |
| Chromium | chromium.googlesource.com/chromium/src | BSD-3-Clause for Chromium's own code, plus a large set of bundled third-party components each under their own license (see Chromium's own `LICENSE`/`about:credits`). The binary in this image is **not compiled here** — it is the build Playwright downloads for its pinned version ("Chrome for Testing"), installed via `playwright install --with-deps chromium` at `/usr/local/share/ms-playwright/`. Treat Playwright's own distribution terms for that build as authoritative over any summary here. |
## Tooling baked into the base image
| Component | Upstream | License (best effort) |
+36 -9
View File
@@ -5,6 +5,7 @@
# Point every client at it by setting, in that client's .env:
#
# MEMPALACE_REMOTE_URL=http://<reachable-host>:8765/mcp
# MEMPALACE_REMOTE_TOKEN=<the shared bearer token>
#
# (see .env.example). When set, the client connects over HTTP and does NOT
# spawn its own local mempalace-mcp.
@@ -18,12 +19,21 @@
# (both are pinned by the same image build). Override with a slimmer image via
# MEMPALACE_SERVER_IMAGE if you prefer (it must provide `mempalace-mcp`).
#
# ⚠ SECURITY: mempalace-mcp's HTTP transport has NO authentication of its own.
# Do NOT expose port 8765 to an untrusted network. The default below binds to
# 127.0.0.1 (host loopback) only. To let sibling containers reach it, either
# attach them to the shared `mempalace-net` network (container-to-container, no
# host port needed — use http://mempalace-server:8765/mcp), or front it with a
# reverse proxy that enforces MEMPALACE_REMOTE_TOKEN as `Authorization: Bearer`.
# ⚠ SECURITY: the HTTP transport IS authenticated as of mempalace 3.6.0 — an
# earlier version of this comment said otherwise and was wrong. The server
# compares `Authorization: Bearer <token>` with hmac.compare_digest and
# **refuses to start on a non-loopback bind without a token**, so
# MEMPALACE_REMOTE_TOKEN below is required, not optional: without it this
# service crash-loops. It also pins `Host` and allowlists `Origin`.
#
# Still do not publish port 8765 to an untrusted network. The default binds to
# 127.0.0.1 (host loopback) only. To let sibling containers reach it, attach
# them to the shared `mempalace-net` network (container-to-container, no host
# port needed — use http://mempalace-server:8765/mcp). To reach it from
# elsewhere, terminate TLS in a tunnel/reverse proxy and let the bearer token be
# the authentication — do NOT add browser-shaped auth (SSO/PIN/password) in
# front, because every MCP client here is a headless JSON-RPC POST and would
# receive a login page where JSON should be.
name: mempalace-server
@@ -40,6 +50,11 @@ services:
user: "0:0"
environment:
- HOME=/data
# Required: mempalace refuses a non-loopback bind without a token (it
# would exit at startup and, with restart:unless-stopped, crash-loop).
# `:?` fails fast at `docker compose up` with a readable message instead.
# Clients send the same value as MEMPALACE_REMOTE_TOKEN.
- MEMPALACE_MCP_HTTP_TOKEN=${MEMPALACE_REMOTE_TOKEN:?set MEMPALACE_REMOTE_TOKEN in .env — the shared palace requires a bearer token}
command:
- mempalace-mcp
- --transport
@@ -60,16 +75,28 @@ services:
- mempalace-shared:/data/.mempalace
# Embedding-model cache (~79 MB, disposable) so search does not re-download.
- mempalace-shared-chroma:/data/.cache/chroma
# Transcript inbox. Clients cannot mine into a remote palace directly:
# `mempalace_mine` expands its source path in THIS process, so it can only
# see paths inside this container. Each client rsyncs its staged session
# exports to a per-device subdirectory on the host (see
# MEMPALACE_PI_SSH_TARGET in .env.example) and then calls mempalace_mine
# with the container-side path below (MEMPALACE_PI_REMOTE_PATH=/data/feed).
# Read-only: mining only reads sources, and all locks live palace-side.
- ${MEMPALACE_FEED_DIR:-./feed}:/data/feed:ro
networks:
- mempalace-net
healthcheck:
# A tools/list round-trip proves the server is answering MCP (python3 is
# always present — mempalace itself is a python tool in the image).
# GET /healthz, which is Host/Origin-gated but deliberately token-free —
# so this probe needs no credentials. Do NOT go back to POSTing
# `tools/list` here: that carries no Authorization header and now 401s,
# marking a perfectly healthy server unhealthy forever. The Host pin is
# only enforced on loopback *binds* (this one is 0.0.0.0), so a request to
# 127.0.0.1 inside the container passes.
test:
- CMD
- python3
- -c
- "import urllib.request,json; d=json.dumps({'jsonrpc':'2.0','id':1,'method':'tools/list','params':{}}).encode(); r=urllib.request.Request('http://127.0.0.1:8765/mcp',data=d,headers={'Content-Type':'application/json','Accept':'application/json'}); urllib.request.urlopen(r,timeout=5).read()"
- "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8765/healthz',timeout=5).status==200 else 1)"
interval: 30s
timeout: 10s
retries: 3
+104 -5
View File
@@ -58,10 +58,17 @@ fi
# the runtime skill-link assertion. Pointing at the image path (/usr/local/...)
# keeps the skill fresh from the image and surviving volume recreate (unlike
# anything baked under a home dir, which a named volume would shadow). Created
# only when absent, so a same-named skillset skill (deployed later, at the end
# of this script) or a user override is never clobbered; the skillset deploy
# classifies these as foreign-links and its --prune-stale pass leaves them
# alone (only dangling symlinks are pruned).
# only when absent, so a user override is never clobbered.
#
# NB: "created only when absent" does NOT hand a same-named skillset skill
# priority — the opposite. The skillset deploy runs at the end of this script
# and classifies these links as foreign, so through v1.8.4 the BAKED copy
# always won and an edit pushed to a skillset-owned skill was invisible until
# the next image build. The links below are therefore the FALLBACK only;
# devbox-skill-reconcile (invoked right after the skillset deploy) hands the
# skillset-OWNED skills back to the live clone. Ownership is per-skill, listed
# in skills/skillset-owned.txt — see VENDORED.md for why pi-extensions must
# keep losing to the baked copy.
DEVBOX_SKILLS_SRC=/usr/local/share/pi-devbox/skills
if [ -d "$DEVBOX_SKILLS_SRC" ]; then
mkdir -p "$HOME/.agents/skills"
@@ -69,7 +76,16 @@ if [ -d "$DEVBOX_SKILLS_SRC" ]; then
[ -d "$_sk" ] || continue
_skname=$(basename "$_sk")
if [ ! -e "$HOME/.agents/skills/$_skname" ]; then
ln -s "${_sk%/}" "$HOME/.agents/skills/$_skname"
# -sfn, not -s: `[ ! -e ]` is TRUE for a DANGLING symlink (-e follows the
# link), and since v1.8.5 these links can point into /workspace/skillset
# (see devbox-skill-reconcile, invoked after the skillset deploy). If that
# mount vanishes while the writable layer survives — a `docker restart` or
# a host reboot under restart: unless-stopped, as opposed to a recreate —
# plain `ln -s` fails with "File exists" and, under `set -e`, aborts
# container start before `exec "$@"`. With -f the broken link heals back to
# the baked fallback, and the reconciler re-points it in the same boot if
# the clone is back.
ln -sfn "${_sk%/}" "$HOME/.agents/skills/$_skname"
fi
done
fi
@@ -92,6 +108,82 @@ if command -v mempalace &>/dev/null && [ -d /workspace ]; then
fi
fi
# ── MemPalace: pi transcript feeder ─────────────────────────────────
# mempalace-toolkit ships `mempalace-pi-session`, which mines pi's own JSONL
# session transcripts into the palace. pi's mempalace extension drives it on
# session_shutdown and on a debounced agent_settled; this is the catch-up for
# the one case no handler can cover — a hard kill (docker kill, OOM, host
# reboot) runs nothing at all, so without this the previous life's transcripts
# are never mined.
#
# No MEMPALACE_PI_STAGE override here on purpose: the feeder stages next to the
# palace it feeds (<palace-root>/pi-stage), so the stage and the dedup keys
# referencing it share one lifetime — whatever persistence the palace has, the
# stage inherits. Pinning it elsewhere (e.g. into the ~/.pi volume) would
# re-introduce the very split that design prevents: palace volume kept, stage
# volume dropped, and `mempalace sync` then prunes every conversation drawer.
#
# Backgrounded: a cold mine can take tens of seconds and must never delay the
# shell. Contention with a live session is handled by the tool itself (it exits
# 0 and lets the palace holder do the mine).
# Self-heal onto PATH for images whose base predates the toolkit symlink.
# ~/.local/bin is already ahead of /usr/local/bin on PATH (Dockerfile.base sets
# it in ENV PATH) and is writable by this (non-root) user, unlike /usr/local/bin.
if [ -x /opt/mempalace-toolkit/bin/mempalace-pi-session ] && \
! command -v mempalace-pi-session >/dev/null 2>&1; then
mkdir -p "$HOME/.local/bin"
ln -sf /opt/mempalace-toolkit/bin/mempalace-pi-session "$HOME/.local/bin/mempalace-pi-session"
fi
# Resolve the feeder explicitly rather than trusting PATH: this runs before any
# login shell, and a silently-skipped catch-up is exactly the failure we are
# here to prevent.
MEMPALACE_FEEDER=""
if command -v mempalace-pi-session >/dev/null 2>&1; then
MEMPALACE_FEEDER="mempalace-pi-session"
elif [ -x /opt/mempalace-toolkit/bin/mempalace-pi-session ]; then
MEMPALACE_FEEDER="/opt/mempalace-toolkit/bin/mempalace-pi-session"
fi
if [ "${MEMPALACE_FEED:-1}" != "0" ] && [ -n "$MEMPALACE_FEEDER" ]; then
if [ -n "${MEMPALACE_REMOTE_URL:-}" ] && [ -z "${MEMPALACE_PI_SSH_TARGET:-}" ]; then
# Remote palace, but no inbox to ship transcripts to — the feeder genuinely
# cannot do anything here, so skipping is right. Saying so is the point:
# this branch used to be a bare `:`, and the skip happens *before* the
# subshell below that writes mempalace-catchup.log, so a container in this
# state contributed nothing to the palace and left no artifact at all — not
# even an empty log — to explain why. That is indistinguishable from a
# healthy run that simply had nothing to file. `tee` puts the notice both in
# the container's start output (docker logs) and at the path anyone
# debugging "why is nothing from this container in the palace?" looks first.
# This is an entrypoint: a notice must never be able to stop a container
# from starting. An unwritable ~/.pi (root-owned volume — a classic Docker
# permission accident) makes `mkdir -p` fail, and under `set -e` that would
# abort startup entirely: a brand-new failure mode in precisely the branch
# that used to do nothing at all. Degrade to stdout-only instead.
_mp_log="$HOME/.pi/agent/mempalace-catchup.log"
mkdir -p "$HOME/.pi/agent" 2>/dev/null || _mp_log=/dev/null
{
echo "MemPalace catch-up skipped: remote palace with no transcript inbox."
echo " MEMPALACE_REMOTE_URL is set (${MEMPALACE_REMOTE_URL})"
echo " but MEMPALACE_PI_SSH_TARGET is not, so there is nowhere to ship this"
echo " container's staged sessions. MCP tools still read and write the shared"
echo " palace — but this container's own conversations are mined nowhere."
echo " Fix: set MEMPALACE_PI_SSH_TARGET (and MEMPALACE_PI_DEVICE) in .env,"
echo " or unset MEMPALACE_REMOTE_URL to keep the palace local."
echo " Deliberate? MEMPALACE_FEED=0 turns the feed off and silences this."
} | tee "$_mp_log" 2>/dev/null || true
unset _mp_log
else
mkdir -p "$HOME/.pi/agent"
(
"$MEMPALACE_FEEDER" --reason container-start \
>"$HOME/.pi/agent/mempalace-catchup.log" 2>&1 || true
) &
fi
fi
# ── Git config defaults ──────────────────────────────────────────────
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
git config --global user.name "$GIT_USER_NAME"
@@ -309,6 +401,13 @@ elif [ -x /workspace/skillset/deploy-skills.sh ]; then
fi
if [ -n "$SKILLSET_DEPLOY" ]; then
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
# The deploy leaves the early baked links (above) in place as foreign links,
# which silently shadows the live clone for skills the skillset OWNS. Repoint
# just those; baked stays the fallback, user overrides still win. `|| true`:
# a skill-link refinement must never break container start.
if command -v devbox-skill-reconcile >/dev/null 2>&1; then
devbox-skill-reconcile "$(dirname "$SKILLSET_DEPLOY")" || true
fi
fi
# ── Execute command ──────────────────────────────────────────────────
+91
View File
@@ -0,0 +1,91 @@
#!/bin/sh
# devbox-skill-reconcile — hand skillset-OWNED skills back to the live clone.
#
# WHY THIS EXISTS
# ---------------
# entrypoint-user.sh links the image-baked skills into ~/.agents/skills/ EARLY
# (before pi-deploy), because the smoke readiness probe gates on markers that
# only land later, and a link created after that gate produced a flaky
# assertion. Those links are created with a `[ ! -e ]` guard — "only when
# absent" — and the skillset deploy runs LAST, treating already-present links
# as foreign and leaving them alone. Net effect through v1.8.4: the baked copy
# always won, so an edit pushed to a skillset-owned skill was invisible in
# every container until the next image build (measured on two hosts: live
# skillset md5 129bcc4752 vs baked 5236024fef, the new section absent).
#
# The fix is NOT "the skillset always wins". Ownership is per-skill (see
# rootfs/usr/local/share/pi-devbox/skills/VENDORED.md):
#
# pi-devbox-environment authored in pi-devbox → baked IS canonical
# pi-extensions owned by the package repo, copied over the snapshot
# at build time; skillset carries a DOWNSTREAM copy
# that can lag → baked must keep winning
# mempalace owned by the skillset repo; baked is a snapshot
# fallback for containers with no skillset mounted
# → the live clone must win when it is present
#
# So only skills listed in skills/skillset-owned.txt are handed over. Baked
# links stay as the fallback (the early-link race fix is untouched), and a user
# override always beats both: a real directory is never replaced, and neither is
# a symlink that already points somewhere other than the baked tree.
#
# Usage: devbox-skill-reconcile <skillset-root> [skills-dir] [baked-src]
# skillset-root the mounted skillset repo (contains skills/<name>/)
# skills-dir default $HOME/.agents/skills
# baked-src default /usr/local/share/pi-devbox/skills
#
# Idempotent, and silent unless it changes something. Exits 0 when there is
# nothing to do (no skillset, no list) so the entrypoint never fails on it.
set -eu
SKILLSET_ROOT="${1:-}"
SKILLS_DIR="${2:-$HOME/.agents/skills}"
BAKED_SRC="${3:-/usr/local/share/pi-devbox/skills}"
BAKED_SRC="${BAKED_SRC%/}" # a trailing slash would make the prefix
# match below ("$BAKED_SRC"/*) match nothing
[ -n "$SKILLSET_ROOT" ] || exit 0
[ -d "$SKILLSET_ROOT/skills" ] || exit 0
[ -d "$SKILLS_DIR" ] || exit 0
# Absolutise BOTH roots before they are used, because each has its own way of
# failing silently when relative: a relative symlink TARGET is resolved against
# the link's directory (~/.agents/skills), not $PWD, so it would dangle on
# creation; and a relative BAKED_SRC would never prefix-match the absolute
# target that `readlink` reports, so every skill would be skipped and the fix
# would look like it had simply done nothing.
SKILLSET_ROOT=$(CDPATH= cd -- "$SKILLSET_ROOT" 2>/dev/null && pwd) || exit 0
BAKED_SRC=$(CDPATH= cd -- "$BAKED_SRC" 2>/dev/null && pwd) || exit 0
OWNED_LIST="$BAKED_SRC/skillset-owned.txt"
[ -f "$OWNED_LIST" ] || exit 0
while IFS= read -r _line || [ -n "$_line" ]; do
# strip comments and surrounding whitespace; skip blanks
_name=$(printf '%s\n' "$_line" | sed -e 's/#.*$//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
[ -n "$_name" ] || continue
# defensive: a list entry must be a plain skill name, never a path
case "$_name" in */*|.*) continue ;; esac
_live="$SKILLSET_ROOT/skills/$_name"
_link="$SKILLS_DIR/$_name"
# the skillset does not ship it → the baked fallback is all there is
[ -d "$_live" ] || continue
# a real directory is a user override → never touch
[ -L "$_link" ] || continue
# only ever replace OUR OWN link. readlink is deliberate: `readlink -f`
# would resolve a link that already points into the skillset clone and,
# since both trees hold a same-named skill, could not tell them apart.
_target=$(readlink "$_link" 2>/dev/null || true)
case "$_target" in
"$BAKED_SRC"/*|"$BAKED_SRC") ;; # baked link → ours to replace
*) continue ;; # user/foreign target → leave alone
esac
# -n so an existing symlink-to-directory is replaced rather than followed
# (without it, ln would create $_link/$_name inside the baked tree).
if ln -sfn "$_live" "$_link" 2>/dev/null; then
printf 'skill %s: baked snapshot -> live skillset (%s)\n' "$_name" "$_live"
fi
done < "$OWNED_LIST"
+24
View File
@@ -55,6 +55,10 @@ release_tag=$(jq -r '.release_tag' "$MANIFEST")
build_date=$(jq -r '.build_date' "$MANIFEST")
source_rev=$(jq -r '.source_revision' "$MANIFEST")
pi_version_baked=$(jq -r '.pi_version' "$MANIFEST")
# `// empty` matters: images built before v1.8.6 have no such field, and
# `jq -r` renders a JSON null as the 4-char string "null" — which would
# print as a bogus version rather than being treated as absent.
mp_version_baked=$(jq -r '.mempalace_version // empty' "$MANIFEST")
if [ "$MODE" = "quiet" ]; then
printf '%s (%s)\n' "$release_tag" "${source_rev:0:7}"
@@ -71,6 +75,16 @@ if command -v pi >/dev/null 2>&1; then
pi_version_live=$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')
fi
# Same check for the palace, which matters more than it looks: mempalace is
# the one component that is BOTH client (here) and server (synlig runs this
# same image), so a skew between the two is a real failure mode rather than
# cosmetic. `mempalace --version` prints "MemPalace 3.8.0" — name-prefixed,
# unlike pi's bare "0.84.3" — hence $NF rather than reading the whole line.
mp_version_live=""
if command -v mempalace >/dev/null 2>&1; then
mp_version_live=$(mempalace --version 2>/dev/null | head -n1 | awk '{print $NF}' | tr -d '\r\n')
fi
printf 'pi-devbox %s\n' "$release_tag"
printf ' built: %s (source %s)\n' "$build_date" "${source_rev:0:12}"
if [ -n "$pi_version_live" ] && [ "$pi_version_live" != "$pi_version_baked" ]; then
@@ -79,5 +93,15 @@ else
printf ' pi: %s\n' "${pi_version_live:-$pi_version_baked}"
fi
# Printed only when known, so this degrades quietly on pre-v1.8.6 images
# instead of showing an empty or "null" palace line.
if [ -n "$mp_version_live" ] || [ -n "$mp_version_baked" ]; then
if [ -n "$mp_version_live" ] && [ -n "$mp_version_baked" ] && [ "$mp_version_live" != "$mp_version_baked" ]; then
printf ' palace: %s \033[33m(baked as %s — drift detected)\033[0m\n' "$mp_version_live" "$mp_version_baked"
else
printf ' palace: %s\n' "${mp_version_live:-$mp_version_baked}"
fi
fi
printf ' components:\n'
jq -r '.components | to_entries[] | select(.value != null) | " \(.key): \(.value[0:12])"' "$MANIFEST"
@@ -41,3 +41,32 @@ especially load-bearing here — a pi-devbox container is frequently recreated,
the palace is your only memory across recreates. Without the habit it is just
storage, not memory. (The skill is the consumer side; feeding the palace is the
separate `opencode-mempalace-bridge` skill, if present.)
### If the palace is central, it is shared — three rules
If `MEMPALACE_REMOTE_URL` is set, the MCP tools write to a **central palace
shared with other machines**, not to a local one. Your drawers are not the only
ones in there, and most drawers' `source_file` paths do not exist on this host.
The skill covers the orientation side (provenance, chronology, whose diary is
whose); these three are here instead because getting them wrong does *damage*
rather than merely confusing you:
- **Never run `mempalace sync` / `mempalace_sync` against a shared palace.** It
prunes drawers whose source files look gitignored, deleted, or moved — and on
a shared palace that describes most of the content, including every other
machine's. Compounding it (RFC-001 §7.2): feeders now stage *inside* the
palace root, so a scoped sync can delete the very drawers it just filed.
`mempalace_delete_by_source` is exact-match rather than existence-based, but
its blast radius is now the whole fleet's palace — leave it on its default
`dry_run=true` and confirm the match count before committing.
- **A timeout is not a failure.** The palace is single-writer, and one large
mine can block every client for minutes, so a write or mine that exceeds the
client's deadline has usually *completed* server-side. Verify with
`mempalace_get_drawer` or `mempalace_search` before retrying — a blind retry
files a duplicate. `[mempalace ext] feed (tick) failed: mine timed out after
30000ms` is the common benign instance: the transcript is already in the
server's inbox and the mine is idempotent, so nothing is lost either way.
- **The `mempalace` CLI is not remote-aware.** It always opens a palace on
local disk, so `mempalace search` can return older and different results than
the MCP tools while both look correct. Use the MCP tools for the central
palace; the CLI only for a local one.
@@ -1,9 +1,10 @@
# Vendored fallback skills
Most directories here are **image-baked skills** that `entrypoint-user.sh`
symlinks into `~/.agents/skills/` on container start (only when a skill of the
same name is not already present, so a mounted `skillset` repo or a user
override always wins).
symlinks into `~/.agents/skills/` on container start. They are the **fallback**
layer: see *Runtime precedence* below for which copy actually wins when a
`skillset` repo is mounted (through v1.8.4 the answer was "always the baked
one", which was a bug).
| skill | owner | how it gets here |
|-------|-------|------------------|
@@ -38,6 +39,35 @@ its skill file needed baking.
*different* skill, `opencode-mempalace-bridge`), so there is no public
package source to copy from. This snapshot is refreshed manually per release.
## Runtime precedence (v1.8.5+)
The baked links are created **early** in `entrypoint-user.sh` (before pi-deploy,
to close a smoke readiness race) with a create-only-when-absent guard, and the
skillset deploy runs **last** and treats them as foreign links. Through v1.8.4
that combination meant the baked snapshot always won: an edit pushed to
`skillset/skills/mempalace/SKILL.md` was invisible in every container until the
next image build (measured on two hosts — live `md5 129bcc4752` vs baked
`5236024fef`, new section absent). Editing those skills *appeared* to work.
`devbox-skill-reconcile` now runs immediately after the skillset deploy and
repoints the links for skills the **skillset owns**, listed one per line in
`skillset-owned.txt`. Precedence, highest first:
1. **user override** — a real directory, or a symlink pointing outside the baked
tree; never touched by anything
2. **live skillset clone** — but only for names in `skillset-owned.txt`
3. **baked snapshot** — everything else, and every skill when no skillset is
mounted
Ownership is per-skill on purpose: `pi-extensions`' authoritative source is the
package repo (copied over the snapshot at build), and `skillset` carries a
downstream copy that can lag, so handing it to the clone would *regress* the
skill. Only `mempalace` is skillset-owned today.
Verify with `readlink -f ~/.agents/skills/<skill>` — not by reading the
entrypoint. Smoke covers both directions (baked resolution with no skillset
mounted, plus a fabricated-skillset run of the reconciler).
## Refreshing the snapshots
cp <pi-extensions-pkg>/skill/SKILL.md pi-extensions/SKILL.md
@@ -50,4 +80,9 @@ also carries a copy, but it is a downstream duplicate and can lag), and
`mempalace` from `skillset`. Copying `pi-extensions` from `skillset` would
regress the snapshot to whatever that repo last mirrored.
Snapshot provenance at last refresh: skillset `63f3bf5`, pi-extensions pkg `e73cb9f`.
Snapshot provenance at last refresh: skillset `670f7f1`, pi-extensions pkg `e73cb9f`.
When you refresh the `mempalace` snapshot, also update the phrase asserted by
the "mempalace skill snapshot is current" smoke test — it deliberately pins the
**newest** section, because the previous canary grepped a phrase that survived
the very edit that made the snapshot stale, and so passed on stale content.
@@ -275,18 +275,30 @@ Wings are top-level categories, typically one per project or domain:
- Named after the project directory (e.g., `cli_utils`, `opencode_devbox`)
- Agent diaries live in `wing_<agent_name>` (e.g., `wing_orchestrator`, `wing_pi`)
#### Multi-harness palace
#### Shared palace: multiple harnesses, and possibly multiple machines
A single palace can be fed by multiple coding-agent harnesses. On this machine the palace is shared between **opencode** and **pi** (Mario Zechner's pi-coding-agent). Implications:
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. 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.
- **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), five more 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.
- **Attribute what you file yourself.** Drawers now 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). Mined content gets these for free — the inbox path gives the device, the filename shape gives the harness — and a timer on the palace host re-stamps hourly, because live re-mining replaces metadata rows and silently drops earlier stamps. But for anything **you** file by hand, the only signal is what you pass: set `added_by="<harness>@<device>"` (e.g. `pi@emb-7kj4vr4g`, from `$MEMPALACE_PI_DEVICE`) on `add_drawer`/`checkpoint`/`mine`. Skip it and your drawer joins the ~16k historic `/workspace` project mines that are permanently unattributable, because `/workspace` exists identically on every devbox. Note the palace preserves `source_file` in full (see `source_path`) but *displays* only the basename — so a device prefix there survives storage even though it looks stripped.
- **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.
- **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:
@@ -324,3 +336,4 @@ Entity-relationship triples with temporal validity. Query with `mempalace_kg_que
- **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 hand-craft provenance.** Leave `added_by` alone (and never put a machine name in a diary's `agent_name` — it becomes the wing name and hides your entries from `diary_read`). Recording *which device* wrote a record is client/server infrastructure, not your job: a hostname or container ID is not a stable identity, and an invented value is worse than none because it silently corrupts any future palace merge. If you find notes in the palace describing an `origin_device` scheme, that is a design for the client to implement — not an instruction for you to start stamping.
@@ -130,6 +130,36 @@ are "command not found" there — you must spell out the underlying command.
If a command "works in my terminal but not when the agent runs it," this alias
gap is the first thing to suspect.
### A negative result is usually your own filter
**When you are about to report that something is absent, unreachable, or not
running, the filter you wrote is the prime suspect — not the thing.** This
environment produces false negatives cheaply, and they are convincing because
the command "succeeded". Three real instances from one session, all wrong, all
mine:
| Claim I made | Why it was false |
|---|---|
| "`tor-ms22` is not in the SSH config" | `grep … \| head -20` — the entry was at **line 454**. `~/.ssh/config` here is ~500 lines. |
| "the Docker host has no `docker`" | non-interactive SSH `PATH` lacks `/usr/local/bin` (§2, §3). It was at `/usr/local/bin/docker`. |
| "no ControlMaster is running" | pattern `ssh ` (trailing space) cannot match a master: those processes **rename themselves** to `ssh: <controlpath> [mux]`. |
Habits that would have caught all three:
```sh
# don't cap the output of a search whose answer you don't already know
grep -n -i -A6 'tor-ms22' ~/.ssh/config # not | head -20
# on the host, resolve the binary instead of trusting PATH
ssh -F "$HOME/.ssh-local/config" mac 'command -v docker || ls /usr/local/bin/docker'
# match a process's ACTUAL argv, not the name you imagine
ps -eo pid,etime,args | grep -Ei 'mux|mosh|ssh'
```
A positive result needs no such scepticism — it carries its own evidence. Only
absence has to be *earned*, so spend the extra command there.
**`dscp`/`scp` with accented filenames on a macOS host.** macOS stores filenames
in Unicode **NFD** (decomposed — e.g. `ä` is `a` + combining U+0308), while the
string you type or paste is usually **NFC** (precomposed `ä`, U+00E4). The bytes
@@ -175,6 +205,23 @@ Two related mechanisms (don't reinvent them):
CGNAT per-destination flow caps on residential ISPs. If `~/.ssh/config` pins
a `ControlPath` under the read-only `~/.ssh`, override with
`-o ControlPath=none` (or use the sidecar, which already redirects it).
- **A live master socket MASKS auth and config changes on the far end.** Once
`~/.ssh-local/cm/<user>@<host>:22` exists, later commands ride it and
authenticate **not at all** — so after editing remote `authorized_keys`,
`sshd_config`, host keys, or firewall rules, "it still works" proves nothing.
A corrupted `authorized_keys` then bites on the next *cold* connect, likely in
a future session with no memory of the edit. Prove it immediately instead:
```sh
ssh -F "$HOME/.ssh-local/config" -O check <host> # 'Master running (pid=N)'
ssh -F "$HOME/.ssh-local/config" -o ControlPath=none -o ControlMaster=no \
-o BatchMode=yes <host> 'echo COLD AUTH OK'
```
To attribute a socket rather than guess whose it is: `ps -p <pid> -o
pid,ppid,lstart,etime,args`. A `mosh` the *user* started on the host
bootstraps with the **host's** `~/.ssh/cm/` and is invisible from in here;
only a mosh started *inside* the container shares `~/.ssh-local/cm/`.
- **`pi --ssh <host>`** rewires pi's own read/write/edit/bash tools to run on a
remote host; it has its own writable-socket fallback. See the `pi-extensions`
skill for that path.
@@ -257,6 +304,10 @@ hardcode. Details are in the `mempalace` skill.
- [ ] Writing durable output? → `/workspace`, not the ephemeral layer.
- [ ] Using `dssh`/`dscp`/`ll` in the bash tool? → spell out the real command.
- [ ] Assuming a hostname / domain / nameserver / host OS? → stop, detect it.
- [ ] About to report something **absent / unreachable / not running**? → re-run
without your own `head`/pattern/`PATH` assumptions first (§2).
- [ ] Changed remote `authorized_keys` / `sshd_config`? → prove it with a **cold**
connect; a live master socket hides breakage (§3).
- [ ] "Resolves but won't connect"? → check route *and* DNS (§3 + §4).
- [ ] `apt`/toolchain install? → tell the user it's ephemeral unless imaged.
- [ ] Editing a skill? → `readlink -f ~/.agents/skills/<name>` first (§1).
@@ -0,0 +1,16 @@
# Skills in this directory whose OWNER is the skillset repo.
#
# Read by devbox-skill-reconcile, which runs after the skillset deploy in
# entrypoint-user.sh: for each name below, if the mounted skillset ships a
# skill of that name, the baked link in ~/.agents/skills/ is repointed at the
# live clone. The baked copy remains the fallback for containers started
# WITHOUT a skillset mount, and a user override always wins over both.
#
# Add a name here ONLY if the skillset repo is the authoritative source (see
# the ownership table in VENDORED.md). Do NOT add:
# pi-devbox-environment — authored in this repo; baked IS canonical
# pi-extensions — owned by the pi-extensions package repo and copied
# over the snapshot at build time; the skillset copy
# is a downstream duplicate that can lag, so letting
# it win would regress the skill.
mempalace
+234 -1
View File
@@ -43,12 +43,23 @@ PASS=0; FAIL=0
# catching an unexpected +GB regression.
SIZE_THRESHOLD_MB=3800
# On failure, surface the last few lines the command produced. This used to
# discard output entirely (`>/dev/null 2>&1`), which made a red ❌ carry zero
# diagnostic weight: explaining the single v1.8.0 stage-default failure took a
# full CI-log dig plus a registry-config inspection, when the container had
# already printed the answer and thrown it away. Assertions that want a
# diagnostic just echo it to stderr — it stays hidden while they pass.
run() {
local label="$1"; local cmd="$2"
if docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" >/dev/null 2>&1; then
local out
if out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" 2>&1); then
printf " ✅ %s\n" "$label"; PASS=$((PASS+1))
else
printf " ❌ %s\n" "$label"; FAIL=$((FAIL+1))
# `if`, not `&&` — a trailing false under `set -e` would abort the script.
if [ -n "$out" ]; then
printf " └─ %s\n" "$(printf '%s' "$out" | tail -3 | tr '\n' ' ' | cut -c1-300)"
fi
fi
}
@@ -91,6 +102,136 @@ run "terminfo: modern emulators (ncurses-term)" 'for t in wezterm alacritty foot
run "terminfo: xterm-ghostty alias (tic)" "infocmp -x xterm-ghostty >/dev/null 2>&1"
run "nvim true-colour default (sysinit.vim)" "nvim --headless -c 'lua os.exit(vim.o.termguicolors and 0 or 1)'"
run "mempalace-mcp" "mempalace-mcp --help"
run "mempalace-pi-session on PATH" "mempalace-pi-session --help"
# The staging dir must sit next to the palace, not in a disposable cache: the
# palace keys per-source dedup on the STAGED path, so a stage that can be wiped
# while the palace survives lets `mempalace sync` prune every drawer mined from
# it. Assert the resolved default, not an env var — the guarantee is "stage
# shares the palace's lifetime", which an ENV pin would quietly break.
# NOTE: --sessions-dir gets an EMPTY temp dir, never /tmp. The stage banner is
# printed before any export, so nothing needs to be found — and pointing a
# default-staged run at a populated dir would export whatever transcripts it
# finds into the real stage, which is how a synthetic test session ends up
# staged for mining as if it were a real conversation.
#
# Asserted $HOME-RELATIVE, not against a literal /home/developer. `run` invokes
# `docker run --entrypoint=""`, and neither Dockerfile sets USER or ENV HOME
# (HOME is set by entrypoint-user.sh, which --entrypoint="" deliberately skips),
# so these assertions execute as root with HOME=/root. The original literal
# /home/developer form could therefore never match and failed the v1.8.0
# release — a test bug, not a product one: the stage resolution was correct all
# along, it just follows $HOME. The invariant under test ("the stage sits beside
# the palace, sharing its lifetime") is user-independent, so pinning the user
# was never part of it. A cache-dir default still fails the pattern below, which
# is the regression this guards.
#
# It went unnoticed for three days because this workflow only triggers on
# `push: tags: v*` — the assertion was added on a main push, so v1.8.0 was its
# first execution ever. Use the `smoke_only` workflow_dispatch input to run
# smoke against HEAD without cutting a tag.
run "pi stage defaults next to the palace (not a cache dir)" '
out=$(mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
stage=$(echo "$out" | grep -oE "stage=[^ ]+" | head -1)
echo "resolved ${stage:-<no stage= line>} with HOME=$HOME" >&2
case "$stage" in
"stage=$HOME/.mempalace/pi-stage/"*) exit 0 ;;
*) exit 1 ;;
esac
'
# Companion to the above: the deployment-specific case the literal assertion was
# reaching for, done properly by supplying the HOME the container actually runs
# with instead of assuming it.
run "pi stage is palace-adjacent for the developer user" '
out=$(HOME=/home/developer mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
echo "$out" | grep -oE "stage=[^ ]+" | head -1 >&2
echo "$out" | grep -q "stage=/home/developer/.mempalace/pi-stage/"
'
run "pi stage follows MEMPALACE_PALACE_PATH" '
out=$(MEMPALACE_PALACE_PATH=/tmp/alt/.mempalace/palace \
mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
echo "$out" | grep -q "stage=/tmp/alt/.mempalace/pi-stage/"
'
# The feeder's --agent default is WHO a drawer is attributed to. mempalace core
# records neither the machine nor the harness on a write, and one shared bearer
# token means the server cannot tell clients apart, so toolkit c64ffa1 changed
# this default from $USER to pi@$MEMPALACE_PI_DEVICE — the one string that makes
# a write attributable to both. Nothing ever PRINTED the resolved value (the
# banner shows mode= and stage= only), so an image built from a pre-c64ffa1
# toolkit ref would ship unattributed writes with every check still green.
#
# `--help` assigns AGENT (script top) before it parses args, then exits 0 with
# no side effects — so `bash -x` observes the REAL resolution, env interpolation
# and fallback included, rather than grepping the source for a literal line that
# any reformat would break. Two-sided on purpose: device set => pi@<device>;
# device UNSET => must not be pi@anything. The second half is what fails against
# the old unconditional $USER default, which ignored the device entirely.
#
# Probes the PATH entry (a symlink into the /opt clone) rather than that clone
# path directly: this is the invocation the systemd/launchd timers and
# entrypoint-user.sh actually use, so it is the default that reaches the palace.
run "feeder resolves --agent to pi@<device> (drawer attribution)" '
f=$(command -v mempalace-pi-session) || { echo "feeder not on PATH" >&2; exit 1; }
with=$(MEMPALACE_PI_DEVICE=smoke-device bash -x $f --help 2>&1 | sed -n "s/^+* *AGENT=//p" | tail -n1)
without=$(env -u MEMPALACE_PI_DEVICE bash -x $f --help 2>&1 | sed -n "s/^+* *AGENT=//p" | tail -n1)
echo "resolved with-device=[$with] without-device=[$without]" >&2
[ "$with" = "pi@smoke-device" ] || exit 1
case "$without" in pi@*) exit 1 ;; esac
echo ok
'
# Regression guard for the pi transcript exporter. If pi ever changes its
# session JSONL shape, the exporter stops recognising sessions and the palace
# silently gets nothing (or, worse, raw JSON chunked as prose). Feed it a
# synthetic session and assert it is actually exported. Uses --dry-run so no
# palace is touched, and a temp stage so nothing real is written.
run "pi transcript exporter recognises a pi session" '
set -e
d=$(mktemp -d); s="$d/sessions/--workspace--"; mkdir -p "$s"
{
printf "%s\n" "{\"type\":\"session\",\"version\":1,\"id\":\"smoke\",\"cwd\":\"/workspace\",\"timestamp\":\"2026-01-01T00:00:00Z\"}"
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"question one\"}}"
a=$(printf "a%.0s" $(seq 1 1200))
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"$a\"}]}}"
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"question two\"}}"
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"short reply\"}]}}"
} > "$s/2026-01-01T00-00-00-000Z_smoke.jsonl"
out=$(mempalace-pi-session --dry-run --sessions-dir "$d/sessions" --stage "$d/stage" 2>&1)
echo "$out" | grep -q "Exported 1 session"
'
# The same guard from the other side: a session with no real assistant output
# (an abandoned prompt, whose bulk is injected skill text) must NOT be filed.
run "pi transcript exporter rejects an abandoned session" '
set -e
d=$(mktemp -d); s="$d/sessions/--workspace--"; mkdir -p "$s"
{
printf "%s\n" "{\"type\":\"session\",\"version\":1,\"id\":\"smoke2\",\"cwd\":\"/workspace\",\"timestamp\":\"2026-01-01T00:00:00Z\"}"
u=$(printf "u%.0s" $(seq 1 13000))
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"$u\"}}"
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Ready. What would you like to work on?\"}]}}"
} > "$s/2026-01-01T00-00-00-000Z_smoke2.jsonl"
out=$(mempalace-pi-session --dry-run --sessions-dir "$d/sessions" --stage "$d/stage" 2>&1)
echo "$out" | grep -q "no sessions qualified"
'
# The remote-palace-without-inbox skip must ANNOUNCE itself, not vanish. This
# branch of entrypoint-user.sh runs at container start (not reachable from a
# `docker run` one-shot), so assert against the entrypoint that actually shipped
# in the image. Guards a silent regression back to the bare `:` no-op, which
# left a container contributing nothing to the palace with no artifact saying
# why — the log it would normally leave is written by the other branch.
run_expect "remote-palace-without-inbox skip is announced, not silent" \
"grep -o 'MemPalace catch-up skipped' /usr/local/bin/entrypoint-user.sh | head -1" \
"MemPalace catch-up skipped"
run "...and the skip notice names the variable that fixes it" \
"grep -A6 'MemPalace catch-up skipped' /usr/local/bin/entrypoint-user.sh | grep -q 'MEMPALACE_PI_SSH_TARGET'"
# A remote mine that FAILS must not report success. MCP answers a hard tool
# failure with HTTP 200 and the tool's own JSON escaped inside
# result.content[].text, so the feeder's old `'\"error\"' in body` check could
# never see it: on 2026-08-15 a mine that died with "source directory not found:
# '/data/feed/...'" logged "Done. Wing updated." and exited 0, and this
# container's transcripts were filed nowhere for a whole session. The feeder
# carries fixtures for that exact body; run them against the baked toolkit so a
# stale/reverted toolkit ref can't reintroduce a silent feed.
run "baked feeder detects a failed remote mine (no silent false success)" \
"mempalace-pi-session --self-test"
# v1.0.0 base additions — verify presence and basic functionality.
run "pandoc" "pandoc --version"
run "typst" "typst --version"
@@ -121,6 +262,16 @@ run "image-baked mempalace fallback skill" \
# baked copy must be the fresh package copy (Option 1), not the stale snapshot.
run "pi-extensions skill refreshed from package when present" \
"if [ -f /opt/pi-extensions/skill/SKILL.md ]; then cmp -s /opt/pi-extensions/skill/SKILL.md /usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md; else true; fi"
# Runtime ownership handover (v1.8.5): the baked links are a FALLBACK, and
# skillset-OWNED skills must be repointed at the live clone when one is mounted.
# The list is data, so assert its content, not just its presence: mempalace in,
# pi-extensions deliberately out (its skillset copy is a lagging duplicate).
run "devbox-skill-reconcile helper present + executable" \
"test -x /usr/local/bin/devbox-skill-reconcile"
run "skillset-owned list ships and names mempalace" \
"grep -qx 'mempalace' /usr/local/share/pi-devbox/skills/skillset-owned.txt"
run "skillset-owned list excludes pi-extensions (ownership)" \
"! grep -qx 'pi-extensions' /usr/local/share/pi-devbox/skills/skillset-owned.txt"
# ── tmux 0-indexing (required for pi-studio variants) ─────────────────
echo ""
@@ -139,6 +290,26 @@ run "pi-fork clone + node_modules" \
"test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules"
run "pi-observational-memory clone + node_modules" \
"test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules"
# ...and that the clone carries the AUTH FIX, not merely that it exists. om's
# pre-flight hasUsableAuth() check silently disabled `recall` for ~8 weeks once
# pi moved to request-time SigV4 signing and stopped exposing a static Bedrock
# key; upstream fixed it in ce9fc98, adopted in v1.8.4. PI_OBSMEM_REF tracks
# master, so an upstream revert or force-push would ship a dead `recall` with
# the clone assertion above still green — the exact gap flagged as open in the
# v1.8.5 changelog.
#
# Pin the markers to src/runtime.ts, the fix SITE, rather than grepping the
# repo: two of these three strings also appear under tests/, so a repo-wide
# grep stays green with runtime.ts itself reverted. That is a false green of the
# same family as the old skill-snapshot canary.
run "pi-observational-memory carries the ce9fc98 auth fix (recall stays alive)" '
f=/opt/pi-observational-memory/src/runtime.ts
test -f "$f" || { echo "fix site missing: $f" >&2; exit 1; }
for m in availability_recheck providerCredentialConfigured hasConfiguredAuth; do
grep -q "$m" "$f" || { echo "marker absent from runtime.ts: $m" >&2; exit 1; }
done
echo ok
'
# pi-atelier: deliberately NO node_modules assertion, unlike its siblings —
# it declares zero runtime dependencies (only peerDeps, satisfied by the baked
# pi) and has no build step, so Dockerfile.variant skips `npm install` for it.
@@ -183,6 +354,20 @@ run_expect "manifest records pi-atelier" \
"cat /etc/pi-devbox/build-manifest.json" '"pi-atelier"'
run_expect "manifest records pi_version" \
"cat /etc/pi-devbox/build-manifest.json" '"pi_version"'
# mempalace CORE was absent from the manifest through v1.8.5: the toolkit SHA
# was recorded but the palace version behind the MCP tools was not, so a palace
# bug could not be correlated to an image version. Assert the field exists AND
# equals the installed binary — recording it from ARG MEMPALACE_VERSION instead
# would look identical here yet drift silently the first time an install
# resolved to something other than the pin, which is the whole reason this file
# is built from ground truth. `// empty` matters: jq -r prints the 4-char
# string "null" for a JSON null, which would satisfy a naive -n test.
run "manifest mempalace_version matches the installed core" '
m=$(jq -r ".mempalace_version // empty" /etc/pi-devbox/build-manifest.json)
b=$(mempalace --version 2>/dev/null | head -n1 | tr -d "\r"); b=${b##* }
echo "manifest=[$m] installed=[$b]" >&2
[ -n "$m" ] && [ "$m" = "$b" ]
'
# Every component must be a resolved commit (or null for pi-studio in the
# non-studio variant) — 'unknown' means a clone silently failed to resolve.
run "manifest has no unresolved ('unknown') components" \
@@ -254,6 +439,54 @@ exec_test "settings.json bootstrapped" 'test -f $HOME/.pi/agent/sett
exec_test "pi-devbox-environment skill linked" 'test -L $HOME/.agents/skills/pi-devbox-environment && test -f $HOME/.agents/skills/pi-devbox-environment/SKILL.md && echo ok'
exec_test "pi-extensions skill linked (fallback)" 'test -L $HOME/.agents/skills/pi-extensions && test -f $HOME/.agents/skills/pi-extensions/SKILL.md && echo ok'
exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills/mempalace && test -f $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
# The vendored mempalace snapshot is refreshed MANUALLY per release (see
# rootfs/usr/local/share/pi-devbox/skills/VENDORED.md). Through v1.8.4 it also
# silently SHADOWED the live skillset copy, so staleness was invisible — and the
# canary that was supposed to catch it could not: it grepped "Shared palace:
# multiple harnesses", a phrase present in BOTH the stale and the fresh copy.
# A snapshot canary must pin the NEWEST section, so update this string whenever
# the snapshot is refreshed — that is the point of it.
exec_test "mempalace skill snapshot is current" 'grep -q "Attribute what you file yourself" $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
# Link TARGETS, not just link existence: with no skillset mounted (as here) the
# baked tree must be what resolves, for all three vendored skills.
exec_test "vendored skills resolve to the baked tree (no skillset mounted)" \
'for s in mempalace pi-extensions pi-devbox-environment; do
case "$(readlink -f $HOME/.agents/skills/$s)" in
/usr/local/share/pi-devbox/skills/$s) ;;
*) echo "$s resolves to $(readlink -f $HOME/.agents/skills/$s)" >&2; exit 1 ;;
esac
done; echo ok'
# The handover path itself. CI never mounts a skillset, so without this the
# v1.8.5 fix would ship untested: fabricate a skillset + a skills dir holding
# baked-style links, run the reconciler, and assert all three outcomes —
# owned skill repointed, unowned skill left baked, user override untouched.
exec_test "reconciler: owned skill handed to live clone, others untouched" \
'set -e; t=$(mktemp -d); mkdir -p $t/ss/skills/mempalace $t/ss/skills/pi-extensions $t/skills
echo LIVE > $t/ss/skills/mempalace/SKILL.md; echo LIVE > $t/ss/skills/pi-extensions/SKILL.md
ln -s /usr/local/share/pi-devbox/skills/mempalace $t/skills/mempalace
ln -s /usr/local/share/pi-devbox/skills/pi-extensions $t/skills/pi-extensions
mkdir -p $t/skills/mine; echo MINE > $t/skills/mine/SKILL.md
devbox-skill-reconcile $t/ss $t/skills >/dev/null
devbox-skill-reconcile $t/ss $t/skills >/dev/null # idempotent
[ "$(readlink $t/skills/mempalace)" = "$t/ss/skills/mempalace" ] || { echo "owned skill NOT repointed" >&2; exit 1; }
[ "$(readlink $t/skills/pi-extensions)" = /usr/local/share/pi-devbox/skills/pi-extensions ] || { echo "unowned skill was repointed" >&2; exit 1; }
[ "$(cat $t/skills/mine/SKILL.md)" = MINE ] || { echo "user override clobbered" >&2; exit 1; }
rm -rf $t; echo ok'
# The case above cannot fail if the reconciler stops checking WHERE a link
# points — a mutation test showed all three of its assertions still passing with
# that guard deleted, which is the same false-green shape as the old snapshot
# canary. This one discriminates: an OWNED name (so it is considered) whose link
# is a user override pointing outside the baked tree (so it must be left alone).
exec_test "reconciler: user override on an owned name is left alone" \
'set -e; t=$(mktemp -d); mkdir -p $t/ss/skills/mempalace $t/skills $t/mine-skill
echo LIVE > $t/ss/skills/mempalace/SKILL.md; echo USERLINK > $t/mine-skill/SKILL.md
ln -sfn $t/mine-skill $t/skills/mempalace
devbox-skill-reconcile $t/ss $t/skills >/dev/null
[ "$(cat $t/skills/mempalace/SKILL.md)" = USERLINK ] || { echo "user symlink override clobbered" >&2; exit 1; }
rm -rf $t; echo ok'
# mempalace-census gained a /usr/local/bin symlink in v1.8.3; its three siblings
# had one since they were added, so this asserts the set stays complete.
exec_test "mempalace-census on PATH" 'command -v mempalace-census >/dev/null && mempalace-census --help >/dev/null && echo ok'
# pi-fork + pi-observational-memory are registered by entrypoint-user.sh via
# `pi install /opt/<pkg>`, which runs slightly after the keybindings marker.