diff --git a/.env.example b/.env.example index 6cf375b..1c67202 100644 --- a/.env.example +++ b/.env.example @@ -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 + # ── 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 diff --git a/.env.shared.example b/.env.shared.example index 859a602..1e3cca8 100644 --- a/.env.shared.example +++ b/.env.shared.example @@ -27,6 +27,13 @@ GIT_USER_EMAIL=your.name@example.com # If you have per-user keys: SSH_KEY_PATH=~//.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 diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml index 635b5bc..90e91c0 100644 --- a/.gitea/workflows/validate.yml +++ b/.gitea/workflows/validate.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f013ba..63ad51a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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://: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 diff --git a/README.md b/README.md index 3618ae7..03ab771 100644 --- a/README.md +++ b/README.md @@ -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://: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 diff --git a/docker-compose.mempalace.yml b/docker-compose.mempalace.yml new file mode 100644 index 0000000..3c2483d --- /dev/null +++ b/docker-compose.mempalace.yml @@ -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://: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 diff --git a/docker-compose.shared.yml b/docker-compose.shared.yml index d4fc54f..900d801 100644 --- a/docker-compose.shared.yml +++ b/docker-compose.shared.yml @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index 9002507..03c9557 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/rootfs/usr/local/lib/opencode-devbox/generate-config.py b/rootfs/usr/local/lib/opencode-devbox/generate-config.py index 4f264e6..1374926 100755 --- a/rootfs/usr/local/lib/opencode-devbox/generate-config.py +++ b/rootfs/usr/local/lib/opencode-devbox/generate-config.py @@ -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"],