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.
This commit is contained in:
Joakim Persson
2026-08-12 17:04:14 +02:00
parent 7649d53f3b
commit 7c00dd6001
6 changed files with 265 additions and 13 deletions
+46 -3
View File
@@ -12,16 +12,59 @@ SSH_KEY_PATH=~/.ssh
# ── MemPalace memory (local by default) ─────────────────────────── # ── MemPalace memory (local by default) ───────────────────────────
# By default the mempalace.ts extension spawns a LOCAL mempalace-mcp stdio # By default the mempalace.ts extension spawns a LOCAL mempalace-mcp stdio
# server (palace at ~/.mempalace). Uncomment the devbox-palace volume in # 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.
# #
# To instead share ONE MemPalace across containers/harnesses (pi + opencode # To instead share ONE MemPalace across containers/harnesses (pi + opencode
# + native), set the URL below. When set, the extension connects over HTTP # + 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 # 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. # 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_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 — must be
# the container path if the server runs in Docker
# (see docker-compose.mempalace.yml)
# 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) ───────────────── # ── LAN access from the container (host-OS-agnostic) ─────────────────
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't # On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
# reach the host's directly-attached LAN peers by default. The entrypoint # reach the host's directly-attached LAN peers by default. The entrypoint
+76
View File
@@ -11,6 +11,82 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
--- ---
## Unreleased
Minor-shaped. 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.
*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 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`.
### 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.
- 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 ## v1.7.0 — 2026-08-07
Minor release. Headline: **pi-atelier is now part of the image** — the TUI Minor release. Headline: **pi-atelier is now part of the image** — the TUI
+4 -1
View File
@@ -424,9 +424,12 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" =
[ "$ok" = "1" ] && \ [ "$ok" = "1" ] && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \ 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 && \ 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 && \
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs \
/opt/mempalace-toolkit/bin/mempalace-pi-session && \
mempalace-session --help >/dev/null && \ mempalace-session --help >/dev/null && \
mempalace-docs --help >/dev/null && \ mempalace-docs --help >/dev/null && \
mempalace-pi-session --help >/dev/null && \
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \ echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
fi fi
+36 -9
View File
@@ -5,6 +5,7 @@
# Point every client at it by setting, in that client's .env: # Point every client at it by setting, in that client's .env:
# #
# MEMPALACE_REMOTE_URL=http://<reachable-host>:8765/mcp # 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 # (see .env.example). When set, the client connects over HTTP and does NOT
# spawn its own local mempalace-mcp. # spawn its own local mempalace-mcp.
@@ -18,12 +19,21 @@
# (both are pinned by the same image build). Override with a slimmer image via # (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`). # MEMPALACE_SERVER_IMAGE if you prefer (it must provide `mempalace-mcp`).
# #
# ⚠ SECURITY: mempalace-mcp's HTTP transport has NO authentication of its own. # ⚠ SECURITY: the HTTP transport IS authenticated as of mempalace 3.6.0 — an
# Do NOT expose port 8765 to an untrusted network. The default below binds to # earlier version of this comment said otherwise and was wrong. The server
# 127.0.0.1 (host loopback) only. To let sibling containers reach it, either # compares `Authorization: Bearer <token>` with hmac.compare_digest and
# attach them to the shared `mempalace-net` network (container-to-container, no # **refuses to start on a non-loopback bind without a token**, so
# host port needed — use http://mempalace-server:8765/mcp), or front it with a # MEMPALACE_REMOTE_TOKEN below is required, not optional: without it this
# reverse proxy that enforces MEMPALACE_REMOTE_TOKEN as `Authorization: Bearer`. # 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 name: mempalace-server
@@ -40,6 +50,11 @@ services:
user: "0:0" user: "0:0"
environment: environment:
- HOME=/data - 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: command:
- mempalace-mcp - mempalace-mcp
- --transport - --transport
@@ -60,16 +75,28 @@ services:
- mempalace-shared:/data/.mempalace - mempalace-shared:/data/.mempalace
# Embedding-model cache (~79 MB, disposable) so search does not re-download. # Embedding-model cache (~79 MB, disposable) so search does not re-download.
- mempalace-shared-chroma:/data/.cache/chroma - 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: networks:
- mempalace-net - mempalace-net
healthcheck: healthcheck:
# A tools/list round-trip proves the server is answering MCP (python3 is # GET /healthz, which is Host/Origin-gated but deliberately token-free —
# always present — mempalace itself is a python tool in the image). # 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: test:
- CMD - CMD
- python3 - python3
- -c - -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 interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
+50
View File
@@ -92,6 +92,56 @@ if command -v mempalace &>/dev/null && [ -d /workspace ]; then
fi fi
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 configured — nothing we can ship to; skip quietly
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 ────────────────────────────────────────────── # ── Git config defaults ──────────────────────────────────────────────
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
git config --global user.name "$GIT_USER_NAME" git config --global user.name "$GIT_USER_NAME"
+53
View File
@@ -91,6 +91,59 @@ 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 "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 "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-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.
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
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/"
'
# 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"
'
# v1.0.0 base additions — verify presence and basic functionality. # v1.0.0 base additions — verify presence and basic functionality.
run "pandoc" "pandoc --version" run "pandoc" "pandoc --version"
run "typst" "typst --version" run "typst" "typst --version"