feat: optional shared/external MemPalace + fix validate.yml false-red on base changes
Added: - generate-config.py registers mempalace as a remote MCP endpoint when MEMPALACE_REMOTE_URL is set (MEMPALACE_REMOTE_TOKEN -> Bearer), else the local stdio command as before. Same env contract as pi-devbox's bridge. - docker-compose.mempalace.yml: optional shared server (mempalace-mcp --transport http), loopback-bound by default. - compose (both) + .env.example(.shared): MEMPALACE_REMOTE_URL/TOKEN + local-vs-external docs. Fixed: - validate.yml: skip validate-base/validate-omos on base-changing commits (base-change-warning now exports base_changed). Previously a commit that changed the base AND tightened smoke-test.sh in lockstep (v2.4.0 nano/micro) hard-failed against the stale base-latest until the release rebuilt it. actionlint clean. README + CHANGELOG (Unreleased).
This commit is contained in:
@@ -31,6 +31,19 @@ WORKSPACE_PATH=~/projects
|
||||
# Path to SSH keys on host
|
||||
SSH_KEY_PATH=~/.ssh
|
||||
|
||||
# ── MemPalace memory (local by default) ───────────────────────────
|
||||
# By default each container runs its OWN MemPalace (a local stdio server;
|
||||
# palace stored at ~/.mempalace). Uncomment the devbox-palace volume in
|
||||
# docker-compose.yml to persist it across container recreation.
|
||||
#
|
||||
# To instead share ONE MemPalace across several containers / harnesses
|
||||
# (pi + opencode + native), point every container at an external HTTP
|
||||
# endpoint. When MEMPALACE_REMOTE_URL is set, no local mempalace-mcp is
|
||||
# spawned and the devbox-palace volume is irrelevant.
|
||||
# Serve it with: mempalace-mcp --transport http --host 0.0.0.0 --port 8765
|
||||
# MEMPALACE_REMOTE_URL=http://mempalace.lan:8765/mcp
|
||||
# MEMPALACE_REMOTE_TOKEN= # optional — sent as: Authorization: Bearer <token>
|
||||
|
||||
# ── LAN access from the container (host-OS-agnostic) ─────────────────
|
||||
# On VM-backed hosts (macOS OrbStack / Docker Desktop, also Docker Desktop
|
||||
# on Windows) the container runs in a Linux VM and CANNOT reach the host's
|
||||
|
||||
@@ -27,6 +27,13 @@ GIT_USER_EMAIL=your.name@example.com
|
||||
# If you have per-user keys: SSH_KEY_PATH=~/<signum>/.ssh
|
||||
# SSH_KEY_PATH=~/.ssh
|
||||
|
||||
# ── MemPalace memory ─────────────────────────────────────────────────
|
||||
# Default: each container runs its own LOCAL palace. To share ONE external
|
||||
# MemPalace across containers/harnesses, set the URL (no local server is
|
||||
# spawned when set). MEMPALACE_REMOTE_TOKEN is an optional bearer token.
|
||||
# MEMPALACE_REMOTE_URL=http://mempalace.lan:8765/mcp
|
||||
# MEMPALACE_REMOTE_TOKEN=
|
||||
|
||||
# ── Locale (defaults to en_US.UTF-8) ────────────────────────────────
|
||||
# LANG=sv_SE.UTF-8
|
||||
# LANGUAGE=sv_SE:sv
|
||||
|
||||
@@ -60,13 +60,19 @@ jobs:
|
||||
|
||||
base-change-warning:
|
||||
# Surfaces a warning when this commit changes base-image inputs
|
||||
# (Dockerfile.base, rootfs/, entrypoint*.sh). validate.yml uses
|
||||
# Hub's base-latest as the parent for variant builds, so changes to
|
||||
# those files are NOT exercised here — only release tags rebuild the
|
||||
# base via docker-publish-split.yml.
|
||||
# (Dockerfile.base, rootfs/, entrypoint*.sh) AND exports `base_changed` so
|
||||
# validate-base/validate-omos can skip. validate.yml uses Hub's base-latest
|
||||
# as the parent for variant builds, so a changed base is NOT exercised here
|
||||
# — worse, if the same commit tightens smoke-test.sh in lockstep with the
|
||||
# base change (as the nano/micro commit did), those jobs HARD-FAIL against
|
||||
# the stale base-latest: a false red that self-heals only once the release
|
||||
# rebuilds base-latest. So we skip them here and defer full base validation
|
||||
# to the release path (docker-publish-split.yml on tag push).
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
outputs:
|
||||
base_changed: ${{ steps.detect.outputs.base_changed }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -74,18 +80,23 @@ jobs:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect base-input changes
|
||||
id: detect
|
||||
run: |
|
||||
set -e
|
||||
if ! git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||
| grep -qE '^(Dockerfile\.base|rootfs/|entrypoint.*\.sh)$'; then
|
||||
echo "base_changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::This commit changes base-image inputs (Dockerfile.base, rootfs/, or entrypoint*.sh). validate.yml uses Hub's base-latest as the parent for variant builds, so the new base is NOT exercised by this workflow — validate-base/validate-omos are SKIPPED to avoid a false failure against the stale base-latest. Cut a release tag, or run a workflow_dispatch of docker-publish-split.yml against a test tag (e.g. v0.0.0-base-test, promote_latest=false) for end-to-end validation of the new base."
|
||||
echo "Changed base-input files:"
|
||||
git diff --name-only HEAD~1 HEAD | grep -E '^(Dockerfile\.base|rootfs/|entrypoint.*\.sh)$'
|
||||
else
|
||||
echo "base_changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No base-image inputs changed in this commit — validate.yml fully exercises the published base-latest."
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::This commit changes base-image inputs (Dockerfile.base, rootfs/, or entrypoint*.sh). validate.yml uses Hub's base-latest as the parent for variant builds, so the new base is NOT exercised by this workflow. Cut a release tag, or run a workflow_dispatch of docker-publish-split.yml against a test tag (e.g. v0.0.0-base-test, promote_latest=false) for end-to-end validation of the new base."
|
||||
echo "Changed base-input files:"
|
||||
git diff --name-only HEAD~1 HEAD | grep -E '^(Dockerfile\.base|rootfs/|entrypoint.*\.sh)$'
|
||||
|
||||
validate-base:
|
||||
needs: [base-change-warning]
|
||||
if: ${{ needs.base-change-warning.outputs.base_changed != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -147,6 +158,8 @@ jobs:
|
||||
bash scripts/smoke-test.sh opencode-devbox:ci-base --variant base
|
||||
|
||||
validate-omos:
|
||||
needs: [base-change-warning]
|
||||
if: ${{ needs.base-change-warning.outputs.base_changed != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
|
||||
@@ -6,6 +6,39 @@ Tags follow **independent semver** (since `v2.0.0`) — they version *this image
|
||||
|
||||
---
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- **Optional shared/external MemPalace via `MEMPALACE_REMOTE_URL`.** MemPalace
|
||||
can now be pointed at one shared HTTP endpoint instead of each container
|
||||
running its own local server. Set `MEMPALACE_REMOTE_URL=http://<host>:8765/mcp`
|
||||
(optionally `MEMPALACE_REMOTE_TOKEN`, sent as `Authorization: Bearer`) in
|
||||
`.env`; `generate-config.py` then registers the `mempalace` MCP server as a
|
||||
`remote` endpoint instead of the local `mempalace-mcp` stdio command (no local
|
||||
server is spawned, and the `devbox-palace` volume becomes irrelevant). Leaving
|
||||
it unset keeps the default local-per-container palace. Mirrors the same env
|
||||
contract as pi-devbox's `mempalace.ts` bridge, so one palace can serve pi +
|
||||
opencode + native.
|
||||
- **`docker-compose.mempalace.yml`** — an optional shared MemPalace server
|
||||
(`mempalace-mcp --transport http`) that several containers/harnesses can point
|
||||
`MEMPALACE_REMOTE_URL` at. Binds to `127.0.0.1:8765` by default (the HTTP
|
||||
transport is unauthenticated — keep it on a trusted network or behind a proxy).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`validate.yml` no longer reports a false failure on base-changing commits.**
|
||||
`validate-base`/`validate-omos` build variants from the published
|
||||
`base-latest`; when a commit changed base inputs *and* tightened
|
||||
`smoke-test.sh` in lockstep (as v2.4.0's nano/micro did), those jobs
|
||||
hard-failed against the still-stale `base-latest` until the release rebuilt it.
|
||||
The `base-change-warning` job now exports a `base_changed` output and those two
|
||||
jobs **skip** when base inputs changed, deferring full base validation to the
|
||||
release path (`docker-publish-split.yml`). The scary red becomes a neutral skip
|
||||
plus the existing warning.
|
||||
|
||||
---
|
||||
|
||||
## v2.4.0 — 2026-07-01
|
||||
|
||||
Minor release. Adds two **non-modal editors** (`nano` + `micro`) alongside
|
||||
|
||||
@@ -594,6 +594,28 @@ Add mempalace as an MCP server in your `opencode.jsonc` (inside `~/.config/openc
|
||||
|
||||
This gives opencode access to 29 MCP tools for searching memory, querying the knowledge graph, managing wings/rooms/drawers, and agent diaries.
|
||||
|
||||
### Sharing one palace across containers (external server)
|
||||
|
||||
By default each container runs its own local `mempalace-mcp`. To share **one**
|
||||
palace across several containers/harnesses (pi + opencode + native), run the
|
||||
bundled server and point clients at it:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.mempalace.yml up -d # serves mempalace-mcp over HTTP
|
||||
```
|
||||
|
||||
Then set in each client's `.env`:
|
||||
|
||||
```bash
|
||||
MEMPALACE_REMOTE_URL=http://<reachable-host>:8765/mcp
|
||||
# MEMPALACE_REMOTE_TOKEN=... # optional; sent as Authorization: Bearer
|
||||
```
|
||||
|
||||
`generate-config.py` then registers mempalace as a `remote` MCP endpoint instead
|
||||
of the local stdio command — no local `mempalace-mcp` is spawned and the
|
||||
`devbox-palace` volume is irrelevant. ⚠ The HTTP transport is **unauthenticated**;
|
||||
keep it on a trusted network or behind a reverse proxy that enforces the token.
|
||||
|
||||
### Basic usage
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Shared MemPalace server (optional) — one palace for many clients.
|
||||
#
|
||||
# Runs `mempalace-mcp` over HTTP so several containers/harnesses (pi +
|
||||
# opencode + native) can share ONE palace instead of each keeping its own.
|
||||
# Point every client at it by setting, in that client's .env:
|
||||
#
|
||||
# MEMPALACE_REMOTE_URL=http://<reachable-host>:8765/mcp
|
||||
#
|
||||
# (see .env.example). When set, the client connects over HTTP and does NOT
|
||||
# spawn its own local mempalace-mcp.
|
||||
#
|
||||
# Start: docker compose -f docker-compose.mempalace.yml up -d
|
||||
# Stop: docker compose -f docker-compose.mempalace.yml down
|
||||
# Logs: docker compose -f docker-compose.mempalace.yml logs -f
|
||||
#
|
||||
# Why reuse the devbox image? mempalace-mcp is already installed in it, and
|
||||
# reusing it GUARANTEES the server's mempalace version matches the clients'
|
||||
# (both are pinned by the same image build). Override with a slimmer image via
|
||||
# MEMPALACE_SERVER_IMAGE if you prefer (it must provide `mempalace-mcp`).
|
||||
#
|
||||
# ⚠ SECURITY: mempalace-mcp's HTTP transport has NO authentication of its own.
|
||||
# Do NOT expose port 8765 to an untrusted network. The default below binds to
|
||||
# 127.0.0.1 (host loopback) only. To let sibling containers reach it, either
|
||||
# attach them to the shared `mempalace-net` network (container-to-container, no
|
||||
# host port needed — use http://mempalace-server:8765/mcp), or front it with a
|
||||
# reverse proxy that enforces MEMPALACE_REMOTE_TOKEN as `Authorization: Bearer`.
|
||||
|
||||
name: mempalace-server
|
||||
|
||||
services:
|
||||
mempalace:
|
||||
image: ${MEMPALACE_SERVER_IMAGE:-joakimp/opencode-devbox:latest}
|
||||
container_name: mempalace-server
|
||||
# Bypass the devbox entrypoint (dev-shell/LAN/config setup) and run the
|
||||
# HTTP MCP server directly. HOME + explicit --palace pin the data path so
|
||||
# it does not depend on the image's default user/HOME. Runs as root so it
|
||||
# can initialise the fresh named volume; the volume is dedicated to this
|
||||
# server (clients reach it over HTTP, never by mounting it).
|
||||
entrypoint: []
|
||||
user: "0:0"
|
||||
environment:
|
||||
- HOME=/data
|
||||
command:
|
||||
- mempalace-mcp
|
||||
- --transport
|
||||
- http
|
||||
- --host
|
||||
- "0.0.0.0"
|
||||
- --port
|
||||
- "8765"
|
||||
- --palace
|
||||
- /data/.mempalace
|
||||
restart: unless-stopped
|
||||
# Loopback-only by default (see SECURITY note). Use "8765:8765" to expose on
|
||||
# all host interfaces, or drop `ports:` entirely and rely on mempalace-net.
|
||||
ports:
|
||||
- "127.0.0.1:8765:8765"
|
||||
volumes:
|
||||
# The shared palace data — precious; back this up.
|
||||
- mempalace-shared:/data/.mempalace
|
||||
# Embedding-model cache (~79 MB, disposable) so search does not re-download.
|
||||
- mempalace-shared-chroma:/data/.cache/chroma
|
||||
networks:
|
||||
- mempalace-net
|
||||
healthcheck:
|
||||
# A tools/list round-trip proves the server is answering MCP (python3 is
|
||||
# always present — mempalace itself is a python tool in the image).
|
||||
test:
|
||||
- CMD
|
||||
- python3
|
||||
- -c
|
||||
- "import urllib.request,json; d=json.dumps({'jsonrpc':'2.0','id':1,'method':'tools/list','params':{}}).encode(); r=urllib.request.Request('http://127.0.0.1:8765/mcp',data=d,headers={'Content-Type':'application/json','Accept':'application/json'}); urllib.request.urlopen(r,timeout=5).read()"
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
volumes:
|
||||
mempalace-shared:
|
||||
mempalace-shared-chroma:
|
||||
|
||||
networks:
|
||||
mempalace-net:
|
||||
name: mempalace-net
|
||||
@@ -38,6 +38,10 @@ services:
|
||||
- TERM=xterm-256color
|
||||
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
|
||||
- GITEA_HOST=${GITEA_HOST:-}
|
||||
# MemPalace transport: empty = LOCAL per-container palace (default);
|
||||
# set MEMPALACE_REMOTE_URL to share one external palace. See .env.example.
|
||||
- MEMPALACE_REMOTE_URL=${MEMPALACE_REMOTE_URL:-}
|
||||
- MEMPALACE_REMOTE_TOKEN=${MEMPALACE_REMOTE_TOKEN:-}
|
||||
volumes:
|
||||
# Host workspace — user's project directory
|
||||
- ${WORKSPACE_PATH:-~/src}:/workspace
|
||||
@@ -73,7 +77,9 @@ services:
|
||||
# Persist uv data (Python installs)
|
||||
- devbox-uv:/home/developer/.local/share/uv
|
||||
|
||||
# Optional: persist MemPalace data (conversation memory, knowledge graph)
|
||||
# Optional: persist MemPalace data (conversation memory, knowledge graph).
|
||||
# LOCAL palace only (default). In EXTERNAL mode (MEMPALACE_REMOTE_URL set)
|
||||
# the shared server owns the data, so this volume is irrelevant.
|
||||
# - devbox-palace:/home/developer/.mempalace
|
||||
|
||||
# Optional: persist ChromaDB embedding model cache (~79 MB)
|
||||
|
||||
+10
-1
@@ -36,6 +36,12 @@ services:
|
||||
- GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN:-}
|
||||
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
|
||||
- GITEA_HOST=${GITEA_HOST:-}
|
||||
# MemPalace transport: leave empty for a LOCAL per-container palace
|
||||
# (default). Set MEMPALACE_REMOTE_URL to share ONE external MemPalace
|
||||
# across containers/harnesses; MEMPALACE_REMOTE_TOKEN is an optional
|
||||
# bearer token. See .env.example.
|
||||
- MEMPALACE_REMOTE_URL=${MEMPALACE_REMOTE_URL:-}
|
||||
- MEMPALACE_REMOTE_TOKEN=${MEMPALACE_REMOTE_TOKEN:-}
|
||||
volumes:
|
||||
# Host workspace — mount your project here
|
||||
- ${WORKSPACE_PATH:-.}:/workspace
|
||||
@@ -128,7 +134,10 @@ services:
|
||||
- devbox-nvim-data:/home/developer/.local/share/nvim
|
||||
|
||||
# Optional: persist MemPalace data (conversation memory, knowledge graph,
|
||||
# embeddings). Without this, palace data is lost on container recreation.
|
||||
# embeddings). Applies to the LOCAL palace only (the default, when
|
||||
# MEMPALACE_REMOTE_URL is unset). Without this, palace data is lost on
|
||||
# container recreation. In EXTERNAL mode the shared server owns the data,
|
||||
# so this volume is irrelevant.
|
||||
# - devbox-palace:/home/developer/.mempalace
|
||||
|
||||
# Optional: persist ChromaDB embedding model cache (~79 MB, downloaded on
|
||||
|
||||
@@ -16,9 +16,13 @@ Environment variables:
|
||||
OPENCODE_MODEL Optional. Overrides the provider default model.
|
||||
AWS_REGION Bedrock only. Default: us-east-1.
|
||||
AWS_PROFILE Bedrock only. Default: default.
|
||||
MEMPALACE_REMOTE_URL Optional. Point MemPalace at a shared HTTP endpoint
|
||||
instead of spawning a local stdio server.
|
||||
MEMPALACE_REMOTE_TOKEN Optional. Bearer token for MEMPALACE_REMOTE_URL.
|
||||
|
||||
MCP servers are auto-registered for tools detected on PATH:
|
||||
- mempalace (if installed) — enabled
|
||||
- mempalace — LOCAL stdio if `mempalace-mcp` is installed, or EXTERNAL
|
||||
(remote HTTP) when MEMPALACE_REMOTE_URL is set (shared palace)
|
||||
- gitea-mcp (if installed) — registered but disabled by default
|
||||
|
||||
Output path: $HOME/.config/opencode/opencode.jsonc
|
||||
@@ -80,13 +84,27 @@ def register_mcp_servers(config: dict) -> list[str]:
|
||||
"""
|
||||
servers: dict[str, dict] = {}
|
||||
|
||||
# MemPalace — local-first AI memory (if installed).
|
||||
# `mempalace-mcp` is the entry-point binary shipped by the mempalace
|
||||
# Python package. `uv tool install mempalace` places it on PATH as a
|
||||
# shim whose shebang points at the isolated venv's Python, so system
|
||||
# `python3 -m mempalace.mcp_server` (which would fail — system
|
||||
# python3 can't import from the uv venv) is unnecessary here.
|
||||
if shutil.which("mempalace-mcp"):
|
||||
# MemPalace — AI memory. Two modes, same env contract as the mempalace.ts
|
||||
# pi extension (mempalace-toolkit), so one shared MemPalace can serve pi +
|
||||
# opencode + native:
|
||||
# LOCAL (default): stdio subprocess. `mempalace-mcp` is the entry-point
|
||||
# binary shipped by the mempalace Python package. `uv tool install
|
||||
# mempalace` places it on PATH as a shim whose shebang points at the
|
||||
# isolated venv's Python, so system `python3 -m mempalace.mcp_server`
|
||||
# (which would fail — system python3 can't import from the uv venv) is
|
||||
# unnecessary here.
|
||||
# EXTERNAL: set MEMPALACE_REMOTE_URL to a shared MemPalace HTTP endpoint
|
||||
# (e.g. http://mempalace.lan:8765/mcp). MEMPALACE_REMOTE_TOKEN, if set,
|
||||
# becomes a Bearer auth header. No local mempalace-mcp is spawned, so
|
||||
# the binary need not even be installed in this mode.
|
||||
remote_url = os.environ.get("MEMPALACE_REMOTE_URL", "").strip()
|
||||
if remote_url:
|
||||
entry: dict = {"type": "remote", "url": remote_url}
|
||||
token = os.environ.get("MEMPALACE_REMOTE_TOKEN", "").strip()
|
||||
if token:
|
||||
entry["headers"] = {"Authorization": f"Bearer {token}"}
|
||||
servers["mempalace"] = entry
|
||||
elif shutil.which("mempalace-mcp"):
|
||||
servers["mempalace"] = {
|
||||
"type": "local",
|
||||
"command": ["mempalace-mcp"],
|
||||
|
||||
Reference in New Issue
Block a user