Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 13e67599c4 | |||
| 7551947466 | |||
| a7d6a7d235 | |||
| d619a6e2ec | |||
| 2abfee141b | |||
| c346a106a3 | |||
| 8de0fad776 | |||
| ed49b8d97a | |||
| 9eff3f3c48 | |||
| a0abacaafb | |||
| da7d70825e | |||
| 41c2c2b716 | |||
| 5c08bfc8a8 | |||
| 1371584634 | |||
| d902b2d056 | |||
| c48abf41d1 | |||
| 777d53354f | |||
| 52fe09d79d | |||
| c9534c639f | |||
| 4ed6764323 | |||
| f8da7890df | |||
| b17dc1fa1f | |||
| 3eec9bc23c | |||
| 4744f05232 |
@@ -47,6 +47,7 @@ env:
|
||||
jobs:
|
||||
# ── Phase 1: decide whether base needs rebuilding ──────────────────
|
||||
base-decide:
|
||||
needs: [resolve-versions]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -57,6 +58,9 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Guard — base *_REF args must be folded into the base hash
|
||||
run: bash scripts/check-base-hash.sh
|
||||
|
||||
- name: Compute base tag from Dockerfile.base + dependencies
|
||||
id: compute
|
||||
run: |
|
||||
@@ -75,6 +79,10 @@ jobs:
|
||||
! -name '._*' \
|
||||
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
|
||||
cat entrypoint.sh entrypoint-user.sh
|
||||
# mempalace-toolkit is cloned in Dockerfile.base at a ref CI
|
||||
# resolves to a SHA; fold it in so base_tag changes when the
|
||||
# toolkit moves (otherwise a toolkit-only fix never lands).
|
||||
echo "${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}"
|
||||
} | sha256sum | cut -c1-12
|
||||
)
|
||||
BASE_TAG="base-${HASH}"
|
||||
@@ -117,54 +125,85 @@ jobs:
|
||||
toolkit_ref: ${{ steps.resolve.outputs.toolkit_ref }}
|
||||
extensions_ref: ${{ steps.resolve.outputs.extensions_ref }}
|
||||
studio_ref: ${{ steps.resolve.outputs.studio_ref }}
|
||||
mempalace_toolkit_ref: ${{ steps.resolve.outputs.mempalace_toolkit_ref }}
|
||||
steps:
|
||||
- name: Resolve pi version + companion refs
|
||||
id: resolve
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
# Query npm registry directly; catthehacker/ubuntu:act-latest's npm
|
||||
# is not reliably on PATH in act_runner job containers.
|
||||
PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version')
|
||||
set -euo pipefail
|
||||
AUTH_HEADER="Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}"
|
||||
|
||||
# Fail loud rather than silently shipping a floating branch. A
|
||||
# transient network/API failure must ABORT the release, not bake
|
||||
# an unpinned ref that defeats both cache-busting AND after-the-
|
||||
# fact reproducibility. (Previously each lookup fell back to
|
||||
# `main`/`master` via `|| echo`.)
|
||||
require_sha() { # $1=label $2=value
|
||||
if ! printf '%s' "${2:-}" | grep -qiE '^[0-9a-f]{40}$'; then
|
||||
echo "::error::Could not resolve $1 to a commit SHA (got '${2:-<empty>}'). Refusing to fall back to a floating ref — published images must stay reproducible. Check connectivity and GITEA_BUILD_TOKEN/GITHUB_TOKEN."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# pi version from npm (catthehacker/ubuntu:act-latest's npm is not
|
||||
# reliably on PATH in act_runner job containers, so query directly).
|
||||
PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version' 2>/dev/null || true)
|
||||
if ! printf '%s' "${PI_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then
|
||||
echo "::error::Could not resolve pi version from npm (got '${PI_VERSION:-<empty>}')."
|
||||
exit 1
|
||||
fi
|
||||
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
# Resolve pi-fork / pi-observational-memory git refs to commit
|
||||
# SHAs so the build-arg string changes whenever upstream moves.
|
||||
|
||||
# pi-fork / pi-observational-memory (GitHub) → commit SHAs.
|
||||
FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || echo "master")
|
||||
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || true)
|
||||
require_sha PI_FORK_REF "$FORK_REF"
|
||||
OBSMEM_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || echo "master")
|
||||
[ -n "$FORK_REF" ] || FORK_REF=master
|
||||
[ -n "$OBSMEM_REF" ] || OBSMEM_REF=master
|
||||
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || true)
|
||||
require_sha PI_OBSMEM_REF "$OBSMEM_REF"
|
||||
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT"
|
||||
# Also resolve pi-toolkit / pi-extensions main HEADs to SHAs so a
|
||||
# workflow_dispatch re-run produces byte-identical images when
|
||||
# those repos haven't moved (and a clean diff in build-arg strings
|
||||
# when they have, defeating the registry buildcache footgun).
|
||||
# Gitea API requires auth even for public-repo commit listing.
|
||||
TOOLKIT_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \
|
||||
|
||||
# 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 // "main"' 2>/dev/null || echo "main")
|
||||
EXTENSIONS_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \
|
||||
| jq -r '.[0].sha // empty' 2>/dev/null || true)
|
||||
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 // "main"' 2>/dev/null || echo "main")
|
||||
[ -n "$TOOLKIT_REF" ] || TOOLKIT_REF=main
|
||||
[ -n "$EXTENSIONS_REF" ] || EXTENSIONS_REF=main
|
||||
| jq -r '.[0].sha // empty' 2>/dev/null || true)
|
||||
require_sha PI_EXTENSIONS_REF "$EXTENSIONS_REF"
|
||||
echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT"
|
||||
# Resolve pi-studio (omaclaren/pi-studio) main HEAD to a SHA for
|
||||
# the :latest-studio variant — same cache-busting rationale.
|
||||
|
||||
# mempalace-toolkit (Gitea) → commit SHA. UNLIKE the others this
|
||||
# is cloned in Dockerfile.base, so the SAME SHA is ALSO folded
|
||||
# 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)
|
||||
require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF"
|
||||
echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# pi-studio (omaclaren/pi-studio) → commit SHA for :latest-studio.
|
||||
STUDIO_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/omaclaren/pi-studio/commits/main" || echo "main")
|
||||
[ -n "$STUDIO_REF" ] || STUDIO_REF=main
|
||||
"https://api.github.com/repos/omaclaren/pi-studio/commits/main" || true)
|
||||
require_sha PI_STUDIO_REF "$STUDIO_REF"
|
||||
echo "studio_ref=${STUDIO_REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Resolved PI_VERSION=${PI_VERSION}"
|
||||
echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}"
|
||||
echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}"
|
||||
echo "Resolved PI_STUDIO_REF=${STUDIO_REF}"
|
||||
echo "Resolved MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}"
|
||||
|
||||
# ── Phase 2: build & push base (multi-arch), only when needed ──────
|
||||
build-base:
|
||||
needs: [base-decide]
|
||||
needs: [base-decide, resolve-versions]
|
||||
if: needs.base-decide.outputs.need_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
@@ -206,6 +245,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
BASE_TAG_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 3-attempt retry around `docker buildx build --push` for transient
|
||||
@@ -219,6 +259,7 @@ jobs:
|
||||
if docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file Dockerfile.base \
|
||||
--build-arg MEMPALACE_TOOLKIT_REF="${MEMPALACE_TOOLKIT_REF}" \
|
||||
--push \
|
||||
--tag "${BASE_TAG_FULL}" \
|
||||
.; then
|
||||
@@ -280,6 +321,9 @@ jobs:
|
||||
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
PI_TOOLKIT_REF=${{ needs.resolve-versions.outputs.toolkit_ref }}
|
||||
PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }}
|
||||
MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
||||
RELEASE_TAG=smoke
|
||||
SOURCE_REVISION=${{ github.sha }}
|
||||
- name: Smoke test (amd64)
|
||||
env:
|
||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
@@ -336,6 +380,9 @@ jobs:
|
||||
PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }}
|
||||
INSTALL_STUDIO=true
|
||||
PI_STUDIO_REF=${{ needs.resolve-versions.outputs.studio_ref }}
|
||||
MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
||||
RELEASE_TAG=smoke-studio
|
||||
SOURCE_REVISION=${{ github.sha }}
|
||||
- name: Smoke test studio (amd64)
|
||||
env:
|
||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
@@ -387,10 +434,12 @@ jobs:
|
||||
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }}
|
||||
EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }}
|
||||
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_FLAGS=()
|
||||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
# 3-attempt retry (see build-base step for rationale).
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
@@ -404,6 +453,10 @@ jobs:
|
||||
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
|
||||
--build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \
|
||||
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
|
||||
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
|
||||
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \
|
||||
--build-arg "BUILD_DATE=${BUILD_DATE}" \
|
||||
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
|
||||
"${TAG_FLAGS[@]}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
@@ -468,10 +521,12 @@ jobs:
|
||||
TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }}
|
||||
EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }}
|
||||
STUDIO_REF: ${{ needs.resolve-versions.outputs.studio_ref }}
|
||||
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_FLAGS=()
|
||||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
# 3-attempt retry (see build-base step for rationale).
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
@@ -485,8 +540,12 @@ jobs:
|
||||
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
|
||||
--build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \
|
||||
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
|
||||
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
|
||||
--build-arg "INSTALL_STUDIO=true" \
|
||||
--build-arg "PI_STUDIO_REF=${STUDIO_REF}" \
|
||||
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \
|
||||
--build-arg "BUILD_DATE=${BUILD_DATE}" \
|
||||
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
|
||||
"${TAG_FLAGS[@]}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
|
||||
@@ -14,15 +14,28 @@ re-brand of opencode-devbox's `pi-only` variant.
|
||||
- `Dockerfile.variant` — `FROM base-<hash>`, adds pi + companions
|
||||
(`pi-toolkit`, `pi-extensions`, `pi-fork`, `pi-observational-memory`)
|
||||
and, when `INSTALL_STUDIO=true`, vendors `pi-studio` to `/opt/pi-studio`
|
||||
(`-studio` variant).
|
||||
(`-studio` variant). Also appends the pi-devbox managed block from
|
||||
`pi-global-AGENTS.append.md` onto pi-toolkit's `pi-global-AGENTS.md` (the
|
||||
single global instruction slot pi loads) so containers proactively load the
|
||||
baked `pi-devbox-environment` skill. Idempotent via a marker grep. After the
|
||||
pinned clones it also refreshes the vendored `pi-extensions` fallback skill
|
||||
by copying `/opt/pi-extensions/skill/` over the committed `rootfs/` snapshot
|
||||
(Option 1 over Option 2 — see `skills/VENDORED.md`).
|
||||
- `entrypoint.sh` — UID/GID alignment as root, then drops to `developer`.
|
||||
- `entrypoint-user.sh` — per-container start: SSH ControlMaster socket
|
||||
dir, LAN-access setup, MemPalace init, pi-toolkit + pi-extensions
|
||||
deploy, mempalace-bridge symlink, fork/recall + pi-studio pi-install,
|
||||
optional `studio-expose` bridge (when `STUDIO_EXPOSE=1`), skillset
|
||||
deploy.
|
||||
optional `studio-expose` bridge (when `STUDIO_EXPOSE=1`), image-baked
|
||||
skills symlink-in, skillset deploy.
|
||||
- `rootfs/` — files baked into the image (bash aliases, inputrc,
|
||||
setup-lan-access.sh, `studio-expose` helper).
|
||||
setup-lan-access.sh, `studio-expose` helper). Also
|
||||
`usr/local/share/pi-devbox/skills/<name>/SKILL.md` — image-baked agent
|
||||
skills (the repo-authored `pi-devbox-environment`, plus vendored fallback
|
||||
copies of `pi-extensions` and `mempalace` — see `skills/VENDORED.md`)
|
||||
symlinked into `~/.agents/skills/` by the entrypoint, available with or
|
||||
without a mounted skillset — plus
|
||||
`usr/local/share/pi-devbox/pi-global-AGENTS.append.md` (the global-AGENTS
|
||||
pointer concatenated in `Dockerfile.variant`).
|
||||
- `scripts/smoke-test.sh` — sanity checks run by CI before pushing to Hub.
|
||||
- `.gitea/workflows/docker-publish.yml` — two-phase CI (base-decide →
|
||||
build-base → smoke → build-variant → promote-base-latest →
|
||||
@@ -33,7 +46,8 @@ re-brand of opencode-devbox's `pi-only` variant.
|
||||
## Versioning scheme
|
||||
|
||||
- Tags follow semver. **v1.0.0** is the first decoupled release; future
|
||||
minor bumps add variants (`-studio`, `-studio-tex`); patch bumps follow
|
||||
minor bumps add variants (`-studio`, `-studio-tex`) or significant base
|
||||
additions (e.g. v1.2.0 image-baked agent skills); patch bumps follow
|
||||
pi npm version updates and small fixes.
|
||||
- Docker Hub tags: `joakimp/pi-devbox:vX.Y.Z` + `joakimp/pi-devbox:latest`
|
||||
+ (since v1.1.0) `joakimp/pi-devbox:vX.Y.Z-studio` +
|
||||
@@ -45,9 +59,17 @@ re-brand of opencode-devbox's `pi-only` variant.
|
||||
|
||||
1. Confirm `pi --version` resolves from npm to the expected version
|
||||
(`curl -sf 'https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest' | jq -r .version`).
|
||||
Check release notes at https://github.com/earendil-works/pi/releases for
|
||||
the upstream changelog to include in `CHANGELOG.md`.
|
||||
2. Update `CHANGELOG.md` Unreleased → vX.Y.Z section.
|
||||
3. Verify `docker compose up` works locally with the current `latest` image
|
||||
if you're upgrading users from a previous version.
|
||||
if you're upgrading users from a previous version. Then run the
|
||||
**post-recreate sanity check** inside the running container to confirm
|
||||
persisted volumes survived and the pi runtime wiring re-deployed (not just
|
||||
that the container booted):
|
||||
`docker compose exec devbox bash scripts/recreate-sanity-check.sh --expected-version X.Y.Z`
|
||||
(or just `pi-devbox-sanity --expected-version X.Y.Z` if `cli_utils/bin` is
|
||||
on PATH). This is the runtime peer of the build-time `smoke-test.sh` gate.
|
||||
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
|
||||
@@ -55,7 +77,24 @@ re-brand of opencode-devbox's `pi-only` variant.
|
||||
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
|
||||
`gitea.jordbo.se/user/settings/applications`.
|
||||
`gitea.jordbo.se/user/settings/applications`. N/A if you used the
|
||||
`GITEA_ACCESS_TOKEN` env var instead (see *Gitea API access* below) —
|
||||
its lifecycle is managed host-side, nothing to revoke.
|
||||
|
||||
## Gitea API access (env token)
|
||||
|
||||
`GITEA_ACCESS_TOKEN` + `GITEA_HOST` are passed into the container from the
|
||||
host `.env` via `docker-compose.yml` (`${GITEA_ACCESS_TOKEN:-}` /
|
||||
`${GITEA_HOST:-}`), primarily to enable the `gitea-mcp` server. They are
|
||||
**not** baked into the image. When configured, they are also available for
|
||||
**any** direct Gitea API interaction from inside the container — inspecting
|
||||
CI runs, checking published tags, listing commits — e.g.
|
||||
`curl -H "Authorization: token $GITEA_ACCESS_TOKEN" "$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs?limit=5"`.
|
||||
Prefer this over a short-lived PAT file when the env token is present (the
|
||||
`ci-release-watcher` skill auto-detects it). Public-repo GET listings work
|
||||
unauthenticated too, so the token matters mainly for private repos or
|
||||
rate-limit headroom; its lifecycle is host-managed, so there is nothing to
|
||||
revoke after use. Never echo the token value (including into logs).
|
||||
|
||||
## Cache-hit footgun (must-know)
|
||||
|
||||
|
||||
+383
-1
@@ -11,6 +11,388 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
|
||||
|
||||
---
|
||||
|
||||
## v1.2.1 — 2026-06-22
|
||||
|
||||
Patch release: close the fork/recall + mempalace **under-utilisation gap** in
|
||||
containers started without the private `skillset` repo — bake the
|
||||
`pi-extensions` and `mempalace` skills into the image and add the missing
|
||||
mempalace session-start directive. pi version is re-resolved from npm `latest`
|
||||
at build.
|
||||
|
||||
### Added
|
||||
|
||||
- **Vendored fallback skills: `pi-extensions` + `mempalace`.** The pi-toolkit
|
||||
global `AGENTS.md` directs every pi session to read
|
||||
`~/.agents/skills/pi-extensions/SKILL.md` at start (the fix for fork/recall
|
||||
under-utilisation). That pointer dangled in a container started **without**
|
||||
the private `skillset` repo mounted. The image now bakes fallback copies of
|
||||
both skills under `/usr/local/share/pi-devbox/skills/`, symlinked in by
|
||||
`entrypoint-user.sh` (only when absent, so a mounted skillset still wins).
|
||||
- **Proactive-load directive for `mempalace`.** Baking the skill only fixes
|
||||
*availability*; nothing in pi-toolkit's global `AGENTS.md` told sessions to
|
||||
load it, so it would still surface only via description-matching. The
|
||||
pi-devbox managed block (`pi-global-AGENTS.append.md`) now adds a
|
||||
session-start pointer (gated to pi-devbox containers, conditional on the
|
||||
MemPalace MCP tools being present) so a new container actually picks the
|
||||
skill up — memory continuity matters most in a frequently-recreated
|
||||
container. (`pi-extensions`'s directive already ships in pi-toolkit, so only
|
||||
its skill file needed baking.)
|
||||
- **Layered freshness for the `pi-extensions` skill (Option 1 + Option 2).**
|
||||
The canonical skill was promoted into the **public `pi-extensions` package
|
||||
repo** under `skill/` (co-located with the extensions it documents). A
|
||||
committed snapshot in `rootfs/` is the *floor*; `Dockerfile.variant` copies
|
||||
`/opt/pi-extensions/skill/` (the pinned, manifest-recorded clone) over it at
|
||||
build, so a normal build ships the fresh package copy and an old-ref/mirror
|
||||
build still ships the snapshot. `mempalace` is snapshot-only (its consumer
|
||||
skill has no public package home — the `mempalace-toolkit` repo ships a
|
||||
*different* skill, `opencode-mempalace-bridge`). Provenance + refresh steps:
|
||||
`rootfs/usr/local/share/pi-devbox/skills/VENDORED.md`.
|
||||
- **Smoke-test coverage** for the fallback skills: build-time presence of both
|
||||
`SKILL.md`s and the `pi-extensions` helper, a check that the baked
|
||||
`pi-extensions` skill matches the package copy when the clone carries it, and
|
||||
runtime assertions that both are symlinked into `~/.agents/skills/`.
|
||||
|
||||
---
|
||||
|
||||
## v1.2.0 — 2026-06-22
|
||||
|
||||
Minor release: **image-baked agent skills** — a new base mechanism that ships
|
||||
skills inside the image (independent of any mounted skillset repo) — plus the
|
||||
first such skill, `pi-devbox-environment`, and pi `0.79.9` → `0.79.10`
|
||||
(auto-resolved from npm `latest` at build).
|
||||
|
||||
### Added
|
||||
|
||||
- **Image-baked agent skills.** Skills under
|
||||
`/usr/local/share/pi-devbox/skills/<name>/` are now symlinked into
|
||||
`~/.agents/skills/` by `entrypoint-user.sh` on every start, making them
|
||||
available **with or without** a mounted `skillset` repo. The symlink points
|
||||
at the image path (so it survives volume recreate, unlike anything baked
|
||||
under a home dir a named volume would shadow) and is created only when
|
||||
absent, so a same-named skillset skill or user override is never clobbered.
|
||||
The skillset deploy classifies these as foreign-links and its `--prune-stale`
|
||||
pass leaves them untouched.
|
||||
- **`pi-devbox-environment` skill** (the first image-baked skill). Teaches
|
||||
agents the container-shaped facts that are easy to get wrong: the
|
||||
persistence/ephemerality tier model (what survives `down -v` / image
|
||||
update), host + LAN SSH reachability and ControlMaster, split-horizon DNS
|
||||
*mechanisms*, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`/
|
||||
`cat`→`bat` don't exist in the non-interactive bash tool), the tmux 0-index
|
||||
constraint, uv-first Python, and pi-studio reachability. Deliberately
|
||||
environment-agnostic — host OS, hostnames, internal domains, and nameservers
|
||||
are discovered at runtime, never hardcoded.
|
||||
- **Proactive skill awareness via the global `AGENTS.md`.** `Dockerfile.variant`
|
||||
appends a short, gated pointer (`pi-global-AGENTS.append.md`) onto
|
||||
pi-toolkit's `pi-global-AGENTS.md` — the single global instruction slot pi
|
||||
loads at startup — so containers load the `pi-devbox-environment` skill
|
||||
proactively rather than only on description match. The pointer fires only
|
||||
inside a pi-devbox container (checks for `/usr/local/lib/pi-devbox/`).
|
||||
Build-time append is idempotent via a marker grep; runtime is unaffected
|
||||
(the file is root-owned and re-symlinked by pi-toolkit each boot).
|
||||
- **Smoke-test coverage** for the new mechanism: build-time presence of the
|
||||
baked skill + append snippet + the merged marker in `pi-global-AGENTS.md`,
|
||||
and a runtime assertion that `~/.agents/skills/pi-devbox-environment` is
|
||||
linked after the entrypoint runs.
|
||||
|
||||
### Bumped: pi 0.79.9 → 0.79.10
|
||||
|
||||
Resolved from npm `latest` at build (v1.1.7 shipped `0.79.9`). See the
|
||||
[pi changelog](https://github.com/earendil-works/pi/blob/main/CHANGELOG.md)
|
||||
for the upstream `0.79.10` notes.
|
||||
|
||||
## v1.1.7 — 2026-06-21
|
||||
|
||||
Patch release: pi `0.79.8` → `0.79.9` (auto-resolved at build), plus the
|
||||
`ssh-lan.conf` LAN-peer documentation that landed on `main` after v1.1.6.
|
||||
Companion refs are auto-resolved to SHAs at build as before.
|
||||
|
||||
### Bumped: pi 0.79.8 → 0.79.9
|
||||
|
||||
Notable upstream changes (from [pi releases](https://github.com/earendil-works/pi/releases/tag/v0.79.9)):
|
||||
|
||||
- **Chat-template thinking compatibility** — OpenAI-compatible custom
|
||||
providers can map pi thinking levels into `chat_template_kwargs`, enabling
|
||||
vLLM/Hugging Face chat-template models (e.g. DeepSeek) to use
|
||||
provider-native thinking controls.
|
||||
- **GLM-5.2 provider improvements** — corrected Fireworks OpenAI-compatible
|
||||
routing and OpenRouter `xhigh` thinking support, improving `/model`
|
||||
behaviour and high-effort reasoning for GLM-5.2.
|
||||
- **Fixes** — same-directory session switches now reuse imported extension
|
||||
modules (fresh instances + lifecycle events preserved); deep session
|
||||
branches no longer take quadratic time to build context; Markdown
|
||||
streaming code-fence rendering no longer flickers on partial closing
|
||||
fences; fuzzy `edit` matches preserve untouched line blocks instead of
|
||||
rewriting the whole file; `/model` hides Copilot models unavailable to the
|
||||
account and ranks exact provider-prefixed matches first.
|
||||
|
||||
### Docs: document `~/.config/devbox-shell/ssh-lan.conf` for naming LAN peers
|
||||
|
||||
The host-owned, bind-mounted `~/.config/devbox-shell/ssh-lan.conf` is the
|
||||
intended place to add `ProxyJump host` overrides for **named** LAN peers (so
|
||||
`pi --ssh <peer>` / `dssh <peer>` route through the host), but it was only
|
||||
mentioned in `.env.example` and the `setup-lan-access.sh` header — never in the
|
||||
README. Added a "Naming LAN peers" subsection to the README troubleshooting
|
||||
block (plus a pointer from the SSH/ControlMaster section), and corrected the
|
||||
stale `setup-lan-access.sh` comment that suggested editing the read-only
|
||||
`~/.ssh/config` instead of `ssh-lan.conf`.
|
||||
|
||||
## v1.1.6 — 2026-06-19
|
||||
|
||||
Build provenance + reproducibility hardening, plus pi `0.79.7` → `0.79.8`
|
||||
(auto-resolved at build). Companion refs are auto-resolved to SHAs at build
|
||||
as before.
|
||||
|
||||
### Bumped: pi 0.79.7 → 0.79.8
|
||||
|
||||
Notable upstream changes (from [pi releases](https://github.com/earendil-works/pi/releases/tag/v0.79.8)):
|
||||
|
||||
- **Selective provider base entry points** — SDK users can pair
|
||||
`@earendil-works/pi-ai/base` and `@earendil-works/pi-agent-core/base` with
|
||||
explicit provider registration to keep bundled apps from including unused
|
||||
provider transports.
|
||||
- **Mistral prompt caching** — Mistral sessions use provider-side prompt
|
||||
caching keyed on the pi session ID, with cached-token usage/cost
|
||||
accounting.
|
||||
- **Post-compaction token estimates** — compact results and compaction
|
||||
events now include estimated post-compaction token counts.
|
||||
- **OpenRouter Fusion alias** — `openrouter/fusion` available as a built-in
|
||||
OpenRouter model alias.
|
||||
|
||||
### Added
|
||||
|
||||
- **Self-describing images: OCI labels + on-disk build manifest.** The
|
||||
variant build now records exactly which pi version and companion-repo
|
||||
commits were baked into each image. Previously the SHAs resolved by CI
|
||||
only ever reached the build log (which rotates), so a published tag was
|
||||
not reconstructable after the fact — confirming what shipped meant
|
||||
triangulating from `git`, `pi --version`, and extension source.
|
||||
- OCI labels: `org.opencontainers.image.{version,revision,created}` plus
|
||||
`se.jordbo.pi-devbox.{pi,pi-toolkit,pi-extensions,pi-fork,pi-obsmem,mempalace-toolkit,pi-studio}-*ref` —
|
||||
inspect with `docker inspect`.
|
||||
- `/etc/pi-devbox/build-manifest.json` written from **ground truth** (the
|
||||
actual checked-out `HEAD` of each `/opt` clone + live `pi --version`),
|
||||
not just the intended build-args, so it also exposes a clone that
|
||||
silently resolved to the wrong ref. The provenance ARGs are declared
|
||||
last so a changing `BUILD_DATE` never invalidates the expensive
|
||||
install/clone layers.
|
||||
- **`scripts/check-base-hash.sh` — base-rebuild invariant guard.** Every
|
||||
floating `ARG *_REF` consumed by `Dockerfile.base` must be folded into the
|
||||
`base_tag` hash, or a ref-only change won't trigger a base rebuild (the
|
||||
v1.1.2 mempalace-toolkit staleness footgun). The guard fails CI the moment
|
||||
someone adds an `ARG *_REF` to `Dockerfile.base` without folding it in; it
|
||||
runs in the `base-decide` job and locally. Smoke-test gained assertions for
|
||||
the manifest (present, no `"unknown"` components) and the OCI labels.
|
||||
- **Overridable companion repo URLs.** The three gitea-hosted companions
|
||||
(`pi-toolkit`, `pi-extensions`, `mempalace-toolkit`) gained `*_REPO`
|
||||
build-args defaulting to their canonical `gitea.jordbo.se` origin —
|
||||
matching the existing `PI_FORK_REPO` / `PI_OBSMEM_REPO` / `PI_STUDIO_REPO`
|
||||
pattern. A relocated or forked build can now repoint a companion at a
|
||||
mirror, another host, or a local path (`--build-arg PI_EXTENSIONS_REPO=...`)
|
||||
without editing the Dockerfiles. Defaults are unchanged, so the canonical
|
||||
CI build is byte-identical.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`resolve-versions` now fails loud instead of falling back to a floating
|
||||
branch.** Each pi-version / companion-ref lookup previously degraded to
|
||||
`main`/`master` on a transient API/network failure (`|| echo "main"`),
|
||||
silently shipping an unpinned ref that defeats both cache-busting and
|
||||
reproducibility. Resolution now validates each result is a 40-hex commit
|
||||
SHA (and pi a real semver) and aborts the release otherwise.
|
||||
|
||||
## v1.1.5 — 2026-06-18
|
||||
|
||||
Patch release: SSH ControlMaster read-only-socket fix + pi `0.79.6` → `0.79.7`
|
||||
(auto-resolved at build). The `pi-extensions` ref is auto-resolved to `main`
|
||||
HEAD at build, so the `ssh-controlmaster` fix below lands automatically.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`pi --ssh <host>` no longer fails with "Read-only file system" when the
|
||||
user's `~/.ssh/config` sets a per-host `ControlPath` under the read-only
|
||||
`~/.ssh` mount** (e.g. the common CGNAT idiom `ControlPath ~/.ssh/cm/%r@%h:%p`).
|
||||
Root cause: SSH precedence means a user's per-host `ControlPath` always wins
|
||||
over the baked `/etc/ssh/ssh_config.d` default, so the master socket tried to
|
||||
bind under the RO `~/.ssh` and `ssh … pwd` exited 255 ("Could not resolve
|
||||
remote pwd"). The `ssh-controlmaster` extension (pulled from `pi-extensions`
|
||||
`main` via `PI_EXTENSIONS_REF`) now (a) resolves the remote pwd with a direct
|
||||
connection (`-o ControlPath=none -o ControlMaster=no`), and (b) tests whether
|
||||
the system `ControlPath` dir is actually writable — falling back to its own
|
||||
`/tmp` master (whose command-line `-o ControlPath` overrides the user's path)
|
||||
when it is not. OS-agnostic and independent of whether the user uses
|
||||
ControlMaster, so the majority of configs (no ControlMaster at all) are
|
||||
unaffected.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`setup-lan-access.sh` now renders the writable SSH sidecar
|
||||
(`~/.ssh-local/config`) on every host OS, not just VM-backed ones.**
|
||||
Previously the whole script no-oped on native Linux, so a Linux host that
|
||||
also bind-mounts `~/.ssh` read-only got no `ControlPath` redirect. The
|
||||
`ControlPath` redirect + `Include ~/.ssh/config` (and `dssh`/`dscp` usability)
|
||||
now work on Linux too; only the host-jump block (`Host host mac`), its key
|
||||
generation, and the authorize hints remain gated on VM-backed detection
|
||||
(`DEVBOX_LAN_ACCESS=auto`) or `=jump`.
|
||||
|
||||
### Bumped: pi 0.79.6 → 0.79.7
|
||||
|
||||
Notable upstream changes (from [pi releases](https://github.com/earendil-works/pi/releases/tag/v0.79.7)):
|
||||
|
||||
- **Automatic theme mode** — `/settings` can choose separate light and dark
|
||||
themes and follow terminal color-scheme changes (`/` is now reserved in
|
||||
theme names for this).
|
||||
- **Self-only `pi update` by default** — bare `pi update` updates pi only;
|
||||
`pi update --all` updates pi and packages together.
|
||||
- **Extension API helpers** — `CONFIG_DIR_NAME` exported so extensions resolve
|
||||
project config paths without hardcoding `.pi`; edit-diff helpers
|
||||
(`generateDiffString`, `generateUnifiedPatch`, `EditDiffResult`) exported.
|
||||
- **Warp inline images** via Kitty graphics capability detection.
|
||||
- Fixes: RPC unknown-command errors now include the request id (clients no
|
||||
longer hang); `/model` autocomplete matches provider/model regardless of
|
||||
token order; tree navigator horizontally pans deep entries.
|
||||
|
||||
## v1.1.4 — 2026-06-17
|
||||
|
||||
Patch release: config and shell-quality fixes on a preserved volume. No pi
|
||||
version bump (still `0.79.6`, latest). The `pi-toolkit` ref is auto-resolved
|
||||
to `main` HEAD at build, so the AGENTS.md change below lands automatically.
|
||||
|
||||
### Added
|
||||
|
||||
- **Global `AGENTS.md` auto-loads the pi-extensions skill.** `pi-toolkit` now
|
||||
ships `pi-global-AGENTS.md` and symlinks it to `~/.pi/agent/AGENTS.md` (pi's
|
||||
global-instructions file, loaded at every start). It directs the agent to
|
||||
read the `pi-extensions` skill at session start and carries a core
|
||||
fork/recall cheat-sheet, since on-demand skill description-matching was
|
||||
leaving `pi-fork` / `pi-observational-memory` under-utilised. **Heads-up:**
|
||||
on a preserved volume any pre-existing real `~/.pi/agent/AGENTS.md` is backed
|
||||
up to `*.bak.<timestamp>` and replaced by the symlink (same behavior as
|
||||
`keybindings.json`).
|
||||
- **`settings.json` merge-on-recreate.** The bootstrap only ever copied the
|
||||
template when `settings.json` was *absent*, so a file on a preserved volume
|
||||
never picked up config added in a later image (e.g. the
|
||||
`observational-memory` / `pi-fork` blocks, a newly-enabled model). The
|
||||
entrypoint now deep-merges the template into an existing `settings.json` on
|
||||
start with `jq -s '.[0] * .[1]'` (template first, live second): the user's
|
||||
values always win and only *missing* keys are filled in. Arrays are treated
|
||||
as leaves (a model the user removed is not re-added); the file is only
|
||||
rewritten when the merge changes something, the original is backed up first,
|
||||
and invalid JSON on either side is skipped rather than clobbered. Opt out
|
||||
with `PI_SETTINGS_MERGE=0`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **bash history loss in nested / tmux shells.** The `DEVBOX_HIST_SET` guard
|
||||
that installs the per-prompt `history -a` flush was `export`ed, so it leaked
|
||||
into child processes. Any nested shell — crucially each tmux pane, which
|
||||
inherits the tmux server's env — saw the guard already set and skipped
|
||||
installing `history -a`, persisting history only on a clean exit. Abrupt
|
||||
termination (`docker stop`, `tmux kill-server`, SIGKILL) then silently lost
|
||||
that shell's in-memory history. The guard is now shell-local (no `export`),
|
||||
so every new interactive shell re-installs its own flush. `zoxide` was less
|
||||
affected (its hook is unguarded and writes immediately). History and zoxide
|
||||
storage were never the issue — `~/.cache/bash` (`devbox-shell-history`) and
|
||||
`~/.local/share/zoxide` (`devbox-zoxide`) are persistent named volumes.
|
||||
**Note:** existing shells/panes keep the old behavior until restarted
|
||||
(`tmux kill-server` or open fresh shells).
|
||||
|
||||
### Maintainer
|
||||
|
||||
- `scripts/recreate-sanity-check.sh` gained assertions for the new wiring: the
|
||||
`~/.pi/agent/AGENTS.md` symlink, a nested login shell installing
|
||||
`history -a`, and `settings.json` carrying the `observational-memory` +
|
||||
`pi-fork` blocks after recreate.
|
||||
|
||||
---
|
||||
|
||||
## v1.1.3 — 2026-06-16
|
||||
|
||||
Patch release: pi `0.79.4` → `0.79.5` (auto-resolved at build).
|
||||
|
||||
### Bumped: pi 0.79.4 → 0.79.5
|
||||
|
||||
Notable upstream changes (from [pi releases](https://github.com/earendil-works/pi/releases/tag/v0.79.5)):
|
||||
|
||||
- **Provider-scoped API key environments** — `auth.json` API key entries can
|
||||
now include `env` overrides for provider-specific Cloudflare, Azure OpenAI,
|
||||
Google Vertex, Amazon Bedrock, cache retention, and proxy settings without
|
||||
changing the project shell.
|
||||
- **Global HTTP proxy setting** — configure `httpProxy` once in global settings
|
||||
to apply `HTTP_PROXY` / `HTTPS_PROXY` to Pi-managed HTTP clients.
|
||||
- **Vercel AI Gateway attribution** — requests now include Pi attribution
|
||||
headers by default.
|
||||
- **Fixes:** inherited OpenAI Responses streaming tolerates null message content
|
||||
before tool calls; DeepSeek V4 thinking no longer sends both `thinking` and
|
||||
`reasoning_effort`; device-code login no longer auto-opens the browser;
|
||||
various Google/Vertex Gemini model metadata corrections; session selector
|
||||
empty-state fix; Cursor Up history navigation fix.
|
||||
|
||||
---
|
||||
|
||||
## v1.1.2 — 2026-06-15
|
||||
|
||||
Patch release: pi `0.79.3` → `0.79.4` (auto-resolved at build), plus the
|
||||
build-plumbing fix, maintainer tooling, and docs accumulated since v1.1.1.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`mempalace-toolkit` is now CI-resolved to a commit SHA**, closing a
|
||||
silent-staleness footgun. It is the only companion cloned in
|
||||
`Dockerfile.base` (all others are cloned in `Dockerfile.variant`), so it
|
||||
was never run through the `resolve-versions` → build-arg plumbing. Its
|
||||
ref stayed a literal `main`, and because the base only rebuilds when the
|
||||
hash of `Dockerfile.base + rootfs/* + entrypoints` changes, a
|
||||
toolkit-only fix would *not* land in the image unless `Dockerfile.base`
|
||||
itself happened to change (as it did, incidentally, in v1.1.1).
|
||||
|
||||
Now `resolve-versions` resolves `mempalace-toolkit` `main` HEAD to a SHA
|
||||
(new `mempalace_toolkit_ref` output), `base-decide` folds that SHA into
|
||||
the base-tag hash (so a moved toolkit forces a base rebuild), and
|
||||
`build-base` passes it as `--build-arg MEMPALACE_TOOLKIT_REF`. The base
|
||||
clone switched from `git clone --branch` to a SHA-capable
|
||||
`git fetch <ref> + checkout FETCH_HEAD` (the `--branch <40-char-SHA>`
|
||||
footgun previously fixed in `Dockerfile.variant`, run 374).
|
||||
|
||||
Note: `base-decide` now depends on `resolve-versions`, so the base tag
|
||||
reflects a live gitea API lookup. On an API blip it falls back to `main`
|
||||
— which hashes differently than a SHA and triggers one *extra* rebuild,
|
||||
never a *missed* one (fail-toward-rebuild).
|
||||
|
||||
### Added (maintainer tooling, no image change)
|
||||
|
||||
- **`scripts/recreate-sanity-check.sh`** — runtime post-recreate sanity
|
||||
check; the runtime peer of `smoke-test.sh`. Where `smoke-test.sh` runs at
|
||||
build time with `--entrypoint=""` (and so can never see persisted volumes
|
||||
or the entrypoint's runtime deploy), this verifies what is actually live
|
||||
in the container *after* `docker compose up -d --force-recreate`:
|
||||
persisted named volumes survived, the pi runtime wiring is intact
|
||||
(keybindings symlink, ≥4 extensions, `mempalace.ts` bridge, `settings.json`,
|
||||
and pi-fork / pi-observational-memory / pi-studio registrations),
|
||||
`/tmp/sshcm` is mode 700, shell defaults re-seeded, and `/opt` toolkits
|
||||
intact. Variant (studio/plain) auto-detected via `/opt/pi-studio`. Since
|
||||
pi is built from `latest` (no concrete Dockerfile pin), the version check
|
||||
asserts only when `--expected-version` is passed, else WARNs. Not baked
|
||||
into the image — repo/maintainer tooling, same category as
|
||||
`smoke-test.sh`. A short-name wrapper (`pi-devbox-sanity`) lives in
|
||||
`cli_utils/bin`, kept separate from opencode-devbox's `devbox-sanity` so
|
||||
hosts with only one devbox checked out stay self-contained.
|
||||
|
||||
### Docs (no image change)
|
||||
|
||||
- Correct the MemPalace `diary_write` anyOf workaround watch-target in
|
||||
`Dockerfile.base`: upstream PR #1735 was **closed unmerged** (2026-06-11),
|
||||
so the old “remove once #1735 ships” TODO pointed at a dead PR. Issue #1728
|
||||
is still open; PR #1717 is the current live candidate; mempalace PyPI latest
|
||||
is still 3.4.0 (== our pin), so the workaround stays. Removal trigger is now
|
||||
a PyPI release > 3.4.0 that actually strips the root-level anyOf.
|
||||
|
||||
- Document the post-recreate sanity check: AGENTS.md release-day checklist
|
||||
(step 3) now runs `scripts/recreate-sanity-check.sh` inside the recreated
|
||||
container, and README gains a "Post-recreate sanity check" subsection
|
||||
alongside the build-time smoke-test note.
|
||||
|
||||
---
|
||||
|
||||
## v1.1.1 — 2026-06-13
|
||||
|
||||
Patch release: pi `0.79.1` → `0.79.3` (auto-resolved at build) plus the
|
||||
@@ -212,7 +594,7 @@ dependencies.
|
||||
### Future work
|
||||
|
||||
- v1.1.0: `:latest-studio` variant (adds [pi-studio](https://github.com/omaclaren/pi-studio)).
|
||||
- v1.2.0: `:latest-studio-tex` variant (adds texlive-xetex for PDF export).
|
||||
- v1.3.0: `:latest-studio-tex` variant (adds texlive-xetex for PDF export).
|
||||
|
||||
## v0.79.0 — 2026-06-08
|
||||
|
||||
|
||||
+2
-1
@@ -51,6 +51,7 @@ Full setup guide — authentication for each provider (Anthropic, OpenAI, Gemini
|
||||
- **[pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions)** — 7 user-facing extensions: `ext-toggle`, `mcp-loader`, `todo`, `ssh-controlmaster`, `notify`, `git-checkpoint`, `confirm-destructive`
|
||||
- **`fork`** ([pi-fork](https://github.com/elpapi42/pi-fork)) and **`recall`** ([pi-observational-memory](https://github.com/elpapi42/pi-observational-memory)) tools
|
||||
- **mempalace bridge** — MCP extension auto-symlinked so pi reads/writes the host-mounted palace
|
||||
- **image-baked agent skills** — skills under `/usr/local/share/pi-devbox/skills/` (e.g. `pi-devbox-environment`, which teaches agents the container's persistence/networking/DNS/tmux/REPL specifics) are symlinked into `~/.agents/skills/` on start, available with or without a mounted skillset repo
|
||||
|
||||
The entrypoint deploys/registers all of these on first container start. Re-running is idempotent and preserves user edits.
|
||||
|
||||
@@ -97,7 +98,7 @@ The entrypoint deploys/registers all of these on first container start. Re-runni
|
||||
|
||||
### SSH and networking
|
||||
|
||||
- OpenSSH client with **ControlMaster auto** preconfigured on a writable socket path (`/tmp/sshcm/`). Mitigates ssh banner-exchange failures behind CGNAT-restricted residential ISPs (~4-flow caps).
|
||||
- OpenSSH client with **ControlMaster auto** preconfigured on a writable socket path (`/tmp/sshcm/`). Mitigates ssh banner-exchange failures behind CGNAT-restricted residential ISPs (~4-flow caps). A read-only `~/.ssh` carrying a per-host `ControlPath` (common CGNAT configs) is handled too — redirected to a writable socket dir for both `pi --ssh` and `dssh`/`dscp`.
|
||||
- A **LAN-access helper** that auto-configures ssh jump-via-host on VM-backed hosts (OrbStack / Docker Desktop on macOS) so the container can reach the host's directly-attached LAN peers (`dssh <peer>` alias; `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER`).
|
||||
|
||||
## Versioning
|
||||
|
||||
+46
-7
@@ -130,6 +130,15 @@ RUN printf '%s\n' \
|
||||
# `Include /etc/ssh/ssh_config.d/*.conf` *before* the `Host *` block,
|
||||
# so user config can override these defaults if desired.
|
||||
#
|
||||
# CAVEAT (and why it is handled elsewhere): a user per-host override that
|
||||
# points ControlPath BACK under the read-only ~/.ssh (e.g. the common CGNAT
|
||||
# idiom `ControlPath ~/.ssh/cm/%r@%h:%p`) re-introduces the unwritable-socket
|
||||
# failure — a system drop-in here can never override a user's per-host value.
|
||||
# For `pi --ssh`, the ssh-controlmaster extension handles this by detecting an
|
||||
# unwritable system ControlPath and falling back to its own /tmp master; for
|
||||
# `ssh -F ~/.ssh-local/config` (dssh/dscp), setup-lan-access.sh redirects
|
||||
# ControlPath into the writable ~/.ssh-local. See CHANGELOG "Unreleased".
|
||||
#
|
||||
# ControlPersist=10m means the master socket sticks around 10 min after
|
||||
# the last session closes, so consecutive ssh calls in a workflow reuse
|
||||
# the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm
|
||||
@@ -316,12 +325,18 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
# kwarg alias so existing callers still work.
|
||||
#
|
||||
# Idempotent and self-deactivating: once upstream releases the fix the
|
||||
# regex no longer matches and this RUN is a silent no-op.
|
||||
# Upstream tracking:
|
||||
# regex no longer matches (and the WARN below fires) — that's the signal
|
||||
# to delete this RUN.
|
||||
# Upstream status (last checked 2026-06-14):
|
||||
# issue #1728 — STILL OPEN (root-level anyOf rejected by Anthropic/Codex)
|
||||
# PR #1735 — CLOSED UNMERGED 2026-06-11; do NOT watch it (dead)
|
||||
# PR #1717 — open; the current live fix candidate to watch
|
||||
# mempalace PyPI latest = 3.4.0 (== our pin) → no release contains the fix yet
|
||||
# https://github.com/MemPalace/mempalace/issues/1728
|
||||
# https://github.com/MemPalace/mempalace/pull/1735
|
||||
# TODO: remove this RUN once a mempalace release containing PR #1735 is on
|
||||
# PyPI and installed by the line above.
|
||||
# https://github.com/MemPalace/mempalace/pull/1717
|
||||
# TODO: remove this RUN once a mempalace release > 3.4.0 that actually strips
|
||||
# the root-level anyOf ships on PyPI and is installed by the line above.
|
||||
# Keep MEMPALACE_VERSION in lockstep with opencode-devbox when bumping.
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
MP_FILE="$(find /opt/uv-tools/mempalace -path '*/mempalace/mcp_server.py' | head -n1)" && \
|
||||
if [ -z "$MP_FILE" ]; then echo "mempalace mcp_server.py not found" >&2; exit 1; fi && \
|
||||
@@ -336,9 +351,28 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
# ── mempalace-toolkit — bash wrappers for session/docs mining ────────
|
||||
ARG INSTALL_MEMPALACE_TOOLKIT=true
|
||||
ARG MEMPALACE_TOOLKIT_REF=main
|
||||
# MEMPALACE_TOOLKIT_REPO defaults to the canonical gitea origin but is
|
||||
# overridable so a relocated/forked build can clone from a mirror or a
|
||||
# different host without editing this Dockerfile (mirrors the
|
||||
# PI_FORK_REPO / PI_OBSMEM_REPO / PI_STUDIO_REPO pattern in the variant).
|
||||
ARG MEMPALACE_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/mempalace-toolkit.git
|
||||
# MEMPALACE_TOOLKIT_REF accepts EITHER a branch name OR a commit SHA. CI
|
||||
# resolves it to a SHA (resolve-versions job) and folds that SHA into the
|
||||
# base-decide hash so the base rebuilds when the toolkit moves. `git clone
|
||||
# --branch <40-char-SHA>` fails ("Remote branch not found") — the same
|
||||
# footgun fixed in Dockerfile.variant (v1.0.0-rerun, run 374) — so use
|
||||
# `git fetch <ref> + checkout FETCH_HEAD`, which works for name and SHA.
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \
|
||||
git clone --depth 1 --branch "${MEMPALACE_TOOLKIT_REF}" \
|
||||
https://gitea.jordbo.se/joakimp/mempalace-toolkit.git /opt/mempalace-toolkit && \
|
||||
rm -rf /opt/mempalace-toolkit && mkdir -p /opt/mempalace-toolkit && \
|
||||
git -C /opt/mempalace-toolkit init -q && \
|
||||
git -C /opt/mempalace-toolkit remote add origin "${MEMPALACE_TOOLKIT_REPO}" && \
|
||||
ok=0; for i in 1 2 3 4 5; do \
|
||||
if git -C /opt/mempalace-toolkit fetch --depth 1 origin "${MEMPALACE_TOOLKIT_REF}" && \
|
||||
git -C /opt/mempalace-toolkit checkout -q FETCH_HEAD; then ok=1; break; fi; \
|
||||
echo "git fetch mempalace-toolkit@${MEMPALACE_TOOLKIT_REF} failed (attempt $i/5), retrying in $((i*5))s..."; \
|
||||
sleep $((i*5)); \
|
||||
done; \
|
||||
[ "$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 && \
|
||||
@@ -447,6 +481,11 @@ COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
|
||||
|
||||
# ── Entrypoint ────────────────────────────────────────────────────────
|
||||
COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/
|
||||
# Image-baked skills + the global-AGENTS append snippet. Under /usr/local so a
|
||||
# named volume over a home dir can't shadow them; linked into ~/.agents/skills
|
||||
# by entrypoint-user.sh, and the snippet is concatenated onto the global
|
||||
# AGENTS.md in Dockerfile.variant (after pi-toolkit, which owns that file).
|
||||
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 entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
+104
-2
@@ -41,6 +41,12 @@ ARG USER_NAME=developer
|
||||
ARG PI_VERSION=latest
|
||||
ARG PI_TOOLKIT_REF=main
|
||||
ARG PI_EXTENSIONS_REF=main
|
||||
# Repo URLs default to the canonical gitea origin but are overridable so a
|
||||
# relocated/forked build can clone from a mirror or a different host
|
||||
# without editing this Dockerfile — same pattern as PI_FORK_REPO /
|
||||
# PI_OBSMEM_REPO / PI_STUDIO_REPO below.
|
||||
ARG PI_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/pi-toolkit.git
|
||||
ARG PI_EXTENSIONS_REPO=https://gitea.jordbo.se/joakimp/pi-extensions.git
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool) live on GitHub
|
||||
# under elpapi42. CI resolves these to commit SHAs to defeat the same
|
||||
# cache-hit footgun that affects PI_VERSION.
|
||||
@@ -77,8 +83,8 @@ RUN set -e && \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
|
||||
fi && \
|
||||
pi --version && \
|
||||
git_fetch_ref "https://gitea.jordbo.se/joakimp/pi-toolkit.git" "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
|
||||
git_fetch_ref "https://gitea.jordbo.se/joakimp/pi-extensions.git" "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
|
||||
git_fetch_ref "${PI_TOOLKIT_REPO}" "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
|
||||
git_fetch_ref "${PI_EXTENSIONS_REPO}" "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
|
||||
git_fetch_ref "${PI_FORK_REPO}" "${PI_FORK_REF}" /opt/pi-fork && \
|
||||
git_fetch_ref "${PI_OBSMEM_REPO}" "${PI_OBSMEM_REF}" /opt/pi-observational-memory && \
|
||||
(cd /opt/pi-fork && npm install --omit=dev --no-audit --no-fund) && \
|
||||
@@ -88,6 +94,46 @@ RUN set -e && \
|
||||
echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \
|
||||
echo "pi-observational-memory at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)"
|
||||
|
||||
# ── Image-baked skill refresh: pi-extensions (Option 1 over Option 2) ──
|
||||
# rootfs ships a VENDORED snapshot of the pi-extensions skill at
|
||||
# /usr/local/share/pi-devbox/skills/pi-extensions/ (the "floor" — guarantees the
|
||||
# skill is always in the image). The pi-extensions PACKAGE repo now co-locates
|
||||
# the canonical skill under skill/, so here — after the pinned clone — we copy
|
||||
# that over the snapshot. Result: a normal build ships the fresh, package-owned
|
||||
# copy (pinned + recorded in the manifest via PI_EXTENSIONS_REF); a build whose
|
||||
# ref predates the skill, or a fork pointing at a mirror without it, still ships
|
||||
# the committed snapshot. The skill calls ./evaluate-extension-usage.py, so it
|
||||
# is copied alongside. Idempotent and cache-safe (depends only on the clone).
|
||||
RUN if [ -f /opt/pi-extensions/skill/SKILL.md ]; then \
|
||||
cp /opt/pi-extensions/skill/SKILL.md \
|
||||
/usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md && \
|
||||
if [ -f /opt/pi-extensions/skill/evaluate-extension-usage.py ]; then \
|
||||
cp /opt/pi-extensions/skill/evaluate-extension-usage.py \
|
||||
/usr/local/share/pi-devbox/skills/pi-extensions/evaluate-extension-usage.py ; \
|
||||
fi && \
|
||||
echo "refreshed pi-extensions skill from package @ $(cd /opt/pi-extensions && git rev-parse --short HEAD)" ; \
|
||||
else \
|
||||
echo "pi-extensions package has no skill/ at this ref — keeping vendored snapshot" ; \
|
||||
fi
|
||||
|
||||
# ── pi-devbox awareness: append our pointer to the global AGENTS.md ──
|
||||
# pi loads a SINGLE global instruction file (~/.pi/agent/AGENTS.md), which
|
||||
# pi-toolkit's install.sh re-symlinks to /opt/pi-toolkit/pi-global-AGENTS.md on
|
||||
# every container start. There is no second global slot, and that file is
|
||||
# root-owned (not writable by the runtime user), so we compose at BUILD time:
|
||||
# append the pi-devbox managed block to pi-toolkit's file here, after the clone.
|
||||
# Idempotent via a marker grep so a rebuilt layer never double-appends. This
|
||||
# makes every container proactively aware of the pi-devbox-environment skill;
|
||||
# the snippet itself is gated (only fires when /usr/local/lib/pi-devbox exists).
|
||||
RUN if [ -f /opt/pi-toolkit/pi-global-AGENTS.md ] && \
|
||||
! grep -q 'pi-devbox:managed-block' /opt/pi-toolkit/pi-global-AGENTS.md; then \
|
||||
printf '\n' >> /opt/pi-toolkit/pi-global-AGENTS.md && \
|
||||
cat /usr/local/share/pi-devbox/pi-global-AGENTS.append.md >> /opt/pi-toolkit/pi-global-AGENTS.md && \
|
||||
echo "appended pi-devbox block to pi-global-AGENTS.md" ; \
|
||||
else \
|
||||
echo "pi-devbox block already present or pi-global-AGENTS.md missing (skipped)" ; \
|
||||
fi
|
||||
|
||||
# ── Optional: pi-studio (:latest-studio variant) ─────────────────────
|
||||
# pi-studio (omaclaren/pi-studio) is a pi-package + theme providing a
|
||||
# two-pane browser workspace: prompt/response editor, KaTeX/Mermaid live
|
||||
@@ -154,4 +200,60 @@ RUN if [ "${INSTALL_GO}" = "true" ]; then \
|
||||
ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \
|
||||
fi
|
||||
|
||||
# ── Build provenance: OCI labels + on-disk build manifest ────────────
|
||||
# Records exactly which pi version and companion-repo commits were baked
|
||||
# into THIS image, so a published tag is self-describing and reproducible
|
||||
# after the fact (CI logs rotate; a released image must not depend on
|
||||
# them). Previously the resolved SHAs only ever reached the CI build log.
|
||||
#
|
||||
# These ARGs are declared LAST, immediately before the layer that uses
|
||||
# them, so a changing BUILD_DATE / RELEASE_TAG / SOURCE_REVISION never
|
||||
# invalidates the expensive pi-install / clone layers above.
|
||||
ARG RELEASE_TAG=dev
|
||||
ARG BUILD_DATE=
|
||||
ARG SOURCE_REVISION=
|
||||
# MEMPALACE_TOOLKIT_REF is consumed in Dockerfile.base; re-declared here
|
||||
# only so its intended ref lands in the label set alongside the others.
|
||||
ARG MEMPALACE_TOOLKIT_REF=main
|
||||
|
||||
LABEL org.opencontainers.image.version="${RELEASE_TAG}" \
|
||||
org.opencontainers.image.revision="${SOURCE_REVISION}" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
se.jordbo.pi-devbox.pi-version="${PI_VERSION}" \
|
||||
se.jordbo.pi-devbox.pi-toolkit-ref="${PI_TOOLKIT_REF}" \
|
||||
se.jordbo.pi-devbox.pi-extensions-ref="${PI_EXTENSIONS_REF}" \
|
||||
se.jordbo.pi-devbox.pi-fork-ref="${PI_FORK_REF}" \
|
||||
se.jordbo.pi-devbox.pi-obsmem-ref="${PI_OBSMEM_REF}" \
|
||||
se.jordbo.pi-devbox.mempalace-toolkit-ref="${MEMPALACE_TOOLKIT_REF}" \
|
||||
se.jordbo.pi-devbox.pi-studio-ref="${PI_STUDIO_REF}"
|
||||
|
||||
# The manifest is written from GROUND TRUTH — the actual checked-out HEAD
|
||||
# of each /opt clone and the live `pi --version` — not merely the intended
|
||||
# build-args. That way it also exposes a clone that silently resolved to
|
||||
# something other than the requested ref. pi-studio is present only in the
|
||||
# studio variant (JSON null otherwise).
|
||||
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')"; \
|
||||
STUDIO_REV='null'; \
|
||||
if [ -d /opt/pi-studio/.git ]; then STUDIO_REV="\"$(rev /opt/pi-studio)\""; fi; \
|
||||
{ \
|
||||
echo '{'; \
|
||||
echo " \"release_tag\": \"${RELEASE_TAG}\","; \
|
||||
echo " \"build_date\": \"${BUILD_DATE}\","; \
|
||||
echo " \"source_revision\": \"${SOURCE_REVISION}\","; \
|
||||
echo " \"pi_version\": \"${PI_V}\","; \
|
||||
echo " \"components\": {"; \
|
||||
echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \
|
||||
echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \
|
||||
echo " \"pi-fork\": \"$(rev /opt/pi-fork)\","; \
|
||||
echo " \"pi-observational-memory\": \"$(rev /opt/pi-observational-memory)\","; \
|
||||
echo " \"mempalace-toolkit\": \"$(rev /opt/mempalace-toolkit)\","; \
|
||||
echo " \"pi-studio\": ${STUDIO_REV}"; \
|
||||
echo " }"; \
|
||||
echo '}'; \
|
||||
} > /etc/pi-devbox/build-manifest.json; \
|
||||
echo "── build manifest ──"; cat /etc/pi-devbox/build-manifest.json
|
||||
|
||||
# WORKDIR / ENTRYPOINT / CMD inherited from base.
|
||||
|
||||
@@ -82,6 +82,9 @@ For Python REPLs and notebooks beyond the system interpreter, see the
|
||||
- A LAN-access helper that auto-configures ssh jump-via-host on
|
||||
VM-backed hosts (OrbStack / Docker Desktop on macOS) so the container
|
||||
can reach the host's directly-attached LAN peers.
|
||||
- Read-only `~/.ssh` is handled transparently: a per-host `ControlPath`
|
||||
under it (common CGNAT configs like `~/.ssh/cm/...`) is redirected to a
|
||||
writable socket dir for both `pi --ssh` and `dssh`/`dscp`.
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -439,6 +442,56 @@ session/docs mining; the 29 MCP tools (search, kg-query, drawer-add,
|
||||
diary-write, etc.) are wired into pi automatically by the pi-extensions
|
||||
mempalace bridge.
|
||||
|
||||
## Agent skills
|
||||
|
||||
pi discovers skills under `~/.agents/skills/`. Two delivery paths feed that
|
||||
directory, and they compose:
|
||||
|
||||
- **Image-baked skills (always present).** Skills shipped *inside* the image
|
||||
live under `/usr/local/share/pi-devbox/skills/` and are symlinked into
|
||||
`~/.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
|
||||
**`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`),
|
||||
tmux 0-indexing, uv-first Python, and pi-studio reachability, all as
|
||||
*mechanisms* (deployment-specific hostnames/domains/nameservers are
|
||||
discovered at runtime, never hardcoded).
|
||||
- **Vendored fallback skills.** The pi-toolkit global `AGENTS.md` tells every
|
||||
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
|
||||
`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
|
||||
build, so a normal build ships the fresh copy and an old-ref/mirror build
|
||||
still ships the snapshot. `mempalace` is snapshot-only (its consumer skill
|
||||
has no public package home), and because pi-toolkit's `AGENTS.md` has no
|
||||
directive for it, the pi-devbox managed block adds a session-start
|
||||
*proactive-load* pointer for it (gated to pi-devbox containers, conditional
|
||||
on the MemPalace MCP tools) so a new container actually loads it. See
|
||||
`rootfs/usr/local/share/pi-devbox/skills/VENDORED.md`.
|
||||
- **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.
|
||||
|
||||
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
|
||||
global `AGENTS.md` at build time (see `pi-global-AGENTS.append.md`). The
|
||||
pointer fires only inside a pi-devbox container (it checks for
|
||||
`/usr/local/lib/pi-devbox/`).
|
||||
|
||||
To add another image-baked skill: drop a `SKILL.md` under
|
||||
`rootfs/usr/local/share/pi-devbox/skills/<name>/`; the `COPY` in
|
||||
`Dockerfile.base` and the entrypoint symlink loop pick it up automatically. To
|
||||
refresh a vendored fallback, see
|
||||
`rootfs/usr/local/share/pi-devbox/skills/VENDORED.md`.
|
||||
|
||||
## SSH and ControlMaster
|
||||
|
||||
The base image preconfigures `Host *` ssh defaults:
|
||||
@@ -461,6 +514,27 @@ User-level overrides in `~/.ssh/config` win because Debian's
|
||||
`/etc/ssh/ssh_config` includes `/etc/ssh/ssh_config.d/*.conf` before
|
||||
the `Host *` block.
|
||||
|
||||
### Per-host `ControlPath` on a read-only `~/.ssh`
|
||||
|
||||
`~/.ssh` is usually bind-mounted read-only, so a user `~/.ssh/config` that
|
||||
points `ControlPath` back under it (e.g. the CGNAT idiom
|
||||
`ControlPath ~/.ssh/cm/%r@%h:%p`) can't bind its master socket here — and a
|
||||
system default can never override a user's per-host value. Two layers handle
|
||||
this without editing the read-only config:
|
||||
|
||||
- **`pi --ssh <host>`** — the `ssh-controlmaster` extension detects an
|
||||
unwritable system `ControlPath` and falls back to its own writable
|
||||
`/tmp/pi-cm-<pid>.sock` master (its command-line `-o ControlPath` overrides
|
||||
the user's path); the remote-`pwd` probe uses `-o ControlPath=none` so it
|
||||
cannot fail on the read-only socket dir.
|
||||
- **`ssh -F ~/.ssh-local/config` / `dssh` / `dscp`** — `setup-lan-access.sh`
|
||||
redirects `ControlPath` into the writable `~/.ssh-local/cm` for every host
|
||||
(the sidecar is rendered on all host OSes). To name LAN peers that should
|
||||
jump via the host, add `ProxyJump host` overrides in the host-owned
|
||||
`~/.config/devbox-shell/ssh-lan.conf` (see
|
||||
[Naming LAN peers](#naming-lan-peers)) rather than the read-only
|
||||
`~/.ssh/config`.
|
||||
|
||||
## tmux and 0-indexed sessions
|
||||
|
||||
The image installs `/etc/tmux.conf` with:
|
||||
@@ -517,6 +591,68 @@ pi-coding-agent@latest` (the build-arg string would otherwise be
|
||||
byte-identical across releases and the layer would silently reuse the
|
||||
previous version's bytes).
|
||||
|
||||
### Building a fork / relocated build
|
||||
|
||||
The canonical build clones its companions from `gitea.jordbo.se`. Every
|
||||
companion repo URL is an overridable build-arg (defaulting to the canonical
|
||||
origin), so a fork or a build on a host that can't reach that gitea can
|
||||
repoint each one at a mirror, another host, or a local `file://` path
|
||||
**without editing the Dockerfiles**:
|
||||
|
||||
| Build-arg | Default | Dockerfile |
|
||||
|---|---|---|
|
||||
| `PI_TOOLKIT_REPO` | `https://gitea.jordbo.se/joakimp/pi-toolkit.git` | variant |
|
||||
| `PI_EXTENSIONS_REPO` | `https://gitea.jordbo.se/joakimp/pi-extensions.git` | variant |
|
||||
| `MEMPALACE_TOOLKIT_REPO` | `https://gitea.jordbo.se/joakimp/mempalace-toolkit.git` | base |
|
||||
| `PI_FORK_REPO` | `https://github.com/elpapi42/pi-fork.git` | variant |
|
||||
| `PI_OBSMEM_REPO` | `https://github.com/elpapi42/pi-observational-memory.git` | variant |
|
||||
| `PI_STUDIO_REPO` | `https://github.com/omaclaren/pi-studio.git` | variant |
|
||||
|
||||
Each has a matching `*_REF` arg (branch name or commit SHA). Example — build
|
||||
the variant against forked toolkit/extensions and a pinned pi:
|
||||
|
||||
```bash
|
||||
# base first (mempalace-toolkit lives here)
|
||||
docker build -f Dockerfile.base -t myorg/pi-devbox:base-dev \
|
||||
--build-arg MEMPALACE_TOOLKIT_REPO=https://github.com/myorg/mempalace-toolkit.git .
|
||||
|
||||
# then the variant FROM that base
|
||||
docker build -f Dockerfile.variant -t myorg/pi-devbox:dev \
|
||||
--build-arg BASE_IMAGE=myorg/pi-devbox:base-dev \
|
||||
--build-arg PI_VERSION=0.79.7 \
|
||||
--build-arg PI_TOOLKIT_REPO=https://github.com/myorg/pi-toolkit.git \
|
||||
--build-arg PI_EXTENSIONS_REPO=https://github.com/myorg/pi-extensions.git .
|
||||
```
|
||||
|
||||
Note: the gitea companions clone anonymously (no token needed); only the
|
||||
`resolve-versions` CI job calls the gitea *API* (which needs a token even
|
||||
for public repos). A plain `docker build` like the above skips that job
|
||||
entirely, so no credentials are required for a local/forked build.
|
||||
|
||||
Provenance build-args (all optional; populate the OCI labels and
|
||||
`/etc/pi-devbox/build-manifest.json` — see below): `RELEASE_TAG`,
|
||||
`BUILD_DATE`, `SOURCE_REVISION`. CI sets these automatically; a manual build
|
||||
leaves them at harmless defaults.
|
||||
|
||||
### Build provenance (labels + manifest)
|
||||
|
||||
Every published image is self-describing. Inspect the OCI labels without
|
||||
pulling the filesystem:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
docker run --rm --entrypoint= joakimp/pi-devbox:latest cat /etc/pi-devbox/build-manifest.json
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Image grew unexpectedly
|
||||
@@ -533,12 +669,54 @@ auto-runs on container start and writes `~/.ssh-local/config` with a
|
||||
ssh-jump-via-host configuration. Set `DEVBOX_LAN_ACCESS=jump` and
|
||||
`HOST_SSH_USER=<your-mac-user>` in `.env` if auto-detection fails.
|
||||
|
||||
#### Naming LAN peers
|
||||
|
||||
`DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` only set up the *jump* to the host. To
|
||||
make a **named** peer route through it — so `pi --ssh alpserv-2`,
|
||||
`dssh alpserv-2`, etc. resolve the ProxyJump — add a `ProxyJump host` override
|
||||
for it in the host-owned, bind-mounted `~/.config/devbox-shell/ssh-lan.conf`
|
||||
(**not** `~/.ssh/config`, which is mounted read-only):
|
||||
|
||||
```
|
||||
Host pve pve-2 alpserv-2 lagret
|
||||
ProxyJump host
|
||||
```
|
||||
|
||||
`HostName` / `User` / `IdentityFile` are inherited from the matching block in
|
||||
your real `~/.ssh/config` (first-value-wins, so only `ProxyJump` is taken from
|
||||
here). This file is `Include`d *before* `~/.ssh/config` and read fresh on every
|
||||
connection — newly added peers work immediately, no container or session
|
||||
restart needed — and the peer names stay out of the published image (they're a
|
||||
fact about your specific LAN, not the image). Alternatively, set
|
||||
`DEVBOX_LAN_AUTOJUMP_PRIVATE=1` to ProxyJump *any* RFC1918 address through the
|
||||
host without naming peers (see `.env.example`).
|
||||
|
||||
### Smoke-testing a local build
|
||||
|
||||
```bash
|
||||
./scripts/smoke-test.sh joakimp/pi-devbox:latest
|
||||
```
|
||||
|
||||
`smoke-test.sh` is a **build-time** check (runs with `--entrypoint=""`), so
|
||||
it validates image contents and a fresh entrypoint deploy — it never sees a
|
||||
recreated container's persisted volumes.
|
||||
|
||||
### Post-recreate sanity check
|
||||
|
||||
After `docker compose up -d --force-recreate`, run the **runtime** peer of
|
||||
`smoke-test.sh` from *inside* the container to confirm the new image is live,
|
||||
persisted volumes survived, and pi runtime wiring is intact:
|
||||
|
||||
```bash
|
||||
./scripts/recreate-sanity-check.sh # auto-detects variant
|
||||
./scripts/recreate-sanity-check.sh --expected-version 0.79.4 # assert pi version
|
||||
```
|
||||
|
||||
If `cli_utils` is on your PATH, the `pi-devbox-sanity` wrapper runs the same
|
||||
check by short name and locates the repo automatically (override with
|
||||
`PI_DEVBOX_REPO=/path/to/pi-devbox`). Like `smoke-test.sh`, this script is
|
||||
maintainer tooling and is **not** shipped in the published image.
|
||||
|
||||
## Versioning and release
|
||||
|
||||
pi-devbox follows semver-ish:
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
# Design: single-writer MemPalace broker (cross-host serialization)
|
||||
|
||||
> **Status:** DRAFT / RFC — not yet implemented. Captures the design so it can be
|
||||
> picked up later. Authored 2026-06-14.
|
||||
> **Owner:** unassigned. **Tracking:** queue item #4 ("host-side mempalace-mcp
|
||||
> daemon over a UNIX/shared socket").
|
||||
|
||||
## Problem
|
||||
|
||||
The pi-devbox container's `~/.mempalace` (`/home/developer/.mempalace`) is a
|
||||
**virtiofs bind-mount of the host's `/Users/joakim/.mempalace`** (verified
|
||||
2026-06-14 via `/proc/mounts`: `mac /home/developer/.mempalace virtiofs rw`).
|
||||
Container pi and host-native pi therefore **read and write ONE shared palace** —
|
||||
full memory parity already exists; nothing needs to be built to *enable* sharing.
|
||||
|
||||
The actual hazard is the opposite of sharing: **concurrency**. Two pi processes
|
||||
(one native on the host, one in the container) can open the same
|
||||
`chroma.sqlite3` / `knowledge_graph.sqlite3` and write at the same time. The
|
||||
palace directory already shows the scars of this:
|
||||
|
||||
- `chroma.sqlite3.broken-20260505`
|
||||
- many `*.corrupt-20260528`
|
||||
- a long run of `*.drift-2026*`
|
||||
- `locks/` with `mine_palace_*.lock` files, including a **stale** one.
|
||||
|
||||
These are mempalace's defensive lock + auto-snapshot/repair machinery firing
|
||||
under concurrent access.
|
||||
|
||||
### Why a shared lock file is NOT sufficient
|
||||
|
||||
The container runs inside a Linux VM (OrbStack / Docker Desktop on macOS); the
|
||||
palace bytes live on the macOS host, surfaced into the VM via virtiofs.
|
||||
Consequences:
|
||||
|
||||
- A **UNIX-domain socket file** visible at `~/.mempalace/broker.sock` inside the
|
||||
container is a *host-kernel* object. The container's kernel can see the inode
|
||||
but **cannot connect to it** across the VM boundary.
|
||||
- **flock / advisory lockfiles are not coherent across the host↔VM boundary.**
|
||||
A lock taken on the host is not reliably seen in the container and vice-versa.
|
||||
(The stale `mine_palace_*.lock` is direct evidence the existing lock scheme is
|
||||
not bulletproof across this boundary.)
|
||||
|
||||
**Therefore the only trustworthy serialization is to route every write through a
|
||||
single process.** That single process is the broker. The design question is *not*
|
||||
"how do we lock" — it's "**where does the one writer live, and how does every pi
|
||||
(host or container) reach it across the VM boundary?**"
|
||||
|
||||
## Goals
|
||||
|
||||
1. Exactly one process opens the palace SQLite files at any time (single writer;
|
||||
concurrent reads are fine).
|
||||
2. Works in all three topologies on a given host:
|
||||
- native pi only,
|
||||
- native pi + container pi,
|
||||
- container pi only.
|
||||
3. pi configuration is **identical** in every topology (no per-environment MCP
|
||||
config divergence).
|
||||
4. No new corruption pathway introduced; degrade safely when the broker is
|
||||
genuinely unreachable and there are no peers.
|
||||
|
||||
### Non-goals (for this iteration)
|
||||
|
||||
- opencode / opencode-devbox co-existence (see "Co-existence with opencode"
|
||||
below — deferred until the pi case is solved).
|
||||
- Multi-host palace replication. This is about one host's local palace.
|
||||
- Changing mempalace's on-disk format or its public MCP tool surface.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
pi (host) ─stdio─► mp-shim ─┐
|
||||
├─► mempalace-broker ─► chroma.sqlite3
|
||||
pi (ctr) ─stdio─► mp-shim ─┘ (SINGLE owner; knowledge_graph.sqlite3
|
||||
serialized writer, + in-memory HNSW index
|
||||
concurrent readers)
|
||||
```
|
||||
|
||||
### `mempalace-broker`
|
||||
|
||||
A long-lived process that is the **only** opener of the palace SQLite files. It:
|
||||
|
||||
- runs the real mempalace engine,
|
||||
- holds the HNSW index in memory,
|
||||
- pushes all mutations through a single writer queue (reads may fan out),
|
||||
- exposes the mempalace MCP JSON-RPC surface over one or more transports,
|
||||
- is the canonical owner of palace state for the lifetime of the host session.
|
||||
|
||||
**Bonus:** a single always-resident owner also eliminates the stale-HNSW-index
|
||||
problem that `mempalace_reconnect` exists to work around — there is never an
|
||||
external writer to desync the in-memory index against.
|
||||
|
||||
### `mp-shim`
|
||||
|
||||
A tiny stdio↔transport adapter. pi's mempalace MCP config points at the shim
|
||||
**everywhere, unchanged**. pi still believes it is speaking stdio MCP to a local
|
||||
server; the shim forwards JSON-RPC to the broker over whichever transport is
|
||||
available, and handles all discovery / startup / election complexity. Keeping
|
||||
pi's config identical across topologies is a hard requirement (goal #3) and the
|
||||
shim is what makes it possible.
|
||||
|
||||
## Canonical owner = the host
|
||||
|
||||
The broker's home is **always the host**, because:
|
||||
|
||||
1. The palace bytes physically live there (`/Users/joakim/.mempalace`).
|
||||
2. The host outlives any container — ownership does not evaporate on
|
||||
`docker compose down`.
|
||||
3. Containers already have a route back to it (`host.docker.internal` and the
|
||||
verified dssh ControlMaster bridge).
|
||||
|
||||
The broker binds **two listeners feeding one queue**:
|
||||
|
||||
- **AF_UNIX** at `$MEMPALACE_PATH/broker.sock` — for host-native pi (fast,
|
||||
filesystem-perms-secured).
|
||||
- a **cross-boundary** transport for container clients (below).
|
||||
|
||||
## Transport matrix
|
||||
|
||||
| Topology | Broker runs on | Host pi reaches it via | Container pi reaches it via |
|
||||
|---|---|---|---|
|
||||
| native only | host | AF_UNIX socket | — |
|
||||
| native + container | host | AF_UNIX socket | SSH-forwarded socket (preferred) or TCP |
|
||||
| container only | host (started via bridge) | — | SSH-forwarded socket or TCP |
|
||||
|
||||
### Cross-boundary transport options
|
||||
|
||||
**(a) SSH-forwarded UNIX socket over the existing dssh ControlMaster — PREFERRED.**
|
||||
The container's `setup-lan-access.sh` already establishes a ControlMaster to the
|
||||
host with `ControlPersist 4h`. The container shim forwards the host broker socket
|
||||
over that master:
|
||||
|
||||
```
|
||||
ssh -F ~/.ssh-local/config \
|
||||
-L "$XDG_RUNTIME_DIR/mp.sock:$HOME/.mempalace/broker.sock" host
|
||||
```
|
||||
|
||||
then connects to the local forwarded socket. Auth = SSH key; nothing is
|
||||
LAN-exposed; no extra shared secret needed; rides the persistent master so setup
|
||||
cost is near-zero. Most portable across non-OrbStack hosts.
|
||||
|
||||
**(b) TCP on `host.docker.internal:PORT` — fallback.** Simpler, but the broker
|
||||
must bind a routable interface (not just `127.0.0.1`), which requires a
|
||||
**shared-secret token** to prevent other local/LAN processes from talking to it.
|
||||
The token is written to `broker.json` in the virtiofs-mounted palace dir
|
||||
(readable from both sides). More care required to get the bind + auth right.
|
||||
|
||||
## Discovery + on-demand start (the shim's algorithm)
|
||||
|
||||
Run by the shim on every pi session start, so it is correct regardless of who is
|
||||
already running:
|
||||
|
||||
```
|
||||
1. If $MEMPALACE_BROKER is set → use it verbatim (escape hatch).
|
||||
2. Read $MEMPALACE_PATH/broker.json → endpoint + pid + token.
|
||||
Try to connect (UNIX if host; forwarded-sock / TCP if container).
|
||||
If connected & healthy → done.
|
||||
3. Broker not reachable → START IT:
|
||||
- On host: flock($MEMPALACE_PATH/broker.lock, non-blocking)
|
||||
win → exec broker, wait for broker.json, connect.
|
||||
lose → someone else is starting it; backoff + retry connect.
|
||||
- In container: run `ssh host 'mempalace-broker --ensure'` (idempotent;
|
||||
performs the SAME flock election ON THE HOST), then forward +
|
||||
connect.
|
||||
4. Last-resort fallback (no broker, cannot start one):
|
||||
open the palace DIRECTLY — but ONLY after asserting this process is the sole
|
||||
writer (no other live broker/pid recorded in broker.json). Degrades to
|
||||
today's behaviour for the genuinely-alone case; never used when a broker
|
||||
exists.
|
||||
```
|
||||
|
||||
**Key trick:** host-side election uses `flock` on the host, where it is coherent
|
||||
(same kernel) — bulletproof. The cross-boundary case **never relies on cross-VM
|
||||
locking**; it relies on `ssh host 'broker --ensure'`, which runs the election on
|
||||
the host where flock works. That is what makes the design topology-independent.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
- Broker writes `broker.json` (endpoint + pid + token) **atomically** after
|
||||
binding.
|
||||
- Broker holds `broker.lock` for its entire lifetime → at most one host broker.
|
||||
- Idle-exit after N minutes with no connected clients; the next client
|
||||
re-elects. (Or keep-alive; idle-exit is friendlier on resources.)
|
||||
- Clients reclaim a stale lock if the pid recorded in `broker.json` is dead.
|
||||
- Clients retry with backoff while a broker is mid-startup.
|
||||
|
||||
## Engine vs. shim — what the image must still ship
|
||||
|
||||
The component bundled in the images today is really **two separable pieces**:
|
||||
|
||||
- the **mempalace engine** — opens the SQLite files, computes embeddings, owns
|
||||
the HNSW index (the heavy part: chromadb, embedding model, etc.), and
|
||||
- the thin client surface pi actually talks to.
|
||||
|
||||
In the brokered design these split cleanly:
|
||||
|
||||
- the **broker** is the only thing that runs the *engine*;
|
||||
- the **shim** is **engine-free** — it just forwards MCP JSON-RPC. It needs no
|
||||
chromadb, no embedding model, no heavy deps. Embeddings/search happen
|
||||
broker-side. (Potential image-slimming opportunity, though see below for why
|
||||
we keep the engine bundled anyway.)
|
||||
|
||||
Whether the bundled engine is "used as-is" or merely fronted by the broker
|
||||
**depends on who owns the broker**:
|
||||
|
||||
**A) Host runs the broker (native, or native+container — the common case).**
|
||||
The *host's* engine is authoritative and used as-is. The broker is purely an
|
||||
intermediate step so writes can't collide; the host engine does the read/write.
|
||||
The container's **bundled engine is dormant** — the container uses only its shim
|
||||
to reach the host broker. The engine in the image is not needed for this path.
|
||||
|
||||
**B) Container lands on a host with no mempalace (fresh-host case).**
|
||||
The bundled engine earns its keep — you cannot conjure an engine onto the host
|
||||
without installing one. Either the container runs the broker *itself*
|
||||
(in-container ownership, bundled engine used as-is) or it falls back to degraded
|
||||
direct mode (single writer, bundled engine used directly).
|
||||
|
||||
**Decision: keep shipping the engine in the images** — but for three specific
|
||||
reasons, not because the brokered path needs it:
|
||||
|
||||
1. **Self-containedness** — pi-devbox's promise is "works on any host." A
|
||||
container with no memory unless the host pre-installed mempalace breaks that,
|
||||
especially for the Docker Hub audience.
|
||||
2. **Fresh-host bootstrap** (case B) — no host engine to borrow.
|
||||
3. **Degraded fallback** — the no-broker-reachable path opens the DB locally and
|
||||
needs the engine present.
|
||||
|
||||
In the host-managed common case the bundled engine is just dormant insurance;
|
||||
the shim is the only piece the container actively uses.
|
||||
|
||||
### Version-coherence note
|
||||
|
||||
Because **only the broker's engine ever writes**, its version defines the
|
||||
on-disk format. Host-vs-bundled engine version skew is therefore **harmless in
|
||||
the brokered path** (only one engine ever touches the bytes). Skew only bites in
|
||||
**degraded direct mode**, where the container writes with a possibly-different
|
||||
engine version than the host would. This argues for the broker pinning/owning
|
||||
the authoritative engine version and treating the bundled engine as
|
||||
fallback-only.
|
||||
|
||||
> Partially resolves the "where the broker binary ships" open question below:
|
||||
> the **shim** must ship on both sides; the **engine** must ship on the host
|
||||
> (to run the broker) and stays bundled in the image as fallback/bootstrap
|
||||
> insurance, not as the authoritative writer in the common case.
|
||||
|
||||
## The genuinely hard case
|
||||
|
||||
**Container-only with no SSH bridge configured** (e.g. plain Linux Docker,
|
||||
`HOST_SSH_USER` unset, no `host.docker.internal`). The container cannot start or
|
||||
reach a host broker. Options, none free:
|
||||
|
||||
1. **Require the bridge** for multi-writer container setups, and document it as a
|
||||
precondition. Reasonable: pi-devbox already ships `setup-lan-access.sh` and
|
||||
the bridge is the supported path.
|
||||
2. **Run the broker inside the container**, publishing a Docker port the host can
|
||||
later reach. Works, but inverts ownership and the broker dies with the
|
||||
container — only acceptable if containers are the *sole* writers on that host.
|
||||
3. **Accept degraded mode** (algorithm step 4): a lone container with no peers
|
||||
has no concurrency, so direct access is safe *as long as* nothing else opens
|
||||
the palace concurrently. The host shim also checks `broker.json` before
|
||||
opening directly, so a later host pi will not silently start a second
|
||||
uncoordinated writer.
|
||||
|
||||
**Summary:** fully robust for native-only, native+container, and
|
||||
container-only-with-bridge. The only residual sharp edge is container-only
|
||||
*without* a bridge *and* a future concurrent host writer — intrinsic (no shared
|
||||
coherent lock exists across that boundary), best handled by mandating the bridge
|
||||
rather than pretending file locks work.
|
||||
|
||||
## Co-existence with opencode / opencode-devbox (DEFERRED — context only)
|
||||
|
||||
The palace is shared by more than pi. opencode (native) and opencode-devbox
|
||||
(container) also write to the same `~/.mempalace`. **Assumption to verify:**
|
||||
opencode sessions write to **different wings** than pi sessions (pi uses
|
||||
`wing_pi`, diaries per-agent, etc.), so cross-tool intermixing into the *same*
|
||||
destination may be a non-issue at the application level.
|
||||
|
||||
However, the corruption risk here is at the **SQLite-file level, not the wing
|
||||
level** — two processes writing different wings of the *same* `chroma.sqlite3`
|
||||
concurrently is still a concurrent write to one file. So the broker, once it
|
||||
exists, is the right serialization point for opencode too: opencode's mempalace
|
||||
client would route through the same broker via the same shim mechanism.
|
||||
|
||||
**Decision:** do not design for opencode co-existence yet. Resolve the pi case
|
||||
first; then revisit whether opencode clients adopt the same shim. The residual
|
||||
risk in the interim is native + container *opencode* sessions writing the same
|
||||
palace simultaneously — explicitly deferred ("cross that bridge later").
|
||||
|
||||
## Open questions / TODO before implementation
|
||||
|
||||
- Does the mempalace engine expose an embeddable entrypoint suitable for running
|
||||
inside a long-lived broker, or does the broker wrap the existing MCP server
|
||||
binary and multiplex stdio clients onto it? (Affects whether reads can truly
|
||||
fan out or are also serialized.)
|
||||
- Idle-exit timeout default + whether to expose it via env.
|
||||
- `broker.json` schema + atomic-write + stale-pid-reclaim details.
|
||||
- TCP-path token handling and safe bind interface selection on Linux Docker
|
||||
(`--add-host=host.docker.internal:host-gateway`).
|
||||
- Where the broker binary ships: baked into `Dockerfile.base`? host install via
|
||||
pi-toolkit / mempalace-toolkit? Both, since both sides need the shim and the
|
||||
host needs the broker.
|
||||
- Smoke-test plan: prove single-writer invariant under a deliberate concurrent
|
||||
host+container write storm (should produce zero `.corrupt`/`.drift` snapshots).
|
||||
+65
-9
@@ -12,12 +12,16 @@ set -euo pipefail
|
||||
mkdir -p /tmp/sshcm
|
||||
chmod 700 /tmp/sshcm
|
||||
|
||||
# ── LAN access: generic host-OS-agnostic reachability helper ────────
|
||||
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
|
||||
# reach the host's directly-attached LAN peers by default; this generates a
|
||||
# writable ~/.ssh-local/config that uses the host as an SSH jump. On native
|
||||
# Linux (LAN reachable directly) it is a no-op. Controlled by DEVBOX_LAN_ACCESS
|
||||
# (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the script header.
|
||||
# ── LAN access + writable SSH sidecar: host-OS-agnostic helper ──────
|
||||
# Generates the writable ~/.ssh-local/config on EVERY host OS: a `Host *`
|
||||
# ControlPath redirect into ~/.ssh-local/cm (so `ssh -F` / dssh / dscp work
|
||||
# even when ~/.ssh is bind-mounted read-only) plus `Include ~/.ssh/config`. On
|
||||
# VM-backed hosts (macOS OrbStack / Docker Desktop) it ALSO adds an
|
||||
# SSH-jump-via-host block so the container can reach the host's
|
||||
# directly-attached LAN peers; on native Linux (LAN reachable directly) the
|
||||
# jump block is omitted but the sidecar is still rendered. Controlled by
|
||||
# DEVBOX_LAN_ACCESS (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the
|
||||
# script header.
|
||||
if [ -r /usr/local/lib/pi-devbox/setup-lan-access.sh ]; then
|
||||
bash /usr/local/lib/pi-devbox/setup-lan-access.sh || true
|
||||
fi
|
||||
@@ -36,6 +40,32 @@ if [ -d "$SKEL_DIR" ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Image-baked skills: link into ~/.agents/skills ───────────────────
|
||||
# Skills shipped IN the image (under /usr/local/share/pi-devbox/skills/) are
|
||||
# made available regardless of whether a skillset repo is mounted. Done EARLY
|
||||
# — before the pi-toolkit/extensions deploy below — so the symlinks exist by
|
||||
# the time anything gates on "container ready": the smoke-test readiness probe
|
||||
# waits on pi-deploy markers (keybindings.json, mempalace.ts) that only land
|
||||
# AFTER this point, so linking here closes a sample-too-early race that failed
|
||||
# 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).
|
||||
DEVBOX_SKILLS_SRC=/usr/local/share/pi-devbox/skills
|
||||
if [ -d "$DEVBOX_SKILLS_SRC" ]; then
|
||||
mkdir -p "$HOME/.agents/skills"
|
||||
for _sk in "$DEVBOX_SKILLS_SRC"/*/; do
|
||||
[ -d "$_sk" ] || continue
|
||||
_skname=$(basename "$_sk")
|
||||
if [ ! -e "$HOME/.agents/skills/$_skname" ]; then
|
||||
ln -s "${_sk%/}" "$HOME/.agents/skills/$_skname"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── MemPalace: initialize palace for the workspace if mempalace is installed
|
||||
# Creates the palace directory structure on first run. Idempotent — skips
|
||||
# if palace already exists, so upgrades from older versions preserve
|
||||
@@ -86,9 +116,35 @@ if command -v pi &>/dev/null; then
|
||||
|
||||
# Bootstrap settings.json from template if absent (pi rewrites this
|
||||
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
|
||||
if [ ! -f "$HOME/.pi/agent/settings.json" ] && \
|
||||
[ -f /opt/pi-toolkit/settings.example.json ]; then
|
||||
cp /opt/pi-toolkit/settings.example.json "$HOME/.pi/agent/settings.json"
|
||||
_pi_settings="$HOME/.pi/agent/settings.json"
|
||||
_pi_template=/opt/pi-toolkit/settings.example.json
|
||||
if [ ! -f "$_pi_settings" ] && [ -f "$_pi_template" ]; then
|
||||
cp "$_pi_template" "$_pi_settings"
|
||||
echo "pi settings.json bootstrapped from template"
|
||||
elif [ -f "$_pi_settings" ] && [ -f "$_pi_template" ] && \
|
||||
[ "${PI_SETTINGS_MERGE:-1}" != "0" ] && command -v jq >/dev/null 2>&1; then
|
||||
# Non-destructive merge: a settings.json on a PRESERVED volume never
|
||||
# otherwise sees new template keys (the bootstrap above only fires when
|
||||
# the file is absent), so config added in an image upgrade — e.g. the
|
||||
# observational-memory / pi-fork blocks or a newly-enabled model — never
|
||||
# reaches existing users. Deep-merge with the template FIRST and the
|
||||
# live file SECOND ('.[0] * .[1]') so the user's values always win and
|
||||
# only keys MISSING from the live file are filled in from the template.
|
||||
# Arrays are treated as leaves (the user's array is kept verbatim, so a
|
||||
# model they deliberately removed is not re-added). Only rewrite when the
|
||||
# merge actually changes something, and back up the original first.
|
||||
# Set PI_SETTINGS_MERGE=0 to disable. Invalid JSON on either side → skip,
|
||||
# never clobber.
|
||||
if _pi_merged=$(jq -s '.[0] * .[1]' "$_pi_template" "$_pi_settings" 2>/dev/null); then
|
||||
if [ -n "$_pi_merged" ] && \
|
||||
! printf '%s' "$_pi_merged" | jq -e --slurpfile cur "$_pi_settings" '. == $cur[0]' >/dev/null 2>&1; then
|
||||
cp "$_pi_settings" "${_pi_settings}.bak.$(date +%Y%m%d-%H%M%S)"
|
||||
printf '%s\n' "$_pi_merged" > "$_pi_settings"
|
||||
echo "pi settings.json: merged new template keys from settings.example.json (backup saved)"
|
||||
fi
|
||||
else
|
||||
echo "WARN: pi settings.json merge skipped (jq could not parse template or live file; left untouched)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# pi↔mempalace MCP bridge — single extension symlink.
|
||||
|
||||
@@ -89,9 +89,16 @@ fi
|
||||
# we append with a newline separator to avoid the ';;' parse error
|
||||
# described at the top of this file. Guarded so repeated sourcing
|
||||
# (e.g. `exec bash`) doesn't stack duplicates.
|
||||
#
|
||||
# The guard MUST stay shell-local (NOT exported): if it leaks into child
|
||||
# processes, every nested shell -- crucially each tmux pane, which inherits
|
||||
# the tmux server's env -- skips installing `history -a` and only persists
|
||||
# history on a clean exit. Abrupt termination (docker stop, tmux kill-server,
|
||||
# SIGKILL) then loses that shell's in-memory history. Keeping it unexported
|
||||
# means each new interactive shell re-installs its own per-prompt flush.
|
||||
if [ -z "${DEVBOX_HIST_SET:-}" ]; then
|
||||
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}history -a"
|
||||
export DEVBOX_HIST_SET=1
|
||||
DEVBOX_HIST_SET=1
|
||||
fi
|
||||
|
||||
# ── Prompt: show [opencode-devbox] tag so it's obvious you're in the container
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
# The one thing reachable from a container on every OS is the host itself
|
||||
# (host.docker.internal). So on VM-backed hosts we generate a writable SSH
|
||||
# config that reaches the host and lets the user ProxyJump onward to LAN
|
||||
# peers the host can reach. On native Linux we do nothing.
|
||||
# peers the host can reach. On native Linux we render the same writable
|
||||
# config (for the ControlPath redirect + Include ~/.ssh/config) but emit no
|
||||
# jump block, since LAN peers are reachable directly there.
|
||||
#
|
||||
# We ship the MECHANISM (a generic `host` jump alias + writable config),
|
||||
# never the POLICY: the user's specific target hosts live in their own
|
||||
@@ -30,7 +32,9 @@
|
||||
#
|
||||
# CONTROLS (env)
|
||||
# DEVBOX_LAN_ACCESS = auto (default) | jump | off
|
||||
# auto → set up the jump config only on VM-backed hosts; no-op on Linux.
|
||||
# auto → set up the host jump only on VM-backed hosts. The writable
|
||||
# sidecar config (ControlPath redirect + Include) is always
|
||||
# rendered, on every OS.
|
||||
# jump → always set up (e.g. native Linux with extra_hosts host-gateway).
|
||||
# off → do nothing.
|
||||
# HOST_SSH_USER — the username to SSH into the host as. REQUIRED for the
|
||||
@@ -84,42 +88,72 @@ is_vm_backed() {
|
||||
getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
if [ "$MODE" = "auto" ] && ! is_vm_backed; then
|
||||
# Native Linux host: LAN peers are reachable directly. Nothing to do.
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# From here: MODE=jump, or MODE=auto on a VM-backed host.
|
||||
|
||||
command -v ssh-keygen >/dev/null 2>&1 || exit 0
|
||||
|
||||
# ── Writable socket dir + sidecar (ALWAYS, every host OS) ─────────────
|
||||
# The ControlPath redirect in the generated config needs a writable directory
|
||||
# regardless of host OS or jump mode. ~/.ssh is typically read-only, so the
|
||||
# master socket lives under the writable ~/.ssh-local. We create it and render
|
||||
# the config UNCONDITIONALLY so the redirect (and `Include ~/.ssh/config`) works
|
||||
# even on native Linux — where we set up no host jump but a read-only ~/.ssh
|
||||
# would otherwise still break ControlMaster sockets.
|
||||
mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true
|
||||
chmod 700 "${SSH_LOCAL}" "${SSH_LOCAL}/cm" 2>/dev/null || true
|
||||
|
||||
# ── Jump key (generated once; preserved across restarts) ──────────────
|
||||
# ── Decide whether to set up the host jump ────────────────────────────
|
||||
# Jump = reach the container host (host.docker.internal) as an SSH ProxyJump
|
||||
# onward to the host's LAN peers. Needed on VM-backed hosts (macOS / Docker
|
||||
# Desktop) or when forced with DEVBOX_LAN_ACCESS=jump. On native Linux LAN
|
||||
# peers are reachable directly, so NEED_JUMP=0 and we emit no jump block — but
|
||||
# we still render the config for the ControlPath redirect + Include.
|
||||
NEED_JUMP=0
|
||||
if [ "$MODE" = "jump" ] || { [ "$MODE" = "auto" ] && is_vm_backed; }; then
|
||||
NEED_JUMP=1
|
||||
fi
|
||||
|
||||
# ── Jump key (only when a jump is needed; generated once, preserved) ──
|
||||
# Persisted via a named volume on ~/.ssh-local (see compose), so a fresh key
|
||||
# is generated only on the very first start (or if the volume is wiped). When
|
||||
# we DO generate one it must be (re-)authorized on the host, so we flag it and
|
||||
# print a copy-paste authorize line below.
|
||||
KEY_JUST_GENERATED=0
|
||||
if [ ! -f "$KEY" ]; then
|
||||
ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1 || exit 0
|
||||
chmod 600 "$KEY" 2>/dev/null || true
|
||||
KEY_JUST_GENERATED=1
|
||||
if [ "$NEED_JUMP" = "1" ] && command -v ssh-keygen >/dev/null 2>&1 && [ ! -f "$KEY" ]; then
|
||||
if ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1; then
|
||||
chmod 600 "$KEY" 2>/dev/null || true
|
||||
KEY_JUST_GENERATED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Render the writable config ────────────────────────────────────────
|
||||
USER_LINE=""
|
||||
if [ -n "${HOST_SSH_USER:-}" ]; then
|
||||
USER_LINE=" User ${HOST_SSH_USER}"
|
||||
fi
|
||||
|
||||
# Optional host-owned named-peer jump overrides (portable: lives on the host,
|
||||
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
|
||||
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
|
||||
# Jump-specific blocks (the host alias, host-owned peer overrides, and the
|
||||
# optional RFC1918 catch-all) only make sense when a jump is set up; on native
|
||||
# Linux they are all empty and only the ControlPath redirect + Include remain.
|
||||
JUMP_BLOCK=""
|
||||
LAN_CONF_BLOCK=""
|
||||
if [ -r "$SSH_LAN_CONF" ]; then
|
||||
LAN_CONF_BLOCK=$(cat <<'EOF'
|
||||
AUTOJUMP_BLOCK=""
|
||||
if [ "$NEED_JUMP" = "1" ]; then
|
||||
USER_LINE=""
|
||||
if [ -n "${HOST_SSH_USER:-}" ]; then
|
||||
USER_LINE=" User ${HOST_SSH_USER}"
|
||||
fi
|
||||
JUMP_BLOCK=$(cat <<EOF
|
||||
|
||||
# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases.
|
||||
Host host mac
|
||||
HostName ${HOST_ALIAS_HOSTNAME}
|
||||
${USER_LINE}
|
||||
IdentityFile ~/.ssh-local/devbox_jump_ed25519
|
||||
IdentitiesOnly yes
|
||||
ControlMaster auto
|
||||
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||
ControlPersist 4h
|
||||
ServerAliveInterval 30
|
||||
EOF
|
||||
)
|
||||
|
||||
# Optional host-owned named-peer jump overrides (portable: lives on the host,
|
||||
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
|
||||
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
|
||||
if [ -r "$SSH_LAN_CONF" ]; then
|
||||
LAN_CONF_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# Host-owned named-peer jump overrides (bind-mounted; edit on the host).
|
||||
# Scope reset to match-all so the Include applies to every target host.
|
||||
@@ -127,14 +161,13 @@ Host *
|
||||
Include ~/.config/devbox-shell/ssh-lan.conf
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
fi
|
||||
|
||||
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
|
||||
# host. Matches the typed address, never the resolved HostName, so named hosts
|
||||
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
|
||||
AUTOJUMP_BLOCK=""
|
||||
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
|
||||
AUTOJUMP_BLOCK=$(cat <<'EOF'
|
||||
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
|
||||
# host. Matches the typed address, never the resolved HostName, so named hosts
|
||||
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
|
||||
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
|
||||
AUTOJUMP_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# RFC1918 auto-jump (DEVBOX_LAN_AUTOJUMP_PRIVATE=1): reach any private IP on
|
||||
# the host's CURRENT LAN via bare `dssh user@<ip>`. Public IPs are unmatched
|
||||
@@ -146,6 +179,7 @@ Host 10.* 192.168.* 172.16.* 172.17.* 172.18.* 172.19.* 172.20.* 172.21.* 172.22
|
||||
ProxyJump host
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
fi
|
||||
|
||||
INCLUDE_BLOCK=""
|
||||
@@ -154,7 +188,9 @@ if [ -r "${HOME}/.ssh/config" ]; then
|
||||
|
||||
# Your own target hosts. Scope reset to match-all so this Include applies to
|
||||
# every target (an Include is otherwise scoped to the enclosing Host block).
|
||||
# Add 'ProxyJump host' to LAN entries here (or in ssh-lan.conf above).
|
||||
# To make a LAN peer jump via the host, add 'ProxyJump host' to its entry in
|
||||
# the host-owned ~/.config/devbox-shell/ssh-lan.conf (Included above) — NOT
|
||||
# here in ~/.ssh/config, which is typically bind-mounted read-only.
|
||||
Host *
|
||||
Include ~/.ssh/config
|
||||
EOF
|
||||
@@ -176,17 +212,7 @@ Host *
|
||||
UserKnownHostsFile ~/.ssh-local/known_hosts
|
||||
StrictHostKeyChecking accept-new
|
||||
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||
|
||||
# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases.
|
||||
Host host mac
|
||||
HostName ${HOST_ALIAS_HOSTNAME}
|
||||
${USER_LINE}
|
||||
IdentityFile ~/.ssh-local/devbox_jump_ed25519
|
||||
IdentitiesOnly yes
|
||||
ControlMaster auto
|
||||
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||
ControlPersist 4h
|
||||
ServerAliveInterval 30
|
||||
${JUMP_BLOCK}
|
||||
${LAN_CONF_BLOCK}
|
||||
${AUTOJUMP_BLOCK}
|
||||
${INCLUDE_BLOCK}
|
||||
@@ -199,6 +225,7 @@ chmod 600 "$CONFIG" 2>/dev/null || true
|
||||
# host won't recognize. With ~/.ssh-local persisted via a named volume, case
|
||||
# (b) fires only on first-ever start (or after the volume is reset) — so this
|
||||
# is normally a one-time, one-line step per machine, with no file to locate.
|
||||
if [ "$NEED_JUMP" = "1" ]; then
|
||||
PUBKEY_TEXT="$(cat "${KEY}.pub" 2>/dev/null)"
|
||||
if [ -z "${HOST_SSH_USER:-}" ]; then
|
||||
cat <<EOF
|
||||
@@ -221,5 +248,6 @@ elif [ "$KEY_JUST_GENERATED" = "1" ]; then
|
||||
repeat this on container updates — only if that volume is reset.
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<!-- pi-devbox:managed-block — appended to the global AGENTS.md at image build
|
||||
time (Dockerfile.variant), after pi-toolkit is cloned. Keep this short:
|
||||
it is a pointer, the depth lives in the skill. -->
|
||||
|
||||
## Running inside pi-devbox
|
||||
|
||||
If the directory `/usr/local/lib/pi-devbox/` exists (or your shell prompt is
|
||||
prefixed `[devbox]`, or `~/.ssh-local/config` is present), you are in a
|
||||
**pi-devbox container** — a Docker environment whose persistence, networking,
|
||||
DNS, host/LAN reachability, tmux, and Python/REPL behaviour differ from a normal
|
||||
workstation. Before any task that touches **reaching the host or its LAN, SSH,
|
||||
DNS/name resolution, what survives container recreate, running Python/REPLs,
|
||||
tmux, or pi-studio**, read `~/.agents/skills/pi-devbox-environment/SKILL.md`.
|
||||
|
||||
Key reflex from that skill: **the deployment specifics are not universal** — the
|
||||
host OS, hostnames, internal domains, and nameservers vary per instance and must
|
||||
be discovered at runtime, never assumed. And interactive shell aliases
|
||||
(`dssh`, `dscp`, `cat`→`bat`) do **not** exist in your non-interactive bash
|
||||
tool, so spell out the underlying command (e.g.
|
||||
`ssh -F "$HOME/.ssh-local/config" mac …`).
|
||||
|
||||
## Session start: load the mempalace skill
|
||||
|
||||
If MemPalace MCP tools (e.g. `mempalace_search`, `mempalace_diary_write`) are in
|
||||
your tool list, **read `~/.agents/skills/mempalace/SKILL.md` before doing
|
||||
non-trivial work** and follow its protocol: search the palace before answering
|
||||
about past work, and write a diary entry before the session ends. This is
|
||||
especially load-bearing here — a pi-devbox container is frequently recreated, so
|
||||
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.)
|
||||
@@ -0,0 +1,47 @@
|
||||
# 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).
|
||||
|
||||
| skill | owner | how it gets here |
|
||||
|-------|-------|------------------|
|
||||
| `pi-devbox-environment` | pi-devbox (this repo) | authored here; the canonical copy |
|
||||
| `pi-extensions` | the `pi-extensions` package repo (`skill/`) | **vendored fallback** + refreshed at build |
|
||||
| `mempalace` | the `skillset` repo | **vendored fallback** (snapshot only) |
|
||||
|
||||
## Why fallbacks exist
|
||||
|
||||
The pi-toolkit global `AGENTS.md` tells every pi session to read
|
||||
`~/.agents/skills/pi-extensions/SKILL.md` at start (to fix fork/recall
|
||||
under-utilisation). That pointer dangles in a container started **without** the
|
||||
private `skillset` repo mounted. Baking the skill closes that *availability*
|
||||
gap. `mempalace` is baked for the same reason (memory continuity); since
|
||||
nothing in pi-toolkit's `AGENTS.md` points to it, the pi-devbox managed block
|
||||
(`pi-global-AGENTS.append.md`) also adds the matching *proactive-load*
|
||||
directive ("load the mempalace skill at session start") so a new container
|
||||
actually picks it up rather than relying on description-matching.
|
||||
`pi-extensions`'s directive already ships in pi-toolkit's `AGENTS.md`, so only
|
||||
its skill file needed baking.
|
||||
|
||||
## Freshness model (layered — see Dockerfile.variant)
|
||||
|
||||
- **`pi-extensions`** — Option 1 + Option 2. The committed copy here is the
|
||||
*floor*; at build time `Dockerfile.variant` copies `/opt/pi-extensions/skill/`
|
||||
(the pinned, package-owned source) over it, so a normal build ships the fresh
|
||||
package copy and a stale-ref / mirror build still ships the snapshot. Keep
|
||||
`evaluate-extension-usage.py` alongside `SKILL.md` — the skill calls it via
|
||||
`./`.
|
||||
- **`mempalace`** — Option 2 only. The `mempalace` *consumer* skill lives only
|
||||
in the private `skillset` repo (the `mempalace-toolkit` repo ships a
|
||||
*different* skill, `opencode-mempalace-bridge`), so there is no public
|
||||
package source to copy from. This snapshot is refreshed manually per release.
|
||||
|
||||
## Refreshing the snapshots
|
||||
|
||||
cp <skillset>/skills/pi-extensions/SKILL.md pi-extensions/SKILL.md
|
||||
cp <skillset>/skills/pi-extensions/evaluate-extension-usage.py pi-extensions/
|
||||
cp <skillset>/skills/mempalace/SKILL.md mempalace/SKILL.md
|
||||
|
||||
Snapshot provenance at last refresh: skillset `8e8db64`, pi-extensions pkg `a7f3044`.
|
||||
@@ -0,0 +1,301 @@
|
||||
---
|
||||
name: mempalace
|
||||
description: MemPalace agent memory protocol. Use on every session to maintain continuity across conversations — search before answering about past work, write diary entries before session ends, and mine new projects into the palace. Load this skill at session start.
|
||||
---
|
||||
|
||||
# MemPalace Agent Memory Protocol
|
||||
|
||||
## Overview
|
||||
|
||||
MemPalace gives you persistent memory across sessions via an MCP server. It stores project knowledge (mined from files), conversation summaries (diary entries), and entity relationships (knowledge graph). Without this protocol, you have tools but no habits — and memory without habits is just storage.
|
||||
|
||||
**Core principle:** Storage is not memory. Storage + protocol = memory.
|
||||
|
||||
## When to Load This Skill
|
||||
|
||||
- At the **start of every session** (proactively, before the user asks)
|
||||
- When the user mentions **past conversations, decisions, or work**
|
||||
- When working on a **new project or repository** for the first time
|
||||
- When the user asks about **people, projects, or relationships**
|
||||
|
||||
## Session Lifecycle
|
||||
|
||||
### Phase 1: Wake Up (session start)
|
||||
|
||||
Run these immediately when a session begins, before responding to the user:
|
||||
|
||||
1. **Load palace overview:**
|
||||
```
|
||||
mempalace_status
|
||||
```
|
||||
This returns wing/room counts, the AAAK spec, and the memory protocol reminder.
|
||||
|
||||
2. **Read your recent diary:**
|
||||
```
|
||||
mempalace_diary_read(agent_name="<your_agent_name>", last_n=5)
|
||||
```
|
||||
Scan for context about recent sessions — what was worked on, what matters, what's pending.
|
||||
|
||||
3. **Check the knowledge graph** for the user or active project if relevant:
|
||||
```
|
||||
mempalace_kg_query(entity="<project_or_person>")
|
||||
```
|
||||
|
||||
Do NOT announce this to the user. Just do it silently to orient yourself.
|
||||
|
||||
### Phase 2: Active Session (during work)
|
||||
|
||||
#### Search Before You Speak
|
||||
|
||||
Before answering questions about past work, decisions, people, or projects:
|
||||
|
||||
```
|
||||
mempalace_search(query="<keywords>", wing="<project>")
|
||||
```
|
||||
|
||||
**Never guess about facts that might be in the palace.** Wrong is worse than slow. Say "let me check" and query.
|
||||
|
||||
#### Mine New Projects
|
||||
|
||||
When working on a new codebase for the first time:
|
||||
|
||||
1. Check if it's already mined:
|
||||
```
|
||||
mempalace_list_wings
|
||||
```
|
||||
|
||||
2. **Decide what to mine — docs first, code never (by default).**
|
||||
|
||||
The palace is for *context and intent*, not code recall. Code is better read from the working tree via `Read`/`Grep`/`glob` — always authoritative, never stale. Embedding source code produces thousands of low-signal drawers (e.g. `def __init__(self, ...)` across every class) that pollute search for years.
|
||||
|
||||
**Mine by default:**
|
||||
- `*.md`, `*.rst`, `*.txt` — docs, READMEs, CHANGELOGs, architecture notes
|
||||
- `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, design/decision docs — highest signal per byte
|
||||
- `*.sh`, `Dockerfile`, `Makefile`, entrypoints — small, intent-bearing
|
||||
- `*.yml`, `*.yaml`, `*.toml`, selective `*.json` (`docker-compose`, `pyproject`, `mkdocs.yml`, CI workflows) — skip lockfiles
|
||||
|
||||
**Do NOT mine by default:**
|
||||
- `*.py`, `*.ts`, `*.tsx`, `*.js`, `*.go`, `*.rs`, `*.java`, `*.cpp`, `*.c`, `*.rb` — raw source code
|
||||
- Test files, fixtures, generated code
|
||||
- `node_modules/`, `.venv/`, `__pycache__/`, `.mypy_cache/`, `.pytest_cache/`, `.ruff_cache/` (the miner respects `.gitignore` but double-check)
|
||||
|
||||
Exception: if a code file *is* the documentation (e.g. a heavily-commented reference script, or a protocol definition), file it manually via `mempalace_add_drawer`.
|
||||
|
||||
3. **Before mining**, inspect the repo to estimate drawer count:
|
||||
```bash
|
||||
# Quick audit — what will actually get mined?
|
||||
find <dir> -type f \
|
||||
-not -path '*/.git/*' -not -path '*/node_modules/*' \
|
||||
-not -path '*/.venv/*' -not -path '*/__pycache__/*' \
|
||||
\( -name '*.md' -o -name '*.sh' -o -name '*.yml' -o -name '*.yaml' \
|
||||
-o -name '*.toml' -o -name 'Dockerfile*' -o -name 'Makefile' \) | wc -l
|
||||
```
|
||||
A docs-heavy repo should produce ~5–10 drawers per file. If a mine produces >15 drawers/file on average, code leaked in — investigate.
|
||||
|
||||
4. Run the mine:
|
||||
```bash
|
||||
mempalace init --yes <directory>
|
||||
mempalace mine <directory> --agent <your_agent_name>
|
||||
```
|
||||
|
||||
The miner currently lacks a `--docs-only` or `--exclude-ext` flag (as of v3.3.3). Until it does, either:
|
||||
- (a) Add a `mempalace.yaml` at the repo root with explicit include globs, OR
|
||||
- (b) Mine everything, then surgically remove code-sourced drawers via SQL on `~/.mempalace/palace/chroma.sqlite3` (delete by `embedding_metadata.source_file LIKE '%.py'`), followed by `mempalace repair --yes`.
|
||||
|
||||
5. If the CLI miner misses a file you *do* want (e.g., `.zsh`, an undocumented extension), file it manually:
|
||||
```
|
||||
mempalace_add_drawer(wing="<project>", room="<aspect>", content="<verbatim content>", source_file="<path>")
|
||||
```
|
||||
|
||||
6. After mining, reconnect to pick up the new embeddings:
|
||||
```
|
||||
mempalace_reconnect
|
||||
```
|
||||
If search errors occur after mining ("Error finding id"), repair the index:
|
||||
```bash
|
||||
mempalace repair --yes
|
||||
```
|
||||
|
||||
#### Track Facts in the Knowledge Graph
|
||||
|
||||
When you learn new facts about people, projects, or relationships:
|
||||
|
||||
```
|
||||
mempalace_kg_add(subject="ProjectX", predicate="uses", object="PostgreSQL")
|
||||
mempalace_kg_add(subject="Alice", predicate="owns", object="ProjectX", valid_from="2026-01-15")
|
||||
```
|
||||
|
||||
When facts change (ended, no longer true):
|
||||
|
||||
```
|
||||
mempalace_kg_invalidate(subject="Alice", predicate="works_at", object="OldCorp", ended="2026-03-01")
|
||||
```
|
||||
|
||||
#### Cross-Reference with Tunnels
|
||||
|
||||
When content in one project relates to another, create a tunnel:
|
||||
|
||||
```
|
||||
mempalace_create_tunnel(
|
||||
source_wing="project_api", source_room="endpoints",
|
||||
target_wing="project_db", target_room="schema",
|
||||
label="API endpoints map to these DB tables"
|
||||
)
|
||||
```
|
||||
|
||||
#### Feeding opencode session history (opencode + mempalace-toolkit only)
|
||||
|
||||
MemPalace has no upstream integration with [opencode](https://github.com/anomalyco/opencode) as of v3.3.3 — `hooks_cli.py` only supports `claude-code` and `codex` harnesses. Opencode persists every turn in a local SQLite DB at `~/.local/share/opencode/opencode.db`, but nothing moves that data into the palace automatically.
|
||||
|
||||
On a machine with opencode + the [`mempalace-toolkit`](https://gitea.jordbo.se/joakimp/mempalace-toolkit) installed, session history is fed into `wing_conversations` via `mempalace-session` — either manually, or on a weekly systemd user timer / cron schedule shipped in `mempalace-toolkit/contrib/`. If this is missing, opencode conversations exist only in the local SQLite DB and are invisible to `mempalace_search`.
|
||||
|
||||
**How to tell if it's set up:**
|
||||
|
||||
```
|
||||
mempalace_list_wings
|
||||
```
|
||||
|
||||
If `wing_conversations` exists and has a drawer count comparable to the user's opencode session count, session feeding is working. If it's empty or suspiciously small, suggest:
|
||||
|
||||
1. Check if the toolkit is installed: `which mempalace-session`.
|
||||
2. If installed, suggest running `mempalace-session --dry-run` to preview and `mempalace-session` to file.
|
||||
3. If not installed, point the user at `gitea.jordbo.se/joakimp/mempalace-toolkit` for setup.
|
||||
|
||||
**Don't try to paper over the gap by dumping turn-level content into the palace manually via `mempalace_add_drawer`** — that reinvents what `mempalace-session` does with normalization and dedup. Use the tool.
|
||||
|
||||
Full routine (triggers, cadence, automation) is in the [`opencode-mempalace-bridge`](https://gitea.jordbo.se/joakimp/mempalace-toolkit) skill and the toolkit's `ARCHITECTURE.md` §5. The two skills pair: this one (`mempalace`) covers using the palace; that one (`opencode-mempalace-bridge`) covers feeding it from opencode.
|
||||
|
||||
### Phase 3: Wind Down (session end)
|
||||
|
||||
**Always write a diary entry before the session ends.** This is the most important habit.
|
||||
|
||||
```
|
||||
mempalace_diary_write(
|
||||
agent_name="<your_agent_name>",
|
||||
entry="<AAAK compressed summary>",
|
||||
topic="session-summary"
|
||||
)
|
||||
```
|
||||
|
||||
#### Why still write diaries when sessions may be mined automatically?
|
||||
|
||||
On machines running opencode + `mempalace-toolkit`, every session is mined into `wing_conversations` on a weekly (or user-defined) schedule. A common and incorrect conclusion: *"since every turn is captured automatically, writing a diary entry is redundant."* It isn't.
|
||||
|
||||
Session mining captures **what was said** (every turn, verbatim). A diary captures **what the session meant** — editorial judgment by the agent who lived it:
|
||||
|
||||
- Lessons learned, patterns noticed, pending items rolled forward
|
||||
- Meta-observations that were never said aloud during the session
|
||||
- Aggregate counts (commits shipped, bugs fixed, hours spent)
|
||||
- A compressed, recency-scannable summary for the *next* agent's wake-up
|
||||
|
||||
Mining raw turns cannot surface these because the words don't exist verbatim — they're the agent's reflection at wind-down. Think of the split as *release notes* (diary) vs. *git log with diffs* (session mine): a repo keeps both because they answer different questions. So does the palace.
|
||||
|
||||
**Practical rule:** automated mining does not replace Phase 3. Both systems cover each other's failure modes — a skipped diary is recovered from the raw turns; a missed mine is recovered from the diary summary. For the full treatment (comparison table, retrieval patterns, token economics), see [`mempalace-toolkit/ARCHITECTURE.md` §5 → "Diary vs session mine: why keep both?"](https://gitea.jordbo.se/joakimp/mempalace-toolkit/src/branch/main/ARCHITECTURE.md#diary-vs-session-mine-why-keep-both).
|
||||
|
||||
#### AAAK Diary Format
|
||||
|
||||
Write diary entries in compressed AAAK format for efficiency. Structure:
|
||||
|
||||
```
|
||||
SESSION:<date>|<what.you.worked.on>|
|
||||
TASKS:
|
||||
1.<task.description>→<outcome>|
|
||||
2.<task.description>→<outcome>|
|
||||
DISCOVERED:<unexpected.findings>|
|
||||
ENTITIES:<people.or.projects.encountered>|
|
||||
<importance: one to five stars>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
SESSION:2026-04-28|api.refactor+db.migration|
|
||||
TASKS:
|
||||
1.refactored.auth.endpoints→split.into.3.modules|
|
||||
2.added.user.roles.migration→postgres.enum.type|
|
||||
DISCOVERED:legacy.session.table.unused.since.v2|
|
||||
ENTITIES:ProjectX;Alice(reviewer)|
|
||||
***
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Use dots instead of spaces within phrases
|
||||
- Use pipes as field separators
|
||||
- Use arrows for cause/effect or transitions
|
||||
- Stars indicate session importance (one to five)
|
||||
- Keep it tight — a future agent should get the gist in seconds
|
||||
|
||||
#### What to Capture
|
||||
|
||||
Prioritize recording:
|
||||
- **Decisions made** and their rationale
|
||||
- **Discoveries** — things that surprised you or that a future session needs to know
|
||||
- **Unfinished work** — what's pending, what was deferred
|
||||
- **User preferences** observed during the session
|
||||
- **Entities encountered** — people, projects, tools, services
|
||||
|
||||
### Phase 4: Fact Updates
|
||||
|
||||
If facts changed during the session, update the knowledge graph before writing the diary:
|
||||
|
||||
```
|
||||
mempalace_kg_invalidate(subject="...", predicate="...", object="...", ended="<today>")
|
||||
mempalace_kg_add(subject="...", predicate="...", object="...", valid_from="<today>")
|
||||
```
|
||||
|
||||
## Palace Structure
|
||||
|
||||
### Wings
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
- **`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.
|
||||
- **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.
|
||||
|
||||
### Rooms
|
||||
|
||||
Rooms are aspects within a wing:
|
||||
- `fzf`, `scripts`, `configuration`, `general` — whatever the miner detects
|
||||
- Diary entries go into rooms by topic tag
|
||||
|
||||
### Drawers
|
||||
|
||||
Drawers hold verbatim content — never summarized, always searchable.
|
||||
|
||||
### Tunnels
|
||||
|
||||
Cross-wing connections linking related content across projects.
|
||||
|
||||
### Knowledge Graph
|
||||
|
||||
Entity-relationship triples with temporal validity. Query with `mempalace_kg_query`, browse with `mempalace_kg_timeline`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Fix |
|
||||
|---|---|
|
||||
| "No palace found" | Run `mempalace init <dir>` then `mempalace mine <dir>` |
|
||||
| "Error finding id" after mining | Run `mempalace repair --yes` then `mempalace_reconnect` |
|
||||
| Search returns irrelevant results | Use `max_distance=1.0` for stricter matching; add `wing` filter |
|
||||
| Miner skips file types | File manually with `mempalace_add_drawer` or use `--no-gitignore` |
|
||||
| Stale results after external changes | Call `mempalace_reconnect` |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- **Don't guess when you can search.** If a question touches past work, search first.
|
||||
- **Don't skip the diary.** A session without a diary entry is a session forgotten.
|
||||
- **Don't summarize drawer content.** File verbatim — the embedding model needs the original words.
|
||||
- **Don't mine .git directories or node_modules.** The CLI miner respects .gitignore by default.
|
||||
- **Don't create duplicate drawers.** Use `mempalace_check_duplicate` before adding manually.
|
||||
- **Don't treat the palace as a task list.** It's for knowledge and context, not todos.
|
||||
@@ -0,0 +1,207 @@
|
||||
---
|
||||
name: pi-devbox-environment
|
||||
description: >-
|
||||
Operate correctly inside a pi-devbox container. Load when running inside
|
||||
pi-devbox (detection: the directory `/usr/local/lib/pi-devbox/` exists, the
|
||||
shell prompt is prefixed `[devbox]`, or `~/.ssh-local/config` is present) and
|
||||
the task touches any of: reaching the Docker host or its LAN, SSH, DNS name
|
||||
resolution, what survives container recreate (persistence vs ephemerality),
|
||||
running Python or other REPLs, tmux, or the pi-studio browser UI. Covers the
|
||||
persistence model, the interactive-vs-tool-shell alias gotcha
|
||||
(dssh/dscp/cat=bat exist only in interactive bash), host + LAN SSH
|
||||
reachability and ControlMaster, split-horizon DNS mechanisms, the tmux
|
||||
0-index constraint, uv-first Python, and pi-studio reachability. This skill
|
||||
teaches MECHANISMS only — concrete hostnames, usernames, internal domains,
|
||||
nameservers, and even the host OS vary per deployment and MUST be discovered
|
||||
at runtime, never assumed or hardcoded.
|
||||
---
|
||||
|
||||
# pi-devbox environment
|
||||
|
||||
You are (or may be) running inside **pi-devbox**: a Docker container that ships
|
||||
pi, MemPalace, and a curated tool stack, with the host source tree mounted at
|
||||
`/workspace`. This skill is about the *container-shaped* facts that change how
|
||||
you should act — things that are easy to get wrong because they differ from a
|
||||
normal workstation shell.
|
||||
|
||||
> **Golden rule: this environment is a template, not a fixed deployment.**
|
||||
> The host could be macOS, Windows, or Linux. There may or may not be LAN
|
||||
> peers, a VPN, split-DNS, a skillset mount, or the `-studio` variant. Detect
|
||||
> and verify the specifics live (commands below) — do **not** assume any
|
||||
> particular hostname, domain, nameserver, or OS. Where this skill shows
|
||||
> example values they are illustrative placeholders.
|
||||
|
||||
## 0. Am I in pi-devbox, and what's true *here*?
|
||||
|
||||
Cheap detection signals (any one is sufficient):
|
||||
|
||||
```sh
|
||||
[ -d /usr/local/lib/pi-devbox ] && echo "pi-devbox image"
|
||||
[ -r "$HOME/.ssh-local/config" ] && echo "LAN/host SSH sidecar present"
|
||||
case "$PS1" in *'[devbox]'*) echo "interactive devbox shell";; esac
|
||||
```
|
||||
|
||||
Then orient before acting:
|
||||
|
||||
```sh
|
||||
cat /etc/os-release | head -2 # container distro (usually Debian)
|
||||
ls -la /usr/local/lib/pi-devbox/ # which devbox helpers exist
|
||||
sed -n '/^Host /,$p' ~/.ssh-local/config 2>/dev/null # host/LAN reachability, if any
|
||||
mount | grep -E ' /workspace | /home/\S+/\.ssh ' # what's bind-mounted
|
||||
```
|
||||
|
||||
## 1. Persistence vs ephemerality — know before you write
|
||||
|
||||
The container has **three storage tiers with very different lifetimes**. Pick
|
||||
the right one or work is silently lost on the next recreate/update.
|
||||
|
||||
| Tier | Examples | Survives `down`? | Survives `down -v`? | Survives image update / `--force-recreate`? |
|
||||
|---|---|---|---|---|
|
||||
| **Host bind-mount** | `/workspace`, usually `~/.ssh` (ro), often `~/.mempalace` | yes | yes (lives on host) | yes |
|
||||
| **Named volume** | `~/.pi`, `~/.ssh-local`, `~/.cache/bash`, `~/.local/share/{uv,nvim,zoxide}` | yes | **no** | yes |
|
||||
| **Writable container layer** | anything else: `sudo apt install …`, `rustup`/`ghc`/`R` toolchains, files in `/tmp`, `/opt` edits | yes | **no** | **no** |
|
||||
|
||||
Practical consequences:
|
||||
|
||||
- **Durable work goes in `/workspace`** (it's the host filesystem, UID-aligned —
|
||||
what you write appears with the user's normal ownership on the host).
|
||||
- **Runtime-installed system packages and language toolchains are ephemeral.**
|
||||
If a task needs them reproducibly, it belongs in the image (Dockerfile) or a
|
||||
project manifest, not an ad-hoc `apt install`. Tell the user when you install
|
||||
something that won't survive.
|
||||
- **`~/.pi` is a named volume**, so things baked into the *image* under
|
||||
`/home/<user>/...` are **shadowed** by the volume on existing containers and
|
||||
only seen on a fresh volume. Image-owned content that must always be live
|
||||
belongs under an image path like `/usr/local/...` or `/opt/...` and is linked
|
||||
in by the entrypoint — not dropped into a home directory that a volume covers.
|
||||
|
||||
## 2. Interactive shell vs. your tool shell (a real footgun)
|
||||
|
||||
The conveniences below are defined in `~/.bash_aliases` and **only exist in an
|
||||
interactive login shell.** Your `bash` *tool* runs non-interactively, so these
|
||||
are "command not found" there — you must spell out the underlying command.
|
||||
|
||||
| Interactive alias | Non-interactive equivalent to actually run |
|
||||
|---|---|
|
||||
| `dssh <host>` | `ssh -F "$HOME/.ssh-local/config" <host>` |
|
||||
| `dscp …` | `scp -F "$HOME/.ssh-local/config" …` |
|
||||
| `cat file` (→ `bat`) | `cat file` works, but output differs; use `command cat` for raw |
|
||||
| `ll`, `la` (→ `eza`/`ls`) | `ls -lh`, `ls -lha` |
|
||||
|
||||
If a command "works in my terminal but not when the agent runs it," this alias
|
||||
gap is the first thing to suspect.
|
||||
|
||||
## 3. Reaching the Docker host and its LAN over SSH
|
||||
|
||||
When the host is VM-backed (e.g. OrbStack / Docker Desktop on macOS) the
|
||||
entrypoint's `setup-lan-access.sh` writes a **writable SSH sidecar** at
|
||||
`~/.ssh-local/config`. It always provides:
|
||||
|
||||
- A `Host *` block redirecting `ControlPath` into the writable `~/.ssh-local/cm`
|
||||
(because `~/.ssh` is typically bind-mounted **read-only**, so a master socket
|
||||
can't be created under it), plus `Include ~/.ssh/config`.
|
||||
- Aliases **`host` / `mac`** → `host.docker.internal` (user comes from
|
||||
`HOST_SSH_USER`) — i.e. SSH back into the Docker host.
|
||||
- On VM-backed hosts only: an **SSH-jump-via-host** block so the container can
|
||||
reach the host's directly-attached LAN peers (`ProxyJump host`). On a native
|
||||
Linux host the LAN is usually reachable directly and this jump block is
|
||||
omitted — **so don't assume a jump path exists; read the sidecar.**
|
||||
|
||||
Use it (remember §2 — spell it out in tool bash):
|
||||
|
||||
```sh
|
||||
ssh -F "$HOME/.ssh-local/config" mac 'hostname; whoami' # reach the host
|
||||
ssh -F "$HOME/.ssh-local/config" <lan-peer> '…' # reach a LAN peer (if configured)
|
||||
```
|
||||
|
||||
Two related mechanisms (don't reinvent them):
|
||||
|
||||
- **ControlMaster multiplexing** is preconfigured (`/tmp/sshcm/`) to survive
|
||||
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).
|
||||
- **`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.
|
||||
|
||||
## 4. DNS / name resolution — environment-specific, verify live
|
||||
|
||||
How a name resolves here is **not universal** and depends on the host's
|
||||
networking. The container's own resolver is just `/etc/resolv.conf`, but the
|
||||
*host* (which you reach via §3, and whose DNS the container may inherit) can use
|
||||
**split-horizon DNS** to send certain internal domains to specific nameservers
|
||||
while everything else goes to a default resolver/VPN gateway. The mechanism is
|
||||
OS-specific and **may not be present at all**:
|
||||
|
||||
- **macOS host:** per-domain files in `/etc/resolver/<domain>`, each listing
|
||||
`nameserver` lines. Reading them (over `ssh … mac`) is a fine way to learn the
|
||||
real split-DNS map — *for that one machine.*
|
||||
- **Linux host:** typically `systemd-resolved` split DNS (per-link `Domains=`
|
||||
routing) or `/etc/resolv.conf` `search`/`nameserver`.
|
||||
- **Windows host:** the NRPT (Name Resolution Policy Table) plays the per-suffix
|
||||
role; WSL2 inherits host resolution via mirrored networking + DNS tunneling.
|
||||
|
||||
Operating rules:
|
||||
|
||||
1. **Never hardcode a domain→nameserver mapping or a specific nameserver IP** —
|
||||
it is per-deployment and changes between users and even VPN states.
|
||||
2. **Verify by reading the live config**, e.g. `cat /etc/resolv.conf` in the
|
||||
container, or `ssh … mac 'cat /etc/resolver/* 2>/dev/null'` on a macOS host.
|
||||
3. **Reachability needs both DNS *and* a route.** A name resolving to an
|
||||
internal address is useless if packets to that subnet don't have a path
|
||||
(e.g. via the VPN or the §3 jump). Check both when something "resolves but
|
||||
won't connect."
|
||||
4. If you discover deployment-specific facts (a domain, a nameserver, a
|
||||
reachable peer), prefer recording them in MemPalace over baking them into
|
||||
code or this skill.
|
||||
|
||||
## 5. tmux is 0-indexed — don't change it
|
||||
|
||||
The image ships `/etc/tmux.conf` with `base-index 0` / `pane-base-index 0`
|
||||
because **pi-studio hard-codes its tmux send target to `<session>:0.0`.** If you
|
||||
(or a user `~/.tmux.conf`) set `base-index 1`, pi-studio fails with "can't find
|
||||
window: 0". Leave the indexing alone in this environment.
|
||||
|
||||
## 6. Python and other languages: uv-first, toolchains are ephemeral
|
||||
|
||||
- A system `python3` exists, but **prefer `uv`** for REPLs and project envs —
|
||||
it's installed and its store (`~/.local/share/uv`) is a persisted volume.
|
||||
- Throwaway REPL: `uv run --with ipython ipython`
|
||||
- Project env: `cd /workspace/proj && uv init && uv add <pkgs> && uv run …`
|
||||
(the `pyproject.toml` + `uv.lock` travel with the repo — the durable choice).
|
||||
- Other language toolchains (Rust via rustup, R, GHC, Clojure, Go) are
|
||||
**runtime opt-ins on the ephemeral layer** unless baked into the image — they
|
||||
do not survive `down -v` or an image update. Flag this when installing.
|
||||
|
||||
## 7. pi-studio reachability (only in the `-studio` variant)
|
||||
|
||||
Present only if `/opt/pi-studio` exists / the `studio_*` tools are in your tool
|
||||
list. pi-studio **binds to `127.0.0.1` inside the container** with no host-bind
|
||||
flag, so a plain `docker -p` publish can't reach it. Two supported paths:
|
||||
|
||||
- **Host networking** (`network_mode: host`): container loopback == host
|
||||
loopback; open the tokenized URL on the host. (Changes
|
||||
`host.docker.internal` semantics — weigh against §3 LAN jump.)
|
||||
- **`studio-expose` bridge** (`STUDIO_EXPOSE=1` or run `studio-expose &`): a
|
||||
`socat` relay from the container's external interface to its loopback, so a
|
||||
published `127.0.0.1:PORT` + `ssh -L PORT:127.0.0.1:PORT host` reaches it.
|
||||
|
||||
The real auth token comes from the `/studio` slash command (`/studio --status`
|
||||
to reprint), **not** from `studio-expose`. For Graphviz, use `dot-watch` →
|
||||
PNG (Studio renders Mermaid natively and previews PNG, but not SVG/DOT).
|
||||
|
||||
## 8. MemPalace is the shared brain
|
||||
|
||||
MemPalace data is usually a **host bind-mount**, so a pi on the host and a pi in
|
||||
this container share one palace (SQLite WAL: many readers, one writer). Use it
|
||||
to persist the deployment-specific facts this skill deliberately refuses to
|
||||
hardcode. Details are in the `mempalace` skill.
|
||||
|
||||
## Checklist before acting in this environment
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] "Resolves but won't connect"? → check route *and* DNS (§3 + §4).
|
||||
- [ ] `apt`/toolchain install? → tell the user it's ephemeral unless imaged.
|
||||
- [ ] Touching tmux indexing? → don't (§5).
|
||||
@@ -0,0 +1,298 @@
|
||||
---
|
||||
name: pi-extensions
|
||||
description: >-
|
||||
Use the pi extensions (pi-fork, pi-observational-memory, ssh-controlmaster) effectively in the pi coding agent harness. Load this skill only when running inside pi (detection - `fork` and `recall` are present in your tool list, or `pi --ssh` was used to start the session). pi-fork dispatches focused subtasks to forked agents at fast/balanced/deep effort tiers; pi-observational-memory compacts long sessions into recallable observations + reflections; ssh-controlmaster rewires pi's read/write/edit/bash tools to execute on a remote host over a multiplexed SSH connection. This skill covers tier selection, task design, boundary discipline, when to use recall, and remote-pi mechanics.
|
||||
---
|
||||
|
||||
# Pi Extensions: pi-fork, pi-observational-memory, ssh-controlmaster
|
||||
|
||||
## When to Load This Skill
|
||||
|
||||
Load only when **both** of these are true:
|
||||
|
||||
1. You are running inside the **pi coding agent harness** (not Claude Code, not opencode, not any other harness).
|
||||
2. The `fork` and/or `recall` tools appear in your available tool list, **or** the session was started with `pi --ssh ...`.
|
||||
|
||||
If you do not see those tools, this skill does not apply — skip it. Other harnesses do not have these extensions and the patterns below will not work there.
|
||||
|
||||
This skill is most useful at the start of any non-trivial session where you may need to dispatch parallel subtasks, where the conversation is likely to compact (sessions running > ~80k tokens), or where pi is operating against a remote host.
|
||||
|
||||
## Pi extension landscape (where the wiring lives)
|
||||
|
||||
Pi has **two distinct extension locations** and it's easy to look in the wrong one:
|
||||
|
||||
| Location | Mechanism | Examples |
|
||||
|---|---|---|
|
||||
| `~/.pi/agent/extensions/*.ts` (or `.ts.off`) | **Local extensions** — TypeScript files, usually symlinks into `/opt/pi-extensions/extensions/` or similar. Toggled via `/ext` slash command. | `ssh-controlmaster`, `git-checkpoint`, `notify`, `todo`, `mempalace`, `mcp-loader`, `ext-toggle`, `confirm-destructive` |
|
||||
| `~/.pi/agent/git/<host>/<owner>/<repo>/` | **Package extensions** — git-cloned npm packages registered via the `packages` array in `~/.pi/agent/settings.json`. | `pi-fork` (`github.com/elpapi42/pi-fork`), `pi-observational-memory` (`github.com/elpapi42/pi-observational-memory`, **default branch `master`** — a `main` branch does not exist, so `pi install git:...` resolves against `master`) |
|
||||
|
||||
When the user asks how to use "the X extension", **check both locations** — `find ~/.pi/agent -maxdepth 4 -name "*X*"` covers both. The `/ext` slash command shows the local-extensions list with enable/disable state. There is also a distinct skill-bundled-script category (e.g. `ci-release-watcher`'s `ssh-control-master-setup.sh`) which is **not** a pi extension at all — it's a helper script inside a skill. Don't conflate the three.
|
||||
|
||||
## Why These Extensions Belong Together
|
||||
|
||||
pi-fork and pi-observational-memory are symbiotic. **pi-fork burns context** (each fork dispatches a focused subtask whose detailed exploration would otherwise pollute your main thread). **pi-observational-memory preserves context** (when the main thread eventually compacts, observations + reflections survive the fold and can be recalled by ID). Aggressive forking only works long-term if the surviving summary is high-fidelity, and OM only earns its keep when it's preserving genuinely valuable distilled work.
|
||||
|
||||
ssh-controlmaster is orthogonal but composes cleanly: when pi is operating remotely, fork still spawns local sub-agents (each fork *itself* doesn't ssh), but their `bash`/`read`/`write`/`edit` calls do — see Part 3 caveats.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: pi-fork
|
||||
|
||||
### Effort tier mapping
|
||||
|
||||
Configured in `~/.pi/agent/settings.json` under `pi-fork.effortProfiles`. The conventional mapping is:
|
||||
|
||||
| Tier | Model | Use for |
|
||||
|---|---|---|
|
||||
| `fast` | haiku | mechanical edits, narrow lookups, file-listing, single-fact verification, simple syntactic checks |
|
||||
| `balanced` | sonnet (default) | normal exploration, implementation, testing, code review, option analysis |
|
||||
| `deep` | opus | architecture decisions, security analysis, concurrency reasoning, ambiguous debugging, high-risk reviews, runbook drafting where subtle mistakes are costly |
|
||||
|
||||
**Rule of thumb:** start at `balanced` unless you have a specific reason to go up or down. Going too cheap on a deep task wastes a fork; going too expensive on a mechanical task is just slow.
|
||||
|
||||
### When to fork vs. do it yourself
|
||||
|
||||
Fork when **any** of:
|
||||
- The task requires reading many files whose contents you don't need to keep in your main context afterwards (the fork returns a dense summary; raw file contents stay in the fork's context and are discarded).
|
||||
- You want to run multiple analyses in **parallel** (especially: comparing N options, where independent reasoning is itself a signal — see "parallel forks" below).
|
||||
- The task is well-scoped enough to specify completely up front and well-bounded enough that returning a dense report is more useful than continuing the dialogue.
|
||||
- You are about to do something that would burn a lot of tokens on tool calls (long file reads, many bash invocations) whose output you will mostly discard.
|
||||
|
||||
Don't fork when:
|
||||
- The work fits in your current context budget without crowding out what comes next.
|
||||
- The task is exploratory and you'll need to iterate based on what you find (forking turns iteration into round-trips with full task-spec rewrites).
|
||||
- You need to make decisions during the work that depend on context only the main thread has.
|
||||
|
||||
### Task design: the four things a fork brief must contain
|
||||
|
||||
1. **Verified context up front.** Do not say "go look at the codebase and figure out X". Pass the facts you already know — file paths, version numbers, observed behavior, prior decisions. The fork should be reasoning *from* context, not *finding* context. Discovery work costs the fork tokens that don't come back to you.
|
||||
2. **A specific deliverable.** "Analyze X" is too vague. "Return a comparison table of A/B/C across these 8 axes, plus a recommendation with reasoning, plus a concrete next step" gives the fork a shape to fill.
|
||||
3. **Decision authority.** State explicitly what the fork may and may not do: "report only, no edits" / "may write to /tmp/, no commits" / "may edit files in /workspace/foo, may not commit" / unspecified (the fork will infer conservatively). **State this even when it seems obvious.** See "Boundary discipline" below.
|
||||
4. **What "unsure" looks like.** Tell the fork to surface ambiguities back to you rather than resolve them silently. "Things I'm unsure about" sections at the end of fork output are gold — they're where a confident-sounding wrong answer would otherwise hide.
|
||||
|
||||
### Parallel forks for option-comparison
|
||||
|
||||
When facing a "which approach should we take" question with 2–4 candidate approaches, dispatching the candidates as parallel forks is high-leverage:
|
||||
|
||||
- They reason **independently**. No fork sees the others' work.
|
||||
- **Convergence is signal.** If three forks at different effort tiers reach the same recommendation citing different evidence, that's a strong validation that doesn't depend on any one model's bias.
|
||||
- **Divergence is also signal.** If one disagrees, read its reasoning carefully — it may have spotted something the others missed, or it may have a tier-specific weakness worth knowing.
|
||||
|
||||
Sample shape for an option-comparison call:
|
||||
- Fork 1 (deep) — detailed runbook for option A, with timing/risk/rollback
|
||||
- Fork 2 (balanced) — comparison table A vs B vs C across N axes, with a recommendation
|
||||
- Fork 3 (fast) — focused sub-question (e.g., "which container image / library version / CLI flag")
|
||||
|
||||
This costs more than a single fork but the cross-validation is often worth it for decisions you'll execute on prod systems.
|
||||
|
||||
### Boundary discipline (observed behavior)
|
||||
|
||||
Forks **mostly** honor explicit decision-authority instructions, but not infallibly. Observed pattern from real sessions:
|
||||
|
||||
- **Pure analysis tasks** (no write authority, "report only") — high compliance. Forks reliably return analysis without editing files or committing.
|
||||
- **Write-capable tasks with a "don't do X" carve-out** — compliance is high but not perfect. Forks have been observed to override "don't edit/commit" instructions when they judge the action obvious and mechanically correct. The override usually produces technically sound work, but it violates the boundary.
|
||||
|
||||
**Practical rules:**
|
||||
- State decision authority explicitly, every time, even when "report only" feels redundant.
|
||||
- For high-stakes write authority, verify the fork's actions afterwards (`git status`, `git log -1`, file diffs) rather than assuming compliance.
|
||||
- If a boundary violation is unacceptable (e.g., compliance review, sandboxed exploration, "don't touch prod"), do not give the fork write tools at all — keep it strictly in analysis mode.
|
||||
- The fact that the fork was "right anyway" is not the same as the fork having followed instructions.
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- **Forking trivial work.** A fork has overhead. If the task takes < 30 seconds in your main thread, just do it.
|
||||
- **Vague briefs.** "Look into the database thing" returns vague output. The fork is not telepathic.
|
||||
- **Forking iterative work.** Forks are one-shot. If you need to iterate, you'll re-spec the task each time — usually worse than doing it yourself.
|
||||
- **Recursive forking** (forks spawning forks). Disabled by default and should stay disabled unless you have a specific batch-fanout use case.
|
||||
- **Treating fork output as ground truth without verification.** Especially for cited code/commit hashes/URLs — forks can hallucinate these like any LLM. Spot-check decisive evidence.
|
||||
|
||||
---
|
||||
|
||||
## Part 2: pi-observational-memory
|
||||
|
||||
### How it actually works
|
||||
|
||||
Observational memory (OM v3, "session-ledger" architecture) runs an **observer agent** in the background as your conversation grows. When token thresholds are crossed (defaults: observe at 10k, reflect at 20k, compact at 81k), the observer distills the recent transcript into:
|
||||
|
||||
- **Observations** — timestamped events, each with a 12-character hex ID like `[3682ebfad7af]`. Compact one-liners describing what happened in the conversation.
|
||||
- **Reflections** — durable, long-lived facts about the user, project, decisions, and constraints. Some reflections include observation IDs as evidence pointers.
|
||||
|
||||
When compaction fires, the raw transcript is folded away and replaced with a structured summary block containing the observations + reflections. **You — the next turn of the same agent — receive that summary block as your starting context.** That's the recovery mechanism.
|
||||
|
||||
**Storage is in-transcript, not on disk.** Do not grep for `observations.jsonl` or similar files; you will not find them. The artifact lives in the model's input context window.
|
||||
|
||||
Configuration lives in `~/.pi/agent/settings.json` under `observational-memory`. Tune `observeAfterTokens`, `reflectAfterTokens`, `compactAfterTokens`, and `observationsPoolMaxTokens` if observations feel sparse or noisy. The default 81k compaction threshold is well-calibrated for typical multi-task sessions.
|
||||
|
||||
### The `recall` tool
|
||||
|
||||
`recall(<12-char-hex-id>)` resolves a specific observation or reflection ID back to the original source context — the exact bash output, file contents, tool call results, commit message, or transcript fragment that the observation was distilled from.
|
||||
|
||||
**Use recall when:**
|
||||
- You are about to make a decision that depends materially on a compacted observation or reflection whose details are unclear.
|
||||
- You need exact wording, paths, commands, errors, commits, or user constraints behind a remembered claim.
|
||||
- A broad reflection is relevant but you need its supporting observations to act safely.
|
||||
- The user asks "why do you believe X" or "what supports that memory".
|
||||
|
||||
**Do not use recall for:**
|
||||
- Semantic search (it's keyed by ID, not topic — you must already have a specific 12-char hex ID).
|
||||
- Browsing the transcript out of curiosity.
|
||||
- Preemptive lookup of every ID in your context "just in case".
|
||||
|
||||
Recall costs tokens. Use it when exact source context will materially change your next action.
|
||||
|
||||
> **Calibration note (from a real ~1-month trial, 2026-05/06):** across 20 logged container sessions, `recall` was invoked **0 times** while obsmem passively carried 529 observations across 6 compactions. Zero recall is a *warning sign*, not a badge of efficiency — it means decisions after a compaction were made on the distilled one-liner alone, without ever re-checking the source. The injected summary is **lossy by design**. Default habit to adopt: when you are about to **edit code, ship a change, or assert a fact** that rests on a `[high]`/`[critical]` observation or a reflection you did not produce *this* turn, `recall` its ID **first**. One recall before a load-bearing action is cheap; redoing finished work or contradicting a prior correction is not.
|
||||
|
||||
### Reading the compaction summary
|
||||
|
||||
When you see a block like `The conversation history before this point was compacted into the following summary:` at the start of a session or turn, that's OM output. Standard structure:
|
||||
|
||||
- **Reflections** at the top: stable facts. Some have IDs in brackets.
|
||||
- **Observations** below, chronological: timestamped events with IDs in brackets and importance markers (`[high]`, `[critical]`, etc.).
|
||||
|
||||
When entries conflict, **the most recent observation reflects the latest known state.** Work that prior observations describe as completed should not be redone unless the user explicitly asks to revisit it.
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- **Treating compacted memory as definitive without recall** when stakes are high. Compaction is lossy; the observation may have lost a constraint that was on the line above it in the original transcript.
|
||||
- **Recalling every ID preemptively.** Wasteful. Recall on demand.
|
||||
- **Assuming the disk holds OM artifacts.** It doesn't. Don't waste time looking.
|
||||
- **Ignoring the summary block** when starting a session. It's there because the prior session was real work — read it before answering questions about past work.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```
|
||||
fork(task=..., effort=fast|balanced|deep)
|
||||
- state decision authority explicitly
|
||||
- pass verified context up front
|
||||
- specify deliverable shape
|
||||
- ask for "unsure about" section
|
||||
|
||||
recall(id=<12-char-hex>)
|
||||
- only when stakes justify the cost
|
||||
- id must already be visible in your context
|
||||
- not a search tool
|
||||
```
|
||||
|
||||
```
|
||||
~/.pi/agent/settings.json
|
||||
pi-fork.effortProfiles — model + thinking-depth per tier
|
||||
pi-fork.defaultEffort — usually "balanced"
|
||||
observational-memory.* — token thresholds, model, agentMaxTurns
|
||||
observational-memory.debugLog: true — opt-in NDJSON telemetry at
|
||||
~/.pi/agent/observational-memory/debug/<session>.ndjson (off by default)
|
||||
```
|
||||
|
||||
### Installing on a fresh machine (host)
|
||||
|
||||
These are git-sourced pi packages (pi-fork is **not** on npm). Add to the
|
||||
`packages` array in `~/.pi/agent/settings.json`, or:
|
||||
|
||||
```
|
||||
pi install git:github.com/elpapi42/pi-fork
|
||||
pi install git:github.com/elpapi42/pi-observational-memory # default branch: master (no main)
|
||||
# obsmem is also published: pi install npm:pi-observational-memory
|
||||
```
|
||||
|
||||
Restart pi after install. Enable `observational-memory.debugLog` if you want
|
||||
the next window instrumented.
|
||||
|
||||
### Evaluating usage
|
||||
|
||||
`evaluate-extension-usage.py` (bundled next to this skill) mines pi session
|
||||
transcripts for fork/recall counts and obsmem compaction stats. Run it per
|
||||
machine (transcripts live at `~/.pi/agent/sessions/`) for a combined
|
||||
host+container picture:
|
||||
|
||||
```
|
||||
./evaluate-extension-usage.py # ~/.pi/agent/sessions
|
||||
./evaluate-extension-usage.py /path/a /path/b # multiple roots
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3: ssh-controlmaster
|
||||
|
||||
### What it does
|
||||
|
||||
When pi is launched with `--ssh`, this extension **rewires pi's `read`, `write`, `edit`, and `bash` tools to execute on the remote machine**, multiplexed over a single SSH ControlMaster socket. Pi is still running locally — the LLM, the UI, the MCP servers, the fork dispatcher all live on your local box — but anything those tools touch on the filesystem is the *remote's* filesystem.
|
||||
|
||||
This is fundamentally different from running pi locally and using `bash` to ssh inside it: with `--ssh`, the tool layer itself is remoted, so the LLM thinks it's working in the remote's `cwd` (the system prompt is rewritten to say so).
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Key-based auth (preferred), remote cwd defaults to remote $HOME
|
||||
pi --ssh lagret
|
||||
|
||||
# Pin to a specific remote directory
|
||||
pi --ssh lagret:/volume1/docker/portainer/compose/119
|
||||
|
||||
# Password auth (input is NOT masked when typing)
|
||||
pi --ssh user@host --ssh-ask-pass
|
||||
```
|
||||
|
||||
The `lagret` form requires a `Host lagret` block in `~/.ssh/config` or a resolvable hostname. The status bar shows `SSH ⚡ own master <host>:<cwd>` or `SSH ⚡ system master <host>:<cwd>` once connected.
|
||||
|
||||
### How it cooperates with system SSH config
|
||||
|
||||
It reads `ssh -G <host>` to learn the effective config, then:
|
||||
|
||||
| `~/.ssh/config` for the host | Behavior |
|
||||
|---|---|
|
||||
| `ControlMaster auto` or `yes` with a `ControlPath` | Reuses the system master socket. Does **not** tear it down on pi exit ("it was the system's to manage before pi arrived"). |
|
||||
| No ControlMaster configured (or explicitly `no`) | Creates its own master at `/tmp/pi-cm-<pid>.sock` with `ControlPersist=yes`. Tears it down on pi `session_shutdown`. |
|
||||
|
||||
This means it composes cleanly with the system-wide `ssh-control-master-setup.sh` helper from the `ci-release-watcher` skill: if that script has already configured `~/.ssh/config` for the host, `pi --ssh` rides on the existing master rather than opening a parallel connection.
|
||||
|
||||
### Caveats and edge cases
|
||||
|
||||
- **Local vs remote tool boundary.** Only `read`/`write`/`edit`/`bash` are remoted. **MCP servers are still local** — `mempalace` files drawers and diary entries against the local palace even when your shell work happens remotely. Same for `fork`, `recall`, `todo`, and any other custom tool. This is usually what you want (palace memory survives across remote sessions) but worth knowing.
|
||||
- **fork over ssh.** Forks spawn locally and inherit the same `--ssh` mode by virtue of the parent's tool wiring; the fork's bash calls hit the same ControlMaster. Forks burn the same SSH socket, not a parallel one — multiplexing wins again.
|
||||
- **macOS Unix socket path limit.** The own-master socket lives at `/tmp/pi-cm-<pid>.sock` to stay under macOS's ~104-char limit. If you have a non-default `TMPDIR` long enough to blow this, ssh will fail to start the master.
|
||||
- **Password auth password visibility.** From the source: *"input is NOT masked — the password is visible while typing."* The password is written to a chmod-700 SSH_ASKPASS script in `/tmp` and deleted after the master establishes; not persisted, but on-screen during entry.
|
||||
- **Remote bash environment.** The remote shell is whatever `ssh user@host '<cmd>'` invokes — typically a non-login non-interactive bash. Don't expect `~/.bashrc` aliases or PATH manipulations from `~/.profile`. Pin tool paths or invoke via `bash -lc '...'` if you need login-shell behavior.
|
||||
- **Path translation is naive.** The extension does `path.replace(localCwd, remoteCwd)` to translate paths in tool calls. If the LLM emits an absolute remote path that doesn't share the local-cwd prefix, the path is passed through unchanged — usually fine but pathological for paths that happen to contain the local-cwd substring.
|
||||
|
||||
### When to use it
|
||||
|
||||
- Editing configs on a NAS / homelab host without scp ping-pong (`pi --ssh lagret:/volume1/...`)
|
||||
- Operating against a host whose tools/data you need but whose disk is too slow to mount via SSHFS
|
||||
- Investigating runner state, container configs, etc., on a remote host as if local
|
||||
- Multi-step remote work where opening a fresh ssh connection per step would burn your CGNAT flow budget
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- **Using `pi --ssh` for one-off shell work.** Just `ssh` directly. The extension shines when there are dozens of tool calls per session.
|
||||
- **Filing palace drawers expecting them on the remote.** They go to the local palace. If you want palace artifacts on the remote host, ssh into the remote and run pi *there* against its local palace.
|
||||
- **Forgetting `--ssh` in followup sessions.** Status bar is the canary — if you don't see `SSH ⚡` you're operating locally despite intending remote. Easy mistake on a fresh terminal.
|
||||
|
||||
### Reaching the devbox host from inside the container (`dssh` / `dscp`)
|
||||
|
||||
Distinct from `pi --ssh` above. When the **pi-devbox container** runs under OrbStack / Docker Desktop on macOS, it can SSH back to its own host. The entrypoint's `setup-lan-access.sh` regenerates `~/.ssh-local/config` on **every container start** (the in-container `~/.ssh` is mounted read-only, so a sidecar config + `known_hosts` + `ControlPath` under `~/.ssh-local/` is used instead).
|
||||
|
||||
```bash
|
||||
# Interactive shells get aliases (from ~/.bash_aliases):
|
||||
dssh host 'cmd' # = ssh -F ~/.ssh-local/config host
|
||||
dscp file host:/path # = scp -F ~/.ssh-local/config ...
|
||||
```
|
||||
|
||||
**The agent's `bash` tool is non-interactive — those aliases are NOT loaded.** Use the explicit form:
|
||||
|
||||
```bash
|
||||
ssh -F ~/.ssh-local/config host 'cmd'
|
||||
scp -F ~/.ssh-local/config <src> host:<dst>
|
||||
```
|
||||
|
||||
- Host aliases `host` and `mac` both resolve to `host.docker.internal` (user varies per host machine — check `~/.ssh-local/config` for the active `User` value, key `~/.ssh-local/devbox_jump_ed25519`, `ControlMaster auto` / `ControlPersist 4h`).
|
||||
- The config chains `Include ~/.config/devbox-shell/ssh-lan.conf` then `Include ~/.ssh/config`, so LAN targets are reachable too (add `ProxyJump host` to those entries).
|
||||
- **Use it for:** enabling/inspecting the host's pi config (`~/.pi/agent/settings.json`), running `evaluate-extension-usage.py` against the host's `~/.pi/agent/sessions/` for a combined host+container metric, or copying host transcripts into the container. The host's pi runs natively there; its palace, sessions, and extensions are separate from the container's.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Skill Notes
|
||||
|
||||
- **mempalace** is for cross-session persistent memory (diary, knowledge graph, drawer storage). OM is for **within-session** context survival across compaction. They complement each other: write a diary entry at session end *and* let OM compact your work-in-progress mid-session.
|
||||
- **systematic-debugging** and **test-driven-development** skills pair well with deep-tier forks: a deep fork can carry out a focused debugging investigation or write a failing test suite without polluting your main context.
|
||||
- **ci-release-watcher** ships a `scripts/ssh-control-master-setup.sh` helper that configures system-wide SSH ControlMaster in `~/.ssh/config`. That's a separate mechanism from the `ssh-controlmaster` pi extension — they compose, they don't overlap. Use the script for persistent host-wide multiplexing, the extension for per-pi-session remote operation.
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Evaluate pi-fork / pi-observational-memory usage from pi session transcripts.
|
||||
|
||||
Mines pi's session .jsonl transcripts and reports:
|
||||
- per-tool call counts (highlighting `fork` and `recall`)
|
||||
- per-session fork/recall breakdown
|
||||
- obsmem passive activity: compaction events, observations carried,
|
||||
relevance-tier distribution, tokensBefore
|
||||
|
||||
Works on any machine. Point it at one or more session roots; by default it
|
||||
scans ~/.pi/agent/sessions (the standard pi location, host or container).
|
||||
|
||||
Usage:
|
||||
./evaluate-extension-usage.py # ~/.pi/agent/sessions
|
||||
./evaluate-extension-usage.py /path/to/sessions ... # explicit roots
|
||||
./evaluate-extension-usage.py --host HOST /path ... # label a root (for combined host+container runs)
|
||||
|
||||
For a true host+container picture, run once per machine (or copy each
|
||||
machine's ~/.pi/agent/sessions here) and pass all roots together.
|
||||
"""
|
||||
import json, sys, os, glob, re, collections, argparse
|
||||
|
||||
TIER_RE = re.compile(r'\[(low|medium|high|critical)\]')
|
||||
OBS_LINE_RE = re.compile(r'^\[[0-9a-f]{12}\] ', re.M)
|
||||
|
||||
|
||||
def walk_tools(x, counter):
|
||||
if isinstance(x, dict):
|
||||
tn = x.get("toolName")
|
||||
if tn:
|
||||
counter[tn] += 1
|
||||
for v in x.values():
|
||||
walk_tools(v, counter)
|
||||
elif isinstance(x, list):
|
||||
for v in x:
|
||||
walk_tools(v, counter)
|
||||
|
||||
|
||||
def analyze(roots):
|
||||
files = []
|
||||
for r in roots:
|
||||
if os.path.isfile(r) and r.endswith(".jsonl"):
|
||||
files.append(r)
|
||||
else:
|
||||
files += glob.glob(os.path.join(r, "**", "*.jsonl"), recursive=True)
|
||||
files = sorted(set(files))
|
||||
|
||||
tool_total = collections.Counter()
|
||||
per_session = []
|
||||
compactions = []
|
||||
for f in files:
|
||||
tc = collections.Counter()
|
||||
with open(f, errors="ignore") as fh:
|
||||
for ln in fh:
|
||||
ln = ln.strip()
|
||||
if not ln:
|
||||
continue
|
||||
try:
|
||||
o = json.loads(ln)
|
||||
except Exception:
|
||||
continue
|
||||
walk_tools(o, tc)
|
||||
if o.get("type") == "compaction":
|
||||
s = o.get("summary", "") or ""
|
||||
compactions.append({
|
||||
"file": os.path.basename(f),
|
||||
"tokensBefore": o.get("tokensBefore"),
|
||||
"observations": len(OBS_LINE_RE.findall(s)),
|
||||
"tiers": dict(collections.Counter(TIER_RE.findall(s))),
|
||||
})
|
||||
tool_total.update(tc)
|
||||
per_session.append((os.path.basename(f)[:10], tc.get("fork", 0),
|
||||
tc.get("recall", 0), sum(tc.values())))
|
||||
return files, tool_total, per_session, compactions
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("roots", nargs="*",
|
||||
default=[os.path.expanduser("~/.pi/agent/sessions")])
|
||||
args = ap.parse_args()
|
||||
|
||||
files, tool_total, per_session, comp = analyze(args.roots)
|
||||
if not files:
|
||||
print("No .jsonl transcripts found under:", args.roots, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"=== {len(files)} transcripts under {args.roots} ===\n")
|
||||
print("Tool call totals:")
|
||||
for t, c in tool_total.most_common():
|
||||
mark = " <== pi-fork" if t == "fork" else (" <== obsmem recall" if t == "recall" else "")
|
||||
print(f" {c:6d} {t}{mark}")
|
||||
|
||||
fk = tool_total["fork"]; rc = tool_total["recall"]
|
||||
fk_sess = sum(1 for p in per_session if p[1])
|
||||
rc_sess = sum(1 for p in per_session if p[2])
|
||||
print(f"\npi-fork: {fk} calls across {fk_sess} sessions")
|
||||
print(f"recall: {rc} calls across {rc_sess} sessions"
|
||||
+ (" (!) zero recall over the window — see SKILL.md calibration note" if rc == 0 else ""))
|
||||
|
||||
if comp:
|
||||
tot_obs = sum(c["observations"] for c in comp)
|
||||
tb = [c["tokensBefore"] for c in comp if c["tokensBefore"]]
|
||||
print(f"\nobsmem passive: {len(comp)} compactions, {tot_obs} observations carried"
|
||||
+ (f", avg tokensBefore {sum(tb)//len(tb):,}" if tb else ""))
|
||||
agg = collections.Counter()
|
||||
for c in comp:
|
||||
agg.update(c["tiers"])
|
||||
if agg:
|
||||
print(" relevance tiers:", dict(agg))
|
||||
else:
|
||||
print("\nobsmem passive: no compaction events found "
|
||||
"(short sessions, or obsmem not active on these transcripts)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-base-hash.sh — guard the base-rebuild invariant.
|
||||
#
|
||||
# Every floating `ARG *_REF` consumed by Dockerfile.base MUST be folded
|
||||
# into the base_tag hash in the docker-publish workflow. Otherwise a
|
||||
# ref-only change to that dependency does not change the base hash, the
|
||||
# Docker Hub probe finds the old base tag, and the base is NOT rebuilt —
|
||||
# the dependency fix silently fails to land. This is the v1.1.2-class
|
||||
# staleness footgun (then it was mempalace-toolkit; this guard stops the
|
||||
# next one before it ships).
|
||||
#
|
||||
# Runs in CI (base-decide job) and locally: bash scripts/check-base-hash.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
WF=".gitea/workflows/docker-publish.yml"
|
||||
DF="Dockerfile.base"
|
||||
|
||||
# Extract the hash-compute block: the `HASH=$( … ) | sha256sum | cut`
|
||||
# brace-group in the "Compute base tag" step. This lives in a separate
|
||||
# file from the workflow, so scanning $WF here is free of the self-match
|
||||
# hazard an inline workflow step would have.
|
||||
block=$(awk '/HASH=\$\(/{f=1} f{print} f && /cut -c1-12/{exit}' "$WF")
|
||||
if [ -z "$block" ]; then
|
||||
echo "::error::could not locate the HASH=\$( … ) | sha256sum block in $WF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
refs=$(grep -oE '^ARG [A-Z0-9_]+_REF' "$DF" | awk '{print $2}' | sort -u)
|
||||
fail=0
|
||||
for r in $refs; do
|
||||
lc=$(printf '%s' "$r" | tr '[:upper:]' '[:lower:]')
|
||||
if ! printf '%s' "$block" | grep -q "outputs.$lc"; then
|
||||
echo "::error::Dockerfile.base declares '$r' but it is NOT folded into the base_tag hash in $WF."
|
||||
echo "::error::Add echo \"\${{ needs.resolve-versions.outputs.$lc }}\" inside the HASH=\$( … ) | sha256sum block, or a $r-only change will silently fail to rebuild the base."
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$fail" = 0 ]; then
|
||||
echo "OK: all Dockerfile.base *_REF args are folded into base_tag (${refs:-none})."
|
||||
fi
|
||||
exit $fail
|
||||
Executable
+303
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runtime post-recreate verification for pi-devbox.
|
||||
#
|
||||
# Verifies that after `docker compose up -d --force-recreate`:
|
||||
# - The new image is actually live (pi version matches, when an expected
|
||||
# version is supplied — see the version note below)
|
||||
# - Persisted named volumes survived (~/.pi config, shell history, zoxide,
|
||||
# nvim data, uv cache, ssh-local)
|
||||
# - pi runtime wiring is intact: keybindings symlink, AGENTS.md symlink,
|
||||
# ≥4 extensions, the mempalace.ts bridge, settings.json, and the pi-fork /
|
||||
# pi-observational-memory / (studio variant) pi-studio package registrations
|
||||
# - Shell defaults re-seeded from /etc/skel-devbox
|
||||
# - /tmp/sshcm exists with mode 700 (ssh ControlMaster dir)
|
||||
# - /opt toolkits intact
|
||||
# - Known expected-absences don't regress
|
||||
#
|
||||
# This is repo/maintainer tooling — the runtime peer of smoke-test.sh.
|
||||
# smoke-test.sh runs at BUILD time with `--entrypoint=""`, so it can never see
|
||||
# a recreated container's persisted volumes or the entrypoint's runtime
|
||||
# deploy. This script is its runtime counterpart: it inspects what is actually
|
||||
# live in the container you are sitting in after a recreate.
|
||||
#
|
||||
# It is NOT baked into the published Docker Hub image; run it from a checkout of
|
||||
# the pi-devbox repo (which a maintainer already has for CI builds). A plain
|
||||
# `docker pull` consumer is not the audience and will not have this file.
|
||||
#
|
||||
# Version note: pi's version is resolved from `latest` at CI build time and is
|
||||
# NOT pinned to a concrete value in Dockerfile.variant (ARG PI_VERSION=latest).
|
||||
# So unlike opencode-devbox, this script cannot self-derive an expected version
|
||||
# from the Dockerfile. Pass --expected-version to assert a match; without it the
|
||||
# live pi version is reported as an informational WARN, not a failure.
|
||||
#
|
||||
# Usage: ./scripts/recreate-sanity-check.sh [--expected-version X.Y.Z] [--variant studio|plain]
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 all checks passed
|
||||
# 1 one or more checks failed
|
||||
# 2 usage error
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
EXPECTED_VERSION=""
|
||||
VARIANT=""
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--expected-version)
|
||||
EXPECTED_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--variant)
|
||||
VARIANT="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 [--expected-version X.Y.Z] [--variant studio|plain]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
FAILED=0
|
||||
pass() { echo " ✓ $1"; }
|
||||
fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); }
|
||||
warn() { echo " ⚠ $1" >&2; }
|
||||
|
||||
# Auto-detect variant if not provided. The studio variant vendors pi-studio to
|
||||
# /opt/pi-studio; the plain variant does not.
|
||||
if [ -z "$VARIANT" ]; then
|
||||
if [ -d /opt/pi-studio ]; then
|
||||
VARIANT="studio"
|
||||
else
|
||||
VARIANT="plain"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Print header with git context
|
||||
echo "=== Recreate sanity check (variant: $VARIANT) ==="
|
||||
if GIT_TAG=$(git -C "$REPO_DIR" describe --tags 2>/dev/null); then
|
||||
echo " Repo HEAD: $GIT_TAG (version-match only meaningful when image tag matches)"
|
||||
else
|
||||
echo " Repo HEAD: (not a git repo or no tags)"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "-- pi version --"
|
||||
if ACTUAL_VERSION=$(pi --version 2>&1 | head -1); then
|
||||
if [ -n "$EXPECTED_VERSION" ]; then
|
||||
if [ "$ACTUAL_VERSION" = "$EXPECTED_VERSION" ]; then
|
||||
pass "pi version $ACTUAL_VERSION"
|
||||
else
|
||||
fail "pi version mismatch: expected $EXPECTED_VERSION, got $ACTUAL_VERSION"
|
||||
fi
|
||||
else
|
||||
warn "pi version $ACTUAL_VERSION (no --expected-version given; pi is built from 'latest', cannot self-derive — informational only)"
|
||||
fi
|
||||
else
|
||||
fail "pi --version failed"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Persisted named volumes (must survive --force-recreate) --"
|
||||
|
||||
# ~/.pi config volume (devbox-pi-config) — holds agent settings, extensions,
|
||||
# keybindings symlink. Must exist and be non-empty after recreate.
|
||||
if [ -d "$HOME/.pi/agent" ] && [ -n "$(ls -A "$HOME/.pi/agent" 2>/dev/null)" ]; then
|
||||
pass "~/.pi/agent exists and is non-empty"
|
||||
else
|
||||
fail "~/.pi/agent missing or empty"
|
||||
fi
|
||||
|
||||
# shell history volume (devbox-shell-history). An empty .bash_history right
|
||||
# after recreate is NORMAL — only the mount point must exist.
|
||||
if [ -d "$HOME/.cache/bash" ]; then
|
||||
pass "~/.cache/bash exists as directory"
|
||||
else
|
||||
fail "~/.cache/bash missing or not a directory"
|
||||
fi
|
||||
|
||||
# remaining persisted volumes — mount points must exist
|
||||
for vol_path in \
|
||||
"$HOME/.local/share/zoxide" \
|
||||
"$HOME/.local/share/nvim" \
|
||||
"$HOME/.local/share/uv" \
|
||||
"$HOME/.ssh-local"; do
|
||||
if [ -d "$vol_path" ]; then
|
||||
pass "$vol_path exists"
|
||||
else
|
||||
fail "$vol_path missing or not a directory"
|
||||
fi
|
||||
done
|
||||
|
||||
# mempalace palace — CONDITIONAL. In this repo's docker-compose.yml the
|
||||
# devbox-palace named volume is commented out; the palace is reached via the
|
||||
# shared /workspace (virtiofs) path instead. So absence of a local palace dir
|
||||
# is NOT a recreate regression here.
|
||||
if [ -f "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
|
||||
SIZE=$(du -h "$HOME/.mempalace/palace/chroma.sqlite3" | cut -f1)
|
||||
if [ -s "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
|
||||
pass "~/.mempalace/palace/chroma.sqlite3 exists ($SIZE)"
|
||||
else
|
||||
fail "~/.mempalace/palace/chroma.sqlite3 exists but is empty"
|
||||
fi
|
||||
else
|
||||
warn "~/.mempalace/palace/chroma.sqlite3 absent — expected unless devbox-palace volume is enabled (palace is shared via /workspace by default)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- pi runtime wiring (deployed by entrypoint-user.sh) --"
|
||||
|
||||
# keybindings symlink (pi-toolkit)
|
||||
if [ -L "$HOME/.pi/agent/keybindings.json" ]; then
|
||||
pass "~/.pi/agent/keybindings.json symlink (pi-toolkit)"
|
||||
else
|
||||
fail "~/.pi/agent/keybindings.json missing or not a symlink"
|
||||
fi
|
||||
|
||||
# global AGENTS.md symlink (pi-toolkit) — global instructions loaded by pi at
|
||||
# every start (directs the agent to read the pi-extensions skill at session start)
|
||||
if [ -L "$HOME/.pi/agent/AGENTS.md" ]; then
|
||||
pass "~/.pi/agent/AGENTS.md symlink (pi-toolkit)"
|
||||
else
|
||||
fail "~/.pi/agent/AGENTS.md missing or not a symlink"
|
||||
fi
|
||||
|
||||
# extensions deployed (pi-extensions) — expect ≥4 *.ts
|
||||
EXT_COUNT=$(ls -1 "$HOME"/.pi/agent/extensions/*.ts 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$EXT_COUNT" -ge 4 ]; then
|
||||
pass "$EXT_COUNT extensions deployed (≥4, pi-extensions)"
|
||||
else
|
||||
fail "only $EXT_COUNT extensions deployed (expected ≥4)"
|
||||
fi
|
||||
|
||||
# mempalace.ts bridge symlink
|
||||
if [ -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
|
||||
pass "~/.pi/agent/extensions/mempalace.ts bridge symlink"
|
||||
else
|
||||
fail "~/.pi/agent/extensions/mempalace.ts missing or not a symlink"
|
||||
fi
|
||||
|
||||
# settings.json bootstrapped
|
||||
if [ -f "$HOME/.pi/agent/settings.json" ]; then
|
||||
pass "~/.pi/agent/settings.json bootstrapped"
|
||||
else
|
||||
fail "~/.pi/agent/settings.json missing"
|
||||
fi
|
||||
|
||||
# settings.json merge: the entrypoint deep-merges new template keys into a
|
||||
# preserved settings.json on every start, so config added in an image upgrade
|
||||
# (e.g. the observational-memory / pi-fork blocks) reaches existing volumes.
|
||||
# Assert those blocks are present and that the file is still valid JSON.
|
||||
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.pi/agent/settings.json" ]; then
|
||||
if jq -e 'has("observational-memory") and has("pi-fork")' "$HOME/.pi/agent/settings.json" >/dev/null 2>&1; then
|
||||
pass "settings.json has observational-memory + pi-fork blocks (template merge)"
|
||||
else
|
||||
fail "settings.json missing observational-memory and/or pi-fork blocks (template merge did not land)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# pi package registrations (pi install <local-path> → recorded in settings.json)
|
||||
if [ -f "$HOME/.pi/agent/settings.json" ]; then
|
||||
for pkg in pi-fork pi-observational-memory; do
|
||||
if grep -q "$pkg" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
|
||||
pass "$pkg registered in settings.json"
|
||||
else
|
||||
fail "$pkg not registered in settings.json"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$VARIANT" = "studio" ]; then
|
||||
if grep -q "pi-studio" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
|
||||
pass "pi-studio registered in settings.json"
|
||||
else
|
||||
fail "pi-studio not registered in settings.json (studio variant)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- ssh ControlMaster dir --"
|
||||
if [ -d /tmp/sshcm ] && [ "$(stat -c %a /tmp/sshcm 2>/dev/null)" = "700" ]; then
|
||||
pass "/tmp/sshcm exists with mode 700"
|
||||
else
|
||||
fail "/tmp/sshcm missing or not mode 700"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Shell defaults re-seeded from /etc/skel-devbox --"
|
||||
if [ -f "$HOME/.bash_aliases" ]; then
|
||||
pass "~/.bash_aliases exists"
|
||||
else
|
||||
fail "~/.bash_aliases missing"
|
||||
fi
|
||||
|
||||
# History flush must survive shell nesting. The DEVBOX_HIST_SET guard must NOT
|
||||
# be exported: if it leaks into child processes, nested shells (esp. tmux
|
||||
# panes) skip installing `history -a` and lose in-memory history on abrupt
|
||||
# termination. Assert a child login shell still wires up the per-prompt flush.
|
||||
if bash -lic 'bash -lic "case \"\$PROMPT_COMMAND\" in *\"history -a\"*) exit 0;; *) exit 1;; esac"' </dev/null >/dev/null 2>&1; then
|
||||
pass "nested shell installs 'history -a' (DEVBOX_HIST_SET not exported)"
|
||||
else
|
||||
fail "nested shell missing 'history -a' — DEVBOX_HIST_SET leaking to children?"
|
||||
fi
|
||||
|
||||
if [ -f "$HOME/.inputrc" ]; then
|
||||
pass "~/.inputrc exists"
|
||||
else
|
||||
fail "~/.inputrc missing"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- cli_utils bind-mount --"
|
||||
if [ -d /workspace/cli_utils ] && [ -d /workspace/cli_utils/.git ]; then
|
||||
pass "/workspace/cli_utils exists with .git subdir"
|
||||
else
|
||||
warn "/workspace/cli_utils missing or .git subdir absent — expected only if cli_utils is bind-mounted"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Baked /opt toolkits --"
|
||||
for opt_path in /opt/pi-toolkit /opt/pi-extensions /opt/pi-fork /opt/pi-observational-memory /opt/mempalace-toolkit; do
|
||||
if [ -d "$opt_path" ]; then
|
||||
pass "$opt_path exists"
|
||||
else
|
||||
fail "$opt_path missing"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$VARIANT" = "studio" ]; then
|
||||
if [ -d /opt/pi-studio ] && [ -f /opt/pi-studio/client/studio-client.js ]; then
|
||||
pass "/opt/pi-studio exists with prebuilt client bundle"
|
||||
else
|
||||
fail "/opt/pi-studio missing or prebuilt client bundle absent (studio variant)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# mempalace MCP entrypoint on PATH
|
||||
if command -v mempalace-mcp >/dev/null 2>&1; then
|
||||
pass "mempalace-mcp on PATH"
|
||||
else
|
||||
fail "mempalace-mcp not on PATH"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Known expected-absences (regressions vs by-design) --"
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
warn "go absent — expected unless image built with INSTALL_GO=true"
|
||||
else
|
||||
pass "go is on PATH"
|
||||
fi
|
||||
|
||||
if [ "$VARIANT" = "plain" ] && [ ! -d /opt/pi-studio ]; then
|
||||
warn "/opt/pi-studio absent — expected on the plain (non-studio) variant"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo "=== FAILED: $FAILED check(s) ===" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "=== PASSED ==="
|
||||
@@ -80,6 +80,26 @@ run "yq" "yq --version"
|
||||
run "tldr (tealdeer)" "tldr --version"
|
||||
run "socat" "socat -V"
|
||||
run "studio-expose helper" "test -x /usr/local/bin/studio-expose"
|
||||
run "image-baked pi-devbox-environment skill" \
|
||||
"test -f /usr/local/share/pi-devbox/skills/pi-devbox-environment/SKILL.md"
|
||||
run "global-AGENTS append snippet present" \
|
||||
"test -f /usr/local/share/pi-devbox/pi-global-AGENTS.append.md"
|
||||
run "pi-devbox block merged into pi-global-AGENTS.md" \
|
||||
"grep -q 'pi-devbox:managed-block' /opt/pi-toolkit/pi-global-AGENTS.md"
|
||||
run "mempalace session-start pointer merged into global AGENTS.md" \
|
||||
"grep -q 'load the mempalace skill' /opt/pi-toolkit/pi-global-AGENTS.md"
|
||||
# Vendored fallback skills (so a no-skillset container still resolves the
|
||||
# AGENTS.md 'read the pi-extensions skill' pointer).
|
||||
run "image-baked pi-extensions fallback skill" \
|
||||
"test -f /usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md"
|
||||
run "pi-extensions skill ships its helper" \
|
||||
"test -f /usr/local/share/pi-devbox/skills/pi-extensions/evaluate-extension-usage.py"
|
||||
run "image-baked mempalace fallback skill" \
|
||||
"test -f /usr/local/share/pi-devbox/skills/mempalace/SKILL.md"
|
||||
# Layered freshness: when the pinned pi-extensions clone carries the skill, the
|
||||
# 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"
|
||||
|
||||
# ── tmux 0-indexing (required for pi-studio variants) ─────────────────
|
||||
echo ""
|
||||
@@ -113,6 +133,28 @@ else
|
||||
echo " ℹ️ pi-studio not present (non-studio variant) — skipping studio clone checks"
|
||||
fi
|
||||
|
||||
# ── Build provenance (manifest + OCI labels) ─────────────────────────
|
||||
echo ""
|
||||
echo "── Build provenance ──"
|
||||
run "/etc/pi-devbox/build-manifest.json present" \
|
||||
"test -f /etc/pi-devbox/build-manifest.json"
|
||||
run_expect "manifest records pi-extensions component" \
|
||||
"cat /etc/pi-devbox/build-manifest.json" '"pi-extensions"'
|
||||
run_expect "manifest records pi_version" \
|
||||
"cat /etc/pi-devbox/build-manifest.json" '"pi_version"'
|
||||
# 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" \
|
||||
"! grep -q '\"unknown\"' /etc/pi-devbox/build-manifest.json"
|
||||
# OCI labels live in the image config, not the container fs — inspect them
|
||||
# from the host docker rather than via `docker run`.
|
||||
LBL=$(docker inspect --format '{{ index .Config.Labels "se.jordbo.pi-devbox.pi-extensions-ref" }}' "$IMAGE" 2>/dev/null || true)
|
||||
if [ -n "$LBL" ] && [ "$LBL" != "<no value>" ]; then
|
||||
printf " ✅ OCI label se.jordbo.pi-devbox.pi-extensions-ref=%s\n" "$LBL"; PASS=$((PASS+1))
|
||||
else
|
||||
printf " ❌ OCI label se.jordbo.pi-devbox.pi-extensions-ref missing or empty\n"; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
# ── Runtime deployment (needs entrypoint to run) ──────────────────────
|
||||
echo ""
|
||||
echo "── Runtime deployment ──"
|
||||
@@ -134,6 +176,9 @@ for i in $(seq 1 45); do
|
||||
if docker exec "$CID" sh -c '
|
||||
test -L /home/developer/.pi/agent/keybindings.json && \
|
||||
test -L /home/developer/.pi/agent/extensions/mempalace.ts && \
|
||||
test -L /home/developer/.agents/skills/pi-devbox-environment && \
|
||||
test -L /home/developer/.agents/skills/pi-extensions && \
|
||||
test -L /home/developer/.agents/skills/mempalace && \
|
||||
count=$(ls -1 /home/developer/.pi/agent/extensions/*.ts 2>/dev/null | wc -l) && \
|
||||
[ "$count" -ge 4 ]
|
||||
' >/dev/null 2>&1; then
|
||||
@@ -155,6 +200,9 @@ exec_test "keybindings.json (pi-toolkit)" 'test -L $HOME/.pi/agent/keybi
|
||||
exec_test "extensions ≥ 4 (pi-extensions)" 'count=$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l); [ $count -ge 4 ] && echo "$count extensions"'
|
||||
exec_test "mempalace.ts bridge" 'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok'
|
||||
exec_test "settings.json bootstrapped" 'test -f $HOME/.pi/agent/settings.json && echo ok'
|
||||
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'
|
||||
|
||||
# pi-fork + pi-observational-memory are registered by entrypoint-user.sh via
|
||||
# `pi install /opt/<pkg>`, which runs slightly after the keybindings marker.
|
||||
|
||||
Reference in New Issue
Block a user