diff --git a/.claude/agents/documentation-keeper.md b/.claude/agents/documentation-keeper.md
index ad1e086..6130dc7 100644
--- a/.claude/agents/documentation-keeper.md
+++ b/.claude/agents/documentation-keeper.md
@@ -142,6 +142,7 @@ When creating documentation:
2. Full content in markdown format
3. Mermaid diagrams embedded within the documentation
4. Summary of what was documented and any follow-up actions needed
+5. Update the change logs
When providing information to other agents:
1. The specific information requested
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 4cad768..a7f5fa1 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -36,7 +36,8 @@
"Bash(docker version:*)",
"Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"servers\": []\n}\nEOF)",
"Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"mcpServers\": {}\n}\nEOF)",
- "Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"mcpServers\": {\n \"example\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@modelcontextprotocol/server-everything\"],\n \"env\": {}\n }\n }\n}\nEOF)"
+ "Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"mcpServers\": {\n \"example\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@modelcontextprotocol/server-everything\"],\n \"env\": {}\n }\n }\n}\nEOF)",
+ "Bash(sleep 20 && sshpass -p '&Rip8a115' ssh -o StrictHostKeyChecking=no glyptic@192.168.86.100 \"docker ps --filter name=n8n --format 'table {{.Names}}\\\\t{{.Status}}' && echo '---' && docker logs n8n --tail 15 2>&1\")"
]
}
}
diff --git a/CLAUDE.md b/CLAUDE.md
index 961e1de..3aa9c4a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -36,6 +36,7 @@ hf-pdocker-01 (23GB RAM, i7-7700T, NVMe, HD 630 GPU) -- RESTORED (2026-03-08, ha
├── [COMPOSE] Farm Manager API (:8888) -- dashboard + API server
├── [COMPOSE] Farm Agent (:8889)
├── [COMPOSE] Rongorongo decipherment engine -- 2 containers (rongorongo-web :8181, rongorongo-api). Server-synced state at /mnt/docker-data/configs/rongorongo/data. Traefik basicAuth on rongorongo.hobbs.farm.
+├── [COMPOSE] LinkedStorm (:8300 pipeline, :8310 api, cron no-port) -- whole-home audio assistant. VAD+Whisper STT+speaker-ID. Deployed 2026-05-28. Public: linkedstorm.hobbs.farm -> :8310.
├── [REMOVED] VoiceCoach -- moved to GPU machine (192.168.86.150) 2026-04-04
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host
├── [COMPOSE] Telegraf -- metrics collection to InfluxDB
@@ -58,6 +59,7 @@ hf-pdocker-02 (7.6GB RAM, i7-6700T, HDD)
├── [COMPOSE] Wiki.js + wikijs-db (PostgreSQL)
├── [COMPOSE] Vaultwarden, Actual Budget, MCPO, Trivy
├── [COMPOSE] n8n + n8n-db (PostgreSQL) -- workflow automation
+├── [COMPOSE] LifeOS (:8095) -- personal life/goals/habits tracker (PWA). Local mode: Flask+SQLite, no Supabase. LAN-only (no auth in backend). AI companion -> Ollama on .150.
├── [COMPOSE] Farm Agent (:8889)
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host
├── [COMPOSE] Telegraf -- metrics collection to InfluxDB
@@ -140,8 +142,9 @@ compose/ # Standalone Compose definitions (deployed per-node)
- Home Assistant: `yoda.hobbs.farm`
- Vaultwarden: `vault.hobbs.farm`
- Gitea: `git.hobbs.farm`
-- VoiceCoach: `coach.hobbs.farm` (LiveKit WebRTC: `voice.hobbs.farm`)
+- FRIDAY Voice: `coach.hobbs.farm` (replaced VoiceCoach 2026-07-05; runs on .150 :8320)
- Rongorongo: `rongorongo.hobbs.farm`
+- LinkedStorm: `linkedstorm.hobbs.farm`
### Internal Services by Node
@@ -167,7 +170,8 @@ compose/ # Standalone Compose definitions (deployed per-node)
- Farm Manager: :8888 (dashboard + API)
- Farm Agent: :8889
- Rongorongo: :8181 (rongorongo-web nginx; reverse-proxies /api/* to rongorongo-api Flask backend on the internal `rongorongo` bridge net). State + images at /mnt/docker-data/configs/rongorongo/data/. Public URL gated by Traefik basicAuth (creds in Vaultwarden).
-- ~~VoiceCoach~~ -- moved to GPU machine (192.168.86.150) 2026-04-04
+- LinkedStorm: :8300 (pipeline/VAD+STT+speaker-ID), :8310 (API/dashboard), cron (no port). Public: linkedstorm.hobbs.farm -> :8310. Source: /opt/linkedstorm (rsync from WSL). Data: /mnt/docker-data/configs/linkedstorm/. STT via Speaches GPU machine :8000, LLM via Ollama :11434 (qwen2.5:14b).
+- ~~VoiceCoach~~ -- fully decommissioned 2026-07-05, replaced by FRIDAY Voice on .150 (see GPU Machine section + memory `friday-voice`)
**hf-pdocker-02 (192.168.86.100):**
- Farm Agent: :8889
@@ -179,6 +183,7 @@ compose/ # Standalone Compose definitions (deployed per-node)
- Actual Budget: :5006
- MCPO: :8001
- Trivy: :4954
+- LifeOS: :8095 (host) -> :8080 (container). LAN-only, deliberately NOT behind Traefik: the Flask backend is single-user with NO auth. Image `lifeos:local` built on-node from /opt/lifeos (upstream https://github.com/lifeos-app/lifeos, pinned 3009bf6). SQLite at /var/lib/lifeos/data.db on LOCAL disk (NFS breaks SQLite WAL locking). Compose: compose/hf-pdocker-02/lifeos/. Note: host 8080 is taken by unifi-os-server.
**bart (192.168.86.167):**
- Farm Agent: :8889
@@ -190,17 +195,14 @@ compose/ # Standalone Compose definitions (deployed per-node)
- Jellyseerr: :5055
- OpenVAS: :9392
-**GPU Machine (192.168.86.150, RTX 3090, 15GB RAM):**
-- Ollama: :11434 (llama3.1:8b for VoiceCoach LLM, qwen2.5:14b, others)
-- [COMPOSE] VoiceCoach (all services co-located):
- - voicecoach-livekit: :7880/:7881, UDP 50000-50100 (public at voice.hobbs.farm)
- - voicecoach-speaches-gpu: :8000 (GPU Whisper STT + Kokoro TTS, CUDA float16)
- - voicecoach-speechbrain: :8001 (emotion analysis)
- - voicecoach-token-server: :8080 (token + settings API)
- - voicecoach-pipecat: pipeline agent (LLM via local Ollama llama3.1:8b)
- - voicecoach-web: :8085 (public at coach.hobbs.farm)
- - voicecoach-openclaw: :3100 (idle, kept for future Claude API routing)
-- Compose: ~/voicecoach-speaches/docker-compose.yml
+**GPU Machine (192.168.86.150, RTX 3090):**
+- **NOTE (2026-07-05):** .150 is actually the user's **Windows workstation** (runs the WSL/Claude Code session), not a headless Linux server. It runs **Docker Desktop** (WSL2 backend, RTX 3090 GPU passthrough working) alongside **UniFi OS Server** (Podman). Docker Desktop panics if UniFi/Podman claims `\\.\pipe\docker_engine` first — start Docker Desktop before UniFi OS Server. See memory `docker-desktop-unifi-podman-conflict`.
+- **FRIDAY Voice replaced VoiceCoach (2026-07-05).** VoiceCoach fully decommissioned (v1+v2 removed from hf-pdocker-01; voice.hobbs.farm route removed). See memory `friday-voice`.
+- [COMPOSE] **FRIDAY Voice stack (all on .150 Docker Desktop, via `docker.exe -c desktop-linux`):**
+ - **voicecoach-speaches-gpu**: :8000 — GPU Whisper large-v3-turbo (STT) + Kokoro-82M af_nova (TTS), CUDA float16. Compose: `compose/gpu-machine/voicecoach-speaches/`.
+ - **hermes**: `nousresearch/hermes-agent:latest` gateway — FRIDAY's reasoning agent. Settings transferred from WSL `~/.hermes` into named volume `hermes-data`. Compose: `compose/gpu-machine/hermes/`.
+ - **friday-voice**: :8320 — push-to-talk console. Calls speaches (`host.docker.internal:8000`) + hermes (`docker exec hermes hermes -Q` via wrapper). Public: **coach.hobbs.farm** → :8320. Compose: `compose/gpu-machine/friday-voice/`.
+- Ollama: :11434 (qwen2.5:14b, Hermes-4.3-36B-GGUF, others)
### Port Conflict Notes
@@ -297,3 +299,7 @@ All persistent data on NAS at `/mnt/docker-data/`:
9. **Guacamole schema**: App image pinned to `guacamole/guacamole:1.6.0` + `guacd:1.6.0` with Watchtower disabled. DB schema was reset to fresh 1.6.0 on 2026-05-20 (old schema was non-standard). Recreate connections after any DB reset.
10. **Gitea image**: Pinned to `gitea/gitea:1.26.1` with Watchtower disabled (2026-05-20). Watchtower previously pulled broken `:latest` images causing missing git binary errors.
11. **Home Assistant reverse proxy**: Config at `/mnt/docker-data/configs/homeassistant/configuration.yaml` (no hyphen). Must have `http: use_x_forwarded_for: true` + `trusted_proxies: [172.16.0.0/12, 127.0.0.1]` or proxied requests return 400.
+12. **hf-pdocker-01 memory pressure (2026-07-05)**: Node runs hot (~15/23GB, chronic swap). Mitigations applied: `vm.swappiness` lowered 60→10 (persisted in `/etc/sysctl.d/99-swappiness.conf`), and VoiceCoach (v1+v2, ~10 containers) fully removed (replaced by FRIDAY Voice on .150). Frigate is the main consumer (~9GB/12GB). Frigate has no semantic-search/face/LPR features enabled.
+13. **Docker Desktop vs UniFi OS Server pipe conflict (Windows box .150, 2026-07-05)**: Both fight over `\\.\pipe\docker_engine`; Docker Desktop's backend nil-panics ("backend exited before becoming ready") if Podman wins. Fix: let Docker Desktop (auto-starts at login) claim the pipe first, launch UniFi OS Server manually after. Full detail in memory `docker-desktop-unifi-podman-conflict`.
+14. **MySQL InnoDB corruption on NFS**: `semaphore-db` (mysql:8.0 on NFS) hit system-tablespace page corruption and crash-looped 3789× (2026-07-11), same class as the Guacamole-on-NFS issue (#9). Recovered in-place via `--innodb-force-recovery=1` → mysqldump → fresh volume → reimport (DB was empty, no data lost); admin recreated with `semaphore user add`. Any MySQL DB on NFS is at risk — prefer LOCAL storage like guacamole-db. See memory `semaphore-db-nfs-corruption`.
+15. **litellm hangs after DB blip**: litellm does NOT self-recover from a transient `litellm-db` disconnect — it loops on "Prisma DB reconnect failed" forever; needs manual `docker restart litellm`. Its healthcheck was fixed from `curl` (absent from image, permanent false-unhealthy) to a python urllib check (2026-07-11).
diff --git a/Rongorongo/RONGORONGO_DEPLOY.md b/Rongorongo/RONGORONGO_DEPLOY.md
new file mode 100644
index 0000000..2699c38
--- /dev/null
+++ b/Rongorongo/RONGORONGO_DEPLOY.md
@@ -0,0 +1,322 @@
+# RONGORONGO_DEPLOY.md
+# Rongorongo Decipherment Engine — Claude Code Deployment Guide
+# Target: hobbs.farm Docker Swarm infrastructure
+# Subdomain: rongorongo.hobbs.farm
+
+---
+
+## CONTEXT
+
+This document instructs Claude Code to build, push, and deploy the Rongorongo
+Decipherment Engine as a containerized static web app to the hobbs.farm Swarm.
+
+The app is a single-file HTML/JS application served by nginx. It calls the
+Anthropic API directly from the browser. No backend, no database. All session
+state is stored in browser localStorage.
+
+Infrastructure assumptions (from existing hobbs.farm stack):
+- Docker Swarm: nodes hf-pdocker-01, hf-pdocker-02, bart
+- Traefik reverse proxy with wildcard *.hobbs.farm TLS via Cloudflare DNS
+- External overlay network: traefik-public
+- Registry: registry.hobbs.farm (if local) OR use Docker Hub as fallback
+- Portainer CE available for stack management
+- DNS: AdGuard Home with hairpin NAT for *.hobbs.farm
+
+---
+
+## REPOSITORY STRUCTURE
+
+After cloning or creating the project, verify this structure exists:
+
+```
+rongorongo/
+├── Dockerfile
+├── nginx.conf
+├── stack.yml
+├── frontend/
+│ └── index.html
+└── RONGORONGO_DEPLOY.md (this file)
+```
+
+---
+
+## STEP 1 — VERIFY PREREQUISITES
+
+Run these checks before proceeding. Stop and report any failures.
+
+```bash
+# Confirm Docker and Swarm context
+docker info | grep -E "Swarm:|Manager:|Nodes:"
+
+# Confirm traefik-public network exists
+docker network ls | grep traefik-public
+
+# Confirm Traefik is running
+docker service ls | grep traefik
+
+# Test registry connectivity (if using local registry)
+curl -s https://registry.hobbs.farm/v2/_catalog || echo "Registry unreachable — will use build-and-load method"
+
+# Confirm you are on a Swarm manager node
+docker node ls
+```
+
+If registry.hobbs.farm is unreachable, use the ALTERNATIVE DEPLOYMENT method
+at the bottom of this document.
+
+---
+
+## STEP 2 — BUILD THE IMAGE
+
+```bash
+cd rongorongo/
+
+# Build image
+docker build -t registry.hobbs.farm/rongorongo:latest .
+
+# Verify image was created
+docker images | grep rongorongo
+```
+
+Expected output: image tagged `registry.hobbs.farm/rongorongo:latest`, size
+should be ~25–40MB (nginx:alpine base + small HTML file).
+
+---
+
+## STEP 3 — PUSH TO REGISTRY
+
+```bash
+# If registry requires auth:
+docker login registry.hobbs.farm
+
+# Push
+docker push registry.hobbs.farm/rongorongo:latest
+```
+
+If the push fails due to registry issues, fall through to ALTERNATIVE DEPLOYMENT.
+
+---
+
+## STEP 4 — DEPLOY STACK
+
+```bash
+# Deploy or update the stack
+docker stack deploy -c stack.yml rongorongo --with-registry-auth
+
+# Watch rollout
+docker service ps rongorongo_rongorongo --no-trunc
+```
+
+Wait for the service to show `Running` status. This typically takes 15–30 seconds.
+
+```bash
+# Confirm healthy
+docker service ls | grep rongorongo
+curl -s http://localhost/health # or via container exec
+```
+
+---
+
+## STEP 5 — DNS VERIFICATION
+
+The stack.yml assumes `rongorongo.hobbs.farm` resolves to the Traefik entrypoint.
+
+```bash
+# Check AdGuard Home has the record (or wildcard covers it)
+nslookup rongorongo.hobbs.farm
+
+# Test internal access
+curl -sk https://rongorongo.hobbs.farm/health
+```
+
+If using AdGuard Home for internal DNS and the wildcard *.hobbs.farm is already
+configured for hairpin NAT, no additional DNS entry is needed.
+
+---
+
+## STEP 6 — SMOKE TEST
+
+Open https://rongorongo.hobbs.farm in a browser and verify:
+1. App loads with "Rongorongo · Decipherment Engine" title
+2. Sidebar navigation works (Image Corpus, Glyph Catalog, etc.)
+3. Enter an Anthropic API key in the sidebar footer input
+4. Upload a test image to the corpus
+5. Navigate to Pattern Analyzer and run an analysis
+6. Confirm AI response appears in the result box
+
+---
+
+## UPDATING THE APP
+
+When index.html is modified:
+
+```bash
+cd rongorongo/
+
+# Rebuild
+docker build -t registry.hobbs.farm/rongorongo:latest .
+
+# Push
+docker push registry.hobbs.farm/rongorongo:latest
+
+# Force service update (pull new image)
+docker service update --image registry.hobbs.farm/rongorongo:latest --force rongorongo_rongorongo
+
+# Watch update
+docker service ps rongorongo_rongorongo
+```
+
+---
+
+## ROLLBACK
+
+If something breaks after an update:
+
+```bash
+docker service rollback rongorongo_rongorongo
+docker service ps rongorongo_rongorongo
+```
+
+---
+
+## TEARDOWN
+
+To remove the service without destroying data (there is no persistent volume):
+
+```bash
+docker stack rm rongorongo
+```
+
+---
+
+## ALTERNATIVE DEPLOYMENT (no registry)
+
+Use this if registry.hobbs.farm is unavailable. This method builds the image
+directly on the Swarm manager and uses `docker service create` instead of
+`stack deploy`.
+
+```bash
+# On the Swarm manager node:
+cd rongorongo/
+
+# Build locally
+docker build -t rongorongo:latest .
+
+# Create service manually (adjust Traefik labels as needed)
+docker service create \
+ --name rongorongo_rongorongo \
+ --network traefik-public \
+ --label "traefik.enable=true" \
+ --label "traefik.docker.network=traefik-public" \
+ --label "traefik.http.routers.rongorongo.rule=Host(\`rongorongo.hobbs.farm\`)" \
+ --label "traefik.http.routers.rongorongo.entrypoints=websecure" \
+ --label "traefik.http.routers.rongorongo.tls=true" \
+ --label "traefik.http.routers.rongorongo.tls.certresolver=cloudflare" \
+ --label "traefik.http.services.rongorongo.loadbalancer.server.port=80" \
+ --replicas 1 \
+ --restart-condition on-failure \
+ rongorongo:latest
+
+# Monitor
+docker service ps rongorongo_rongorongo
+```
+
+NOTE: With this method, to update you must rebuild locally and run:
+```bash
+docker service update --image rongorongo:latest --force rongorongo_rongorongo
+```
+
+This only works if your current node is the one running the replica. If you
+have a multi-node swarm, you should set up the registry to avoid placement issues.
+
+---
+
+## PORTAINER DEPLOYMENT (optional GUI method)
+
+If you prefer Portainer CE:
+
+1. Navigate to https://portainer.hobbs.farm (or your Portainer URL)
+2. Select the Swarm endpoint
+3. Go to Stacks → Add Stack
+4. Name: `rongorongo`
+5. Paste the contents of stack.yml into the web editor
+6. Set `registry.hobbs.farm/rongorongo:latest` as the image
+ (you still need to build and push first)
+7. Click Deploy the stack
+
+---
+
+## ENVIRONMENT NOTES
+
+The app does NOT use environment variables or Docker secrets. The Anthropic API
+key is entered by the user at runtime and stored in their browser's localStorage.
+This is intentional — the app is designed to be used by a single user (Jeremy)
+and no server-side key storage is needed or appropriate.
+
+If you want to pre-populate the API key for convenience (only if the deployment
+is strictly private/internal), you can inject it at build time by modifying
+frontend/index.html before building:
+
+Find this line:
+```javascript
+function getApiKey() {
+ return document.getElementById('apiKeyInput').value.trim()
+```
+
+And prepend a hardcoded fallback (NOT recommended for any externally accessible URL).
+
+---
+
+## TRAEFIK ENTRYPOINT NAMES
+
+The stack.yml assumes:
+- HTTP entrypoint name: `web`
+- HTTPS entrypoint name: `websecure`
+- Cert resolver name: `cloudflare`
+
+These match the standard hobbs.farm Traefik configuration. If your Traefik uses
+different names, edit the labels in stack.yml before deploying.
+
+---
+
+## TROUBLESHOOTING
+
+**Service won't start:**
+```bash
+docker service ps rongorongo_rongorongo --no-trunc
+docker inspect $(docker ps -q --filter name=rongorongo) 2>/dev/null | grep -A5 '"State"'
+```
+
+**Traefik not routing:**
+```bash
+# Check Traefik logs for rongorongo
+docker service logs traefik_traefik 2>&1 | grep -i rongorongo | tail -20
+# Confirm labels are applied
+docker service inspect rongorongo_rongorongo | grep -A2 Labels
+```
+
+**TLS cert not issuing:**
+```bash
+# Wildcard cert should already cover rongorongo.hobbs.farm
+# If not, check Cloudflare DNS API token has Zone:Read and DNS:Edit
+docker service logs traefik_traefik 2>&1 | grep -i "acme\|certificate\|cloudflare" | tail -20
+```
+
+**API calls blocked (CORS/CSP):**
+The nginx.conf includes a Content-Security-Policy header that allows
+`connect-src https://api.anthropic.com`. If you see CORS errors in the browser
+console, verify the CSP header is present:
+```bash
+curl -I https://rongorongo.hobbs.farm/ | grep -i content-security
+```
+
+**App loads but AI calls fail:**
+- Check API key is entered correctly in the sidebar
+- Anthropic API key must start with `sk-ant-`
+- Confirm outbound HTTPS to api.anthropic.com is not blocked by firewall rules
+- The app sends `anthropic-dangerous-direct-browser-access: true` header which
+ is required for direct browser API access
+
+---
+
+## END OF DEPLOYMENT GUIDE
diff --git a/Rongorongo/_extracted/rongorongo/Dockerfile b/Rongorongo/_extracted/rongorongo/Dockerfile
new file mode 100644
index 0000000..ff84aa3
--- /dev/null
+++ b/Rongorongo/_extracted/rongorongo/Dockerfile
@@ -0,0 +1,18 @@
+FROM nginx:1.25-alpine
+
+# Remove default nginx static content
+RUN rm -rf /usr/share/nginx/html/*
+
+# Copy app
+COPY frontend/index.html /usr/share/nginx/html/index.html
+
+# Copy nginx config
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+
+# Healthcheck
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+ CMD wget -qO- http://localhost/health || exit 1
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/Rongorongo/_extracted/rongorongo/deploy/RONGORONGO_DEPLOY.md b/Rongorongo/_extracted/rongorongo/deploy/RONGORONGO_DEPLOY.md
new file mode 100644
index 0000000..2699c38
--- /dev/null
+++ b/Rongorongo/_extracted/rongorongo/deploy/RONGORONGO_DEPLOY.md
@@ -0,0 +1,322 @@
+# RONGORONGO_DEPLOY.md
+# Rongorongo Decipherment Engine — Claude Code Deployment Guide
+# Target: hobbs.farm Docker Swarm infrastructure
+# Subdomain: rongorongo.hobbs.farm
+
+---
+
+## CONTEXT
+
+This document instructs Claude Code to build, push, and deploy the Rongorongo
+Decipherment Engine as a containerized static web app to the hobbs.farm Swarm.
+
+The app is a single-file HTML/JS application served by nginx. It calls the
+Anthropic API directly from the browser. No backend, no database. All session
+state is stored in browser localStorage.
+
+Infrastructure assumptions (from existing hobbs.farm stack):
+- Docker Swarm: nodes hf-pdocker-01, hf-pdocker-02, bart
+- Traefik reverse proxy with wildcard *.hobbs.farm TLS via Cloudflare DNS
+- External overlay network: traefik-public
+- Registry: registry.hobbs.farm (if local) OR use Docker Hub as fallback
+- Portainer CE available for stack management
+- DNS: AdGuard Home with hairpin NAT for *.hobbs.farm
+
+---
+
+## REPOSITORY STRUCTURE
+
+After cloning or creating the project, verify this structure exists:
+
+```
+rongorongo/
+├── Dockerfile
+├── nginx.conf
+├── stack.yml
+├── frontend/
+│ └── index.html
+└── RONGORONGO_DEPLOY.md (this file)
+```
+
+---
+
+## STEP 1 — VERIFY PREREQUISITES
+
+Run these checks before proceeding. Stop and report any failures.
+
+```bash
+# Confirm Docker and Swarm context
+docker info | grep -E "Swarm:|Manager:|Nodes:"
+
+# Confirm traefik-public network exists
+docker network ls | grep traefik-public
+
+# Confirm Traefik is running
+docker service ls | grep traefik
+
+# Test registry connectivity (if using local registry)
+curl -s https://registry.hobbs.farm/v2/_catalog || echo "Registry unreachable — will use build-and-load method"
+
+# Confirm you are on a Swarm manager node
+docker node ls
+```
+
+If registry.hobbs.farm is unreachable, use the ALTERNATIVE DEPLOYMENT method
+at the bottom of this document.
+
+---
+
+## STEP 2 — BUILD THE IMAGE
+
+```bash
+cd rongorongo/
+
+# Build image
+docker build -t registry.hobbs.farm/rongorongo:latest .
+
+# Verify image was created
+docker images | grep rongorongo
+```
+
+Expected output: image tagged `registry.hobbs.farm/rongorongo:latest`, size
+should be ~25–40MB (nginx:alpine base + small HTML file).
+
+---
+
+## STEP 3 — PUSH TO REGISTRY
+
+```bash
+# If registry requires auth:
+docker login registry.hobbs.farm
+
+# Push
+docker push registry.hobbs.farm/rongorongo:latest
+```
+
+If the push fails due to registry issues, fall through to ALTERNATIVE DEPLOYMENT.
+
+---
+
+## STEP 4 — DEPLOY STACK
+
+```bash
+# Deploy or update the stack
+docker stack deploy -c stack.yml rongorongo --with-registry-auth
+
+# Watch rollout
+docker service ps rongorongo_rongorongo --no-trunc
+```
+
+Wait for the service to show `Running` status. This typically takes 15–30 seconds.
+
+```bash
+# Confirm healthy
+docker service ls | grep rongorongo
+curl -s http://localhost/health # or via container exec
+```
+
+---
+
+## STEP 5 — DNS VERIFICATION
+
+The stack.yml assumes `rongorongo.hobbs.farm` resolves to the Traefik entrypoint.
+
+```bash
+# Check AdGuard Home has the record (or wildcard covers it)
+nslookup rongorongo.hobbs.farm
+
+# Test internal access
+curl -sk https://rongorongo.hobbs.farm/health
+```
+
+If using AdGuard Home for internal DNS and the wildcard *.hobbs.farm is already
+configured for hairpin NAT, no additional DNS entry is needed.
+
+---
+
+## STEP 6 — SMOKE TEST
+
+Open https://rongorongo.hobbs.farm in a browser and verify:
+1. App loads with "Rongorongo · Decipherment Engine" title
+2. Sidebar navigation works (Image Corpus, Glyph Catalog, etc.)
+3. Enter an Anthropic API key in the sidebar footer input
+4. Upload a test image to the corpus
+5. Navigate to Pattern Analyzer and run an analysis
+6. Confirm AI response appears in the result box
+
+---
+
+## UPDATING THE APP
+
+When index.html is modified:
+
+```bash
+cd rongorongo/
+
+# Rebuild
+docker build -t registry.hobbs.farm/rongorongo:latest .
+
+# Push
+docker push registry.hobbs.farm/rongorongo:latest
+
+# Force service update (pull new image)
+docker service update --image registry.hobbs.farm/rongorongo:latest --force rongorongo_rongorongo
+
+# Watch update
+docker service ps rongorongo_rongorongo
+```
+
+---
+
+## ROLLBACK
+
+If something breaks after an update:
+
+```bash
+docker service rollback rongorongo_rongorongo
+docker service ps rongorongo_rongorongo
+```
+
+---
+
+## TEARDOWN
+
+To remove the service without destroying data (there is no persistent volume):
+
+```bash
+docker stack rm rongorongo
+```
+
+---
+
+## ALTERNATIVE DEPLOYMENT (no registry)
+
+Use this if registry.hobbs.farm is unavailable. This method builds the image
+directly on the Swarm manager and uses `docker service create` instead of
+`stack deploy`.
+
+```bash
+# On the Swarm manager node:
+cd rongorongo/
+
+# Build locally
+docker build -t rongorongo:latest .
+
+# Create service manually (adjust Traefik labels as needed)
+docker service create \
+ --name rongorongo_rongorongo \
+ --network traefik-public \
+ --label "traefik.enable=true" \
+ --label "traefik.docker.network=traefik-public" \
+ --label "traefik.http.routers.rongorongo.rule=Host(\`rongorongo.hobbs.farm\`)" \
+ --label "traefik.http.routers.rongorongo.entrypoints=websecure" \
+ --label "traefik.http.routers.rongorongo.tls=true" \
+ --label "traefik.http.routers.rongorongo.tls.certresolver=cloudflare" \
+ --label "traefik.http.services.rongorongo.loadbalancer.server.port=80" \
+ --replicas 1 \
+ --restart-condition on-failure \
+ rongorongo:latest
+
+# Monitor
+docker service ps rongorongo_rongorongo
+```
+
+NOTE: With this method, to update you must rebuild locally and run:
+```bash
+docker service update --image rongorongo:latest --force rongorongo_rongorongo
+```
+
+This only works if your current node is the one running the replica. If you
+have a multi-node swarm, you should set up the registry to avoid placement issues.
+
+---
+
+## PORTAINER DEPLOYMENT (optional GUI method)
+
+If you prefer Portainer CE:
+
+1. Navigate to https://portainer.hobbs.farm (or your Portainer URL)
+2. Select the Swarm endpoint
+3. Go to Stacks → Add Stack
+4. Name: `rongorongo`
+5. Paste the contents of stack.yml into the web editor
+6. Set `registry.hobbs.farm/rongorongo:latest` as the image
+ (you still need to build and push first)
+7. Click Deploy the stack
+
+---
+
+## ENVIRONMENT NOTES
+
+The app does NOT use environment variables or Docker secrets. The Anthropic API
+key is entered by the user at runtime and stored in their browser's localStorage.
+This is intentional — the app is designed to be used by a single user (Jeremy)
+and no server-side key storage is needed or appropriate.
+
+If you want to pre-populate the API key for convenience (only if the deployment
+is strictly private/internal), you can inject it at build time by modifying
+frontend/index.html before building:
+
+Find this line:
+```javascript
+function getApiKey() {
+ return document.getElementById('apiKeyInput').value.trim()
+```
+
+And prepend a hardcoded fallback (NOT recommended for any externally accessible URL).
+
+---
+
+## TRAEFIK ENTRYPOINT NAMES
+
+The stack.yml assumes:
+- HTTP entrypoint name: `web`
+- HTTPS entrypoint name: `websecure`
+- Cert resolver name: `cloudflare`
+
+These match the standard hobbs.farm Traefik configuration. If your Traefik uses
+different names, edit the labels in stack.yml before deploying.
+
+---
+
+## TROUBLESHOOTING
+
+**Service won't start:**
+```bash
+docker service ps rongorongo_rongorongo --no-trunc
+docker inspect $(docker ps -q --filter name=rongorongo) 2>/dev/null | grep -A5 '"State"'
+```
+
+**Traefik not routing:**
+```bash
+# Check Traefik logs for rongorongo
+docker service logs traefik_traefik 2>&1 | grep -i rongorongo | tail -20
+# Confirm labels are applied
+docker service inspect rongorongo_rongorongo | grep -A2 Labels
+```
+
+**TLS cert not issuing:**
+```bash
+# Wildcard cert should already cover rongorongo.hobbs.farm
+# If not, check Cloudflare DNS API token has Zone:Read and DNS:Edit
+docker service logs traefik_traefik 2>&1 | grep -i "acme\|certificate\|cloudflare" | tail -20
+```
+
+**API calls blocked (CORS/CSP):**
+The nginx.conf includes a Content-Security-Policy header that allows
+`connect-src https://api.anthropic.com`. If you see CORS errors in the browser
+console, verify the CSP header is present:
+```bash
+curl -I https://rongorongo.hobbs.farm/ | grep -i content-security
+```
+
+**App loads but AI calls fail:**
+- Check API key is entered correctly in the sidebar
+- Anthropic API key must start with `sk-ant-`
+- Confirm outbound HTTPS to api.anthropic.com is not blocked by firewall rules
+- The app sends `anthropic-dangerous-direct-browser-access: true` header which
+ is required for direct browser API access
+
+---
+
+## END OF DEPLOYMENT GUIDE
diff --git a/Rongorongo/_extracted/rongorongo/frontend/index.html b/Rongorongo/_extracted/rongorongo/frontend/index.html
new file mode 100644
index 0000000..3a8b08c
--- /dev/null
+++ b/Rongorongo/_extracted/rongorongo/frontend/index.html
@@ -0,0 +1,2057 @@
+
+
+
Upload Rongorongo tablet photographs. All modules share these images.
+
+
+
+
+
𓀠
+
Drop images here or click to browse
+
JPG · PNG · WEBP · Multiple files supported
+
+
+
+
+
+
Suggested Sources
+
+
— Barthel's Grundlagen zur Entzifferung der Osterinselschrift (1958) — 26 authenticated objects
+
— Fischer's Rongorongo: The Easter Island Script (1997)
+
— Musée de l'Homme, Paris · Smithsonian NMNH · Bishop Museum, Honolulu
+
— Santiago Staff tablet (highest glyph density, ~2320 glyphs)
+
+
+
+
+
+
+
+
Glyph Catalog
+
Click and drag on any image to isolate individual glyphs. Tag, classify, and track them across the corpus.
+
+
+
+
+
Select Image to Annotate
+
+
+
+
+
+
+
+ Draw a box around a glyph
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Glyph Registry
+
+
No glyphs tagged yet
+
+
+
+ ◈ AI GLYPH ANALYSIS
+
Select a glyph to analyze
+
+
+
+
+
+
+
+
+
+
+
Pattern Analyzer
+
AI-powered analysis of recurring motifs, frequency distributions, structural patterns, and cross-script comparisons.
+
+
+
+
Analysis Mode
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ◉ ANALYSIS OUTPUT
+
Upload images and run an analysis to begin
+
+
+
+
Session History
+
+
+
+
+
+
+
+
Cross-Tablet Sequence Matcher
+
Find glyph sequences that repeat across multiple tablets — formulaic phrases, titles, or liturgical refrains.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ◇ SEQUENCE MATCHES
+
Load multiple tablet images then run the matcher
+
+
+
+
+
+
+
Rapa Nui Phoneme Mapper
+
Statistical correlation between Rapa Nui syllable frequencies and observed glyph frequency rankings. Potential phonetic assignments emerge where rankings align.
+
+
+
+
Rapa Nui Phoneme Inventory (Known)
+
+ CV structure dominant. Consonants: p t k m n ŋ h r v. Vowels: a e i o u.
+ Most frequent syllables (corpus analysis): ko · ma · ta · ra · ka · ha · na · te · ai · oa
+
+
+
+
+
+
Frequency Correlation Table
+
+
+
+
Glyph Rank
+
Visual Description
+
Freq. Est.
+
Rapa Nui Candidate
+
Match Confidence
+
+
+
+
+
+ ◌ PHONETIC ANALYSIS
+
Run the mapper to see AI phonetic analysis
+
+
+
+
+
+
+
+
Bilingual Anchor Laboratory
+
Define hypothetical glyph-to-meaning assignments. Each anchor is a foothold for propagation testing.
+
+
+
+
+
+
Define New Anchor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⚓ AI CANDIDATES
+
+
+
+
+
Anchor Registry
+
+
No anchors defined
+
+
+
+
+
+
+
+
+
Anchor Propagation Simulator
+
Select an anchor and simulate how assigning that meaning propagates to neighboring glyphs across all loaded tablet images.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⊕ PROPAGATION OUTPUT
+
Define anchors and select one to simulate propagation
+
+
+
+
+
+
+
Feasibility Testing
+
Test whether each anchor is internally consistent, culturally coherent, and statistically plausible across the corpus.
+
+
+
+
Select Anchors to Test
+
+
No anchors defined. Go to Anchor Lab first.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Feasibility Scores
+
+
+
+
+ ✦ FEASIBILITY REPORT
+
Define and select anchors, then run tests
+
+
+
+
Test History
+
+
+
+
+
+
+
+
Confidence Decay Tracker
+
Running confidence scores for each anchor. Updates automatically as new feasibility tests produce supporting or contradicting evidence.
+
+
+
+
Run feasibility tests to populate confidence tracking
+
+
+
+
+
+
+
Scholarship Database
+
Established decipherment frameworks. Toggle to include as context in any AI analysis.
+
+
+
+
+
+
Ask Against Scholarship
+
+
+
+ 📜 SCHOLARSHIP ANALYSIS
+
Ask a question about existing decipherment scholarship
+
+
+
+
+
+
+
+
Academic Report Generator
+
Export all session work as a structured research document suitable for sharing with scholars.
Upload Rongorongo tablet photographs. All modules share these images.
+
+
+
+
+
𓀠
+
Drop images here or click to browse
+
JPG · PNG · WEBP · Multiple files supported
+
+
+
+
+
+
Suggested Sources
+
+
— Barthel's Grundlagen zur Entzifferung der Osterinselschrift (1958) — 26 authenticated objects
+
— Fischer's Rongorongo: The Easter Island Script (1997)
+
— Musée de l'Homme, Paris · Smithsonian NMNH · Bishop Museum, Honolulu
+
— Santiago Staff tablet (highest glyph density, ~2320 glyphs)
+
+
+
+
+
+
+
+
Glyph Catalog
+
Click and drag on any image to isolate individual glyphs. Tag, classify, and track them across the corpus.
+
+
+
+
+
Select Image to Annotate
+
+
+
+
+
+
+
+ Draw a box around a glyph
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Glyph Registry
+
+
No glyphs tagged yet
+
+
+
+ ◈ AI GLYPH ANALYSIS
+
Select a glyph to analyze
+
+
+
+
+
+
+
+
+
+
+
Pattern Analyzer
+
AI-powered analysis of recurring motifs, frequency distributions, structural patterns, and cross-script comparisons.
+
+
+
+
Analysis Mode
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ◉ ANALYSIS OUTPUT
+
Upload images and run an analysis to begin
+
+
+
+
Session History
+
+
+
+
+
+
+
+
Cross-Tablet Sequence Matcher
+
Find glyph sequences that repeat across multiple tablets — formulaic phrases, titles, or liturgical refrains.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ◇ SEQUENCE MATCHES
+
Load multiple tablet images then run the matcher
+
+
+
+
+
+
+
Rapa Nui Phoneme Mapper
+
Statistical correlation between Rapa Nui syllable frequencies and observed glyph frequency rankings. Potential phonetic assignments emerge where rankings align.
+
+
+
+
Rapa Nui Phoneme Inventory (Known)
+
+ CV structure dominant. Consonants: p t k m n ŋ h r v. Vowels: a e i o u.
+ Most frequent syllables (corpus analysis): ko · ma · ta · ra · ka · ha · na · te · ai · oa
+
+
+
+
+
+
Frequency Correlation Table
+
+
+
+
Glyph Rank
+
Visual Description
+
Freq. Est.
+
Rapa Nui Candidate
+
Match Confidence
+
+
+
+
+
+ ◌ PHONETIC ANALYSIS
+
Run the mapper to see AI phonetic analysis
+
+
+
+
+
+
+
+
Bilingual Anchor Laboratory
+
Define hypothetical glyph-to-meaning assignments. Each anchor is a foothold for propagation testing.
+
+
+
+
+
+
Define New Anchor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⚓ AI CANDIDATES
+
+
+
+
+
Anchor Registry
+
+
No anchors defined
+
+
+
+
+
+
+
+
+
Anchor Propagation Simulator
+
Select an anchor and simulate how assigning that meaning propagates to neighboring glyphs across all loaded tablet images.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⊕ PROPAGATION OUTPUT
+
Define anchors and select one to simulate propagation
+
+
+
+
+
+
+
Feasibility Testing
+
Test whether each anchor is internally consistent, culturally coherent, and statistically plausible across the corpus.
+
+
+
+
Select Anchors to Test
+
+
No anchors defined. Go to Anchor Lab first.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Feasibility Scores
+
+
+
+
+ ✦ FEASIBILITY REPORT
+
Define and select anchors, then run tests
+
+
+
+
Test History
+
+
+
+
+
+
+
+
Confidence Decay Tracker
+
Running confidence scores for each anchor. Updates automatically as new feasibility tests produce supporting or contradicting evidence.
+
+
+
+
Run feasibility tests to populate confidence tracking
+
+
+
+
+
+
+
Scholarship Database
+
Established decipherment frameworks. Toggle to include as context in any AI analysis.
+
+
+
+
+
+
Ask Against Scholarship
+
+
+
+ 📜 SCHOLARSHIP ANALYSIS
+
Ask a question about existing decipherment scholarship
+
+
+
+
+
+
+
+
Academic Report Generator
+
Export all session work as a structured research document suitable for sharing with scholars.
`;
+
+ if (!utterances.length) {
+ panel.innerHTML = nav + '
No utterances recorded for this day.
';
+ return;
+ }
+
+ // Group by hour
+ const byHour = {};
+ for (const u of utterances.reverse()) {
+ const h = new Date(u.start_time).getHours();
+ const label = new Date(u.start_time).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
+ const key = label.slice(0,5);
+ if (!byHour[key]) byHour[key] = [];
+ byHour[key].push(u);
+ }
+
+ let html = nav;
+ for (const [timeKey, utts] of Object.entries(byHour)) {
+ html += `
${timeKey}
`;
+ for (const u of utts) {
+ const color = speakerColor(u.speaker_id);
+ const name = u.speaker_name || 'Unknown';
+ const isUnknown = !u.speaker_id;
+ const badge = u.match_status === 'unknown' ? 'unknown'
+ : u.match_status === 'ambiguous' ? 'ambiguous' : '';
+ const actions = isUnknown ? `
`;
+ panel.innerHTML = html;
+}
+
+async function toggleMute(roomId) {
+ const rooms = await fetch('/api/rooms').then(r => r.json());
+ const room = rooms.find(r => r.id === roomId);
+ const action = room?.muted ? 'unmute' : 'mute';
+ await fetch(`/api/rooms/${roomId}/${action}`, { method: 'POST' });
+ loadRooms();
+}
+
+function escHtml(s) {
+ return String(s).replace(/&/g,'&').replace(//g,'>');
+}
+
+// Initial load
+loadLog();
+setInterval(loadLog, 30000); // Refresh every 30s
+```
+
+- [ ] **Commit**
+
+```bash
+git add app/api/static/
+git commit -m "feat: dashboard SPA — 5-tab log/search/speakers/RAG/rooms UI"
+git push origin master
+```
+
+---
+
+## Task 13: Dockerfiles
+
+**Files:** `pipeline/Dockerfile`, `api/Dockerfile`, `cron/Dockerfile`
+
+- [ ] **Write `pipeline/Dockerfile`**
+
+```dockerfile
+FROM python:3.12-slim
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ffmpeg libsndfile1 && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY pyproject.toml .
+RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu \
+ && pip install --no-cache-dir -e "." \
+ && pip uninstall -y torch && pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
+
+COPY app/ app/
+
+# Pre-download Silero VAD and resemblyzer models into the image layer
+RUN python -c "import torch; torch.hub.load('snakers4/silero-vad','silero_vad',trust_repo=True)" \
+ && python -c "from resemblyzer import VoiceEncoder; VoiceEncoder()"
+
+ENV PYTHONUNBUFFERED=1
+CMD ["python", "-m", "app.pipeline.main"]
+```
+
+- [ ] **Write `api/Dockerfile`**
+
+```dockerfile
+FROM python:3.12-slim
+
+WORKDIR /app
+COPY pyproject.toml .
+RUN pip install --no-cache-dir fastapi uvicorn[standard] pydantic-settings httpx livekit-api
+
+COPY app/ app/
+
+ENV PYTHONUNBUFFERED=1
+CMD ["uvicorn", "app.api.main:app", "--host", "0.0.0.0", "--port", "8310", "--factory"]
+```
+
+Update `app/api/main.py` to add an `app` factory callable at module level:
+
+```python
+# At the bottom of app/api/main.py, add:
+import os
+_app_instance = None
+def app_factory():
+ global _app_instance
+ if _app_instance is None:
+ from app.shared.config import get_settings
+ _app_instance = create_app(get_settings())
+ return _app_instance
+
+# Uvicorn --factory needs this to be importable as app.api.main:app
+# Use: uvicorn app.api.main:app_factory --factory
+```
+
+Change CMD in api/Dockerfile:
+```dockerfile
+CMD ["uvicorn", "app.api.main:app_factory", "--factory", "--host", "0.0.0.0", "--port", "8310"]
+```
+
+- [ ] **Write `cron/Dockerfile`**
+
+```dockerfile
+FROM python:3.12-slim
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libsndfile1 && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+COPY pyproject.toml .
+RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu \
+ && pip install --no-cache-dir -e "."
+COPY app/ app/
+
+# Pre-download resemblyzer model
+RUN python -c "from resemblyzer import VoiceEncoder; VoiceEncoder()"
+
+ENV PYTHONUNBUFFERED=1
+CMD ["python", "-m", "app.cron.main"]
+```
+
+- [ ] **Commit**
+
+```bash
+git add pipeline/ api/ cron/
+git commit -m "feat: Dockerfiles for pipeline, api, and cron containers"
+git push origin master
+```
+
+---
+
+## Task 14: Docker Compose + Traefik + deploy
+
+**Files:**
+- `compose/hf-pdocker-01/linkedstorm/docker-compose.yml` (in docker repo)
+- `compose/hf-pdocker-01/linkedstorm/.env.example` (in docker repo)
+- `stacks/traefik/dynamic/linkedstorm.yml` (in docker repo)
+
+- [ ] **Create `compose/hf-pdocker-01/linkedstorm/docker-compose.yml`** (in the docker repo at `/mnt/c/Users/Jeremy/projects/docker/`)
+
+```yaml
+services:
+ linkedstorm-pipeline:
+ build:
+ context: /opt/linkedstorm
+ dockerfile: pipeline/Dockerfile
+ container_name: linkedstorm-pipeline
+ restart: unless-stopped
+ ports:
+ - "8300:8300"
+ volumes:
+ - /mnt/docker-data/configs/linkedstorm:/data
+ environment:
+ - DB_PATH=/data/linkedstorm.db
+ - CLIPS_DIR=/data/clips
+ - SPEACHES_URL=${SPEACHES_URL}
+ - OLLAMA_URL=${OLLAMA_URL}
+ - LIVEKIT_API_KEY=${LIVEKIT_API_KEY}
+ - LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
+ networks:
+ - linkedstorm-net
+ labels:
+ - com.centurylinklabs.watchtower.enable=false
+
+ linkedstorm-api:
+ build:
+ context: /opt/linkedstorm
+ dockerfile: api/Dockerfile
+ container_name: linkedstorm-api
+ restart: unless-stopped
+ ports:
+ - "8310:8310"
+ volumes:
+ - /mnt/docker-data/configs/linkedstorm:/data
+ environment:
+ - DB_PATH=/data/linkedstorm.db
+ - CLIPS_DIR=/data/clips
+ - PIPELINE_INTERNAL_URL=http://linkedstorm-pipeline:8300
+ - OLLAMA_URL=${OLLAMA_URL}
+ - OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:14b}
+ - LIVEKIT_API_KEY=${LIVEKIT_API_KEY}
+ - LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
+ networks:
+ - linkedstorm-net
+ depends_on:
+ - linkedstorm-pipeline
+ labels:
+ - com.centurylinklabs.watchtower.enable=false
+
+ linkedstorm-cron:
+ build:
+ context: /opt/linkedstorm
+ dockerfile: cron/Dockerfile
+ container_name: linkedstorm-cron
+ restart: unless-stopped
+ volumes:
+ - /mnt/docker-data/configs/linkedstorm:/data
+ environment:
+ - DB_PATH=/data/linkedstorm.db
+ - CLIPS_DIR=/data/clips
+ - OLLAMA_URL=${OLLAMA_URL}
+ - OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:14b}
+ - CLIP_RETENTION_DAYS=${CLIP_RETENTION_DAYS:-30}
+ networks:
+ - linkedstorm-net
+ labels:
+ - com.centurylinklabs.watchtower.enable=false
+
+networks:
+ linkedstorm-net:
+ driver: bridge
+```
+
+- [ ] **Create `compose/hf-pdocker-01/linkedstorm/.env.example`**
+
+```bash
+SPEACHES_URL=http://192.168.86.150:8000
+OLLAMA_URL=http://192.168.86.150:11434
+OLLAMA_MODEL=qwen2.5:14b
+LIVEKIT_API_KEY=your-livekit-api-key
+LIVEKIT_API_SECRET=your-livekit-api-secret
+CLIP_RETENTION_DAYS=30
+```
+
+- [ ] **Create `stacks/traefik/dynamic/linkedstorm.yml`**
+
+```yaml
+http:
+ routers:
+ linkedstorm:
+ rule: "Host(`linkedstorm.hobbs.farm`)"
+ entryPoints:
+ - websecure
+ service: linkedstorm
+ tls:
+ certResolver: letsencrypt
+
+ services:
+ linkedstorm:
+ loadBalancer:
+ servers:
+ - url: "http://192.168.86.192:8310"
+```
+
+- [ ] **Commit to docker repo**
+
+```bash
+cd /mnt/c/Users/Jeremy/projects/docker
+git add compose/hf-pdocker-01/linkedstorm/ stacks/traefik/dynamic/linkedstorm.yml
+git commit -m "feat: linkedstorm docker-compose and Traefik route"
+git push origin master
+```
+
+---
+
+## Task 15: Deploy and verify on hf-pdocker-01
+
+- [ ] **SSH to hf-pdocker-01**
+
+```bash
+ssh glyptic@192.168.86.192
+```
+
+- [ ] **Clone linkedstorm repo and create data directory**
+
+```bash
+sudo git clone http://192.168.86.100:3003/jeremy/linkedstorm /opt/linkedstorm
+sudo mkdir -p /mnt/docker-data/configs/linkedstorm/clips
+sudo chown -R 1000:1000 /opt/linkedstorm
+```
+
+- [ ] **Copy compose files from NAS**
+
+```bash
+# Compose files are already synced to NAS via gitea-backup
+cd /mnt/docker-data/compose/hf-pdocker-01/linkedstorm
+# OR pull from docker repo:
+sudo git clone http://192.168.86.100:3003/jeremy/hobbs-iac /opt/hobbs-iac
+# then copy compose dir to /mnt/docker-data/compose/hf-pdocker-01/linkedstorm/
+```
+
+- [ ] **Create `.env` from example and fill in real credentials**
+
+```bash
+cd /mnt/docker-data/compose/hf-pdocker-01/linkedstorm
+cp .env.example .env
+# Edit .env: add real LIVEKIT_API_KEY and LIVEKIT_API_SECRET from Vaultwarden
+nano .env
+```
+
+- [ ] **Build and start all 3 containers**
+
+```bash
+cd /mnt/docker-data/compose/hf-pdocker-01/linkedstorm
+docker compose up -d --build
+```
+
+Expected: 3 containers start: `linkedstorm-pipeline`, `linkedstorm-api`, `linkedstorm-cron`
+
+- [ ] **Verify 1 — all containers healthy**
+
+```bash
+docker ps --format "table {{.Names}}\t{{.Status}}" | grep linkedstorm
+```
+
+Expected:
+```
+linkedstorm-pipeline Up X minutes
+linkedstorm-api Up X minutes
+linkedstorm-cron Up X minutes
+```
+
+- [ ] **Verify 2 — health endpoint responds**
+
+```bash
+curl -s http://localhost:8310/health # Note: add /health to api/main.py
+curl -s http://localhost:8300/health
+```
+
+Expected: `{"status":"ok"}` from both
+
+Note: Add health endpoint to `app/api/main.py` before deploying:
+```python
+@app.get("/health")
+async def health():
+ return {"status": "ok"}
+```
+
+- [ ] **Verify 3 — open capture page and speak**
+
+On any device on the LAN, open: `http://192.168.86.192:8310/capture?room=Kitchen`
+
+Allow microphone access, speak a sentence. Check dashboard at `http://192.168.86.192:8310`:
+
+```bash
+# Or check directly:
+curl -s "http://localhost:8310/api/utterances" | python3 -m json.tool | head -40
+```
+
+Expected: At least one utterance with a transcript appears within a few seconds.
+
+- [ ] **Verify 4 — speaker labeling**
+
+In the dashboard Speakers tab, click "Review now", tag the unknown utterance as your name. Speak again. Confirm the next utterance resolves to your name automatically.
+
+- [ ] **Verify 5 — search**
+
+Search for a word you spoke. Confirm it appears with keyword highlighting.
+
+- [ ] **Verify 6 — Ask AI**
+
+Ask "What did we talk about today?" in the Ask AI tab. Confirm Ollama returns a coherent response.
+
+- [ ] **Verify 7 — mute**
+
+Click mute on the capture page. Speak. Confirm no new utterances appear in the log. Unmute and speak again — utterance appears.
+
+- [ ] **Verify 8 — Traefik route**
+
+```bash
+curl -s https://linkedstorm.hobbs.farm/health
+```
+
+Expected: `{"status":"ok"}` (may take a minute for Let's Encrypt cert)
+
+- [ ] **Verify 9 — manual cron test**
+
+```bash
+docker exec linkedstorm-cron python -c "
+from app.shared.config import get_settings
+from app.shared.database import get_db
+from app.cron.daily_summary import generate_daily_summary
+import datetime
+s = get_settings()
+db = get_db(s)
+generate_daily_summary(datetime.date.today().isoformat(), s, db)
+print('Done')
+"
+```
+
+Expected: `Done` (if Ollama reachable, a row appears in daily_summaries)
+
+- [ ] **Verify 10 — add Prometheus probe** (in docker repo)
+
+```bash
+cd /mnt/c/Users/Jeremy/projects/docker
+```
+
+Edit `compose/hf-pdocker-01/monitoring/prometheus.yml` — add to the `http-pdocker01` job's `static_configs` targets:
+
+```yaml
+- http://192.168.86.192:8310/health
+```
+
+Then hot-reload Prometheus:
+
+```bash
+ssh glyptic@192.168.86.192 "curl -s -X POST http://localhost:9095/-/reload"
+```
+
+- [ ] **Final commit to docker repo**
+
+```bash
+cd /mnt/c/Users/Jeremy/projects/docker
+git add compose/hf-pdocker-01/monitoring/
+git commit -m "monitoring: add Prometheus probe for linkedstorm-api"
+git push origin master
+```
+
+---
+
+## Self-Review Notes
+
+**Spec coverage check:**
+- ✅ 3 containers: pipeline (Task 7), api (Tasks 8-9, 12-13), cron (Task 10)
+- ✅ Browser capture page with WebSocket + mute (Task 11)
+- ✅ Silero VAD (Task 5)
+- ✅ Whisper STT (Task 6, processor.py)
+- ✅ resemblyzer voice embedding + cosine similarity (Task 4)
+- ✅ Three-tier matching (Task 4, recognition.py)
+- ✅ SQLite schema + FTS5 (Task 3)
+- ✅ Speaker labeling workflow — tag endpoint (Task 8, utterances.py)
+- ✅ Daily log timeline (Task 12, app.js)
+- ✅ Full-text search (Task 9, search.py + Task 12)
+- ✅ RAG via Ollama (Task 9, rag.py + Task 12)
+- ✅ Room status + mute relay (Task 9, rooms.py + Task 12)
+- ✅ Nightly daily summary (Task 10)
+- ✅ Unknown speaker clustering (Task 10)
+- ✅ 30-day clip purge (Task 10)
+- ✅ Docker Compose (Task 14)
+- ✅ Traefik route (Task 14)
+- ✅ Prometheus probe (Task 15)
+- ⚠️ LiveKit publishing from pipeline — simplified: capture page connects directly to LiveKit via JS SDK for live monitoring (future work, out of scope for v1). Replay uses stored WAV clips via /api/utterances/{id}/clip.
+
+**Type consistency:** `find_best_speaker_match` returns `tuple[str, int | None, float | None]` — used consistently in recognition.py (Task 4) and processor.py (Task 6). `pack_embedding`/`unpack_embedding` used in recognition.py, processor.py, and utterances.py — same import path `app.pipeline.recognition` throughout.
diff --git a/docs/superpowers/specs/2026-03-11-rename-bart-design.md b/docs/superpowers/specs/2026-03-11-rename-bart-design.md
new file mode 100644
index 0000000..ab636d1
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-11-rename-bart-design.md
@@ -0,0 +1,89 @@
+# Rename bart → hf-pdocker-03 Design
+
+**Date:** 2026-03-11
+**Server:** 192.168.86.167 (currently "bart", becoming "hf-pdocker-03")
+
+## Overview
+
+Rename the server "bart" to "hf-pdocker-03" across the entire infrastructure — hostname, repository files, NAS paths, Docker Swarm node, monitoring, and documentation. The IP address 192.168.86.167 does not change.
+
+## Scope
+
+### Server-Level
+
+- Change hostname from `bart` to `hf-pdocker-03` via `hostnamectl`
+- Update `/etc/hosts` on the server itself
+- Docker Swarm: drain → remove → change hostname → re-join with new name
+- Swarm global services (Telegraf, Node-exporter) auto-reschedule on new node name
+
+### NAS Path Rename
+
+- `/mnt/docker-data/compose/bart/` → `/mnt/docker-data/compose/hf-pdocker-03/`
+- **Onyx exception:** `compose/hf-pdocker-03/onyx/docker-compose.yml` has hardcoded volume mounts referencing `/mnt/docker-data/compose/bart/onyx`. These must be updated in the compose file AND the Onyx containers redeployed after the NAS rename.
+- All other running containers are unaffected (they don't reference the compose file path at runtime)
+
+### Repository Files
+
+**Directory rename:**
+- `compose/bart/` → `compose/hf-pdocker-03/` (14 files across 8 subdirectories)
+ - Active services: `media/`, `openvas/`, `watchtower/`, `onyx/`
+ - Stale failover dirs (preserved for reference): `smarthome/`, `matrix/`, `guacamole/`, `ai-agents/`
+
+**File inventory (all files containing "bart"):**
+
+| File | Type | Action |
+|------|------|--------|
+| `CLAUDE.md` | Config | Must update — service distribution, storage, internal services |
+| `stacks/monitoring/prometheus.yml` | Config | Must update — hostname label, job name `http-bart` → `http-hf-pdocker-03` |
+| `stacks/monitoring/alerts.yml` | Config | Must update — PromQL `hostname="bart"` matchers. Review: elevated 90% CPU threshold was for Frigate, which is now on hf-pdocker-01, not this node. Consider reverting to standard 80% threshold. |
+| `stacks/monitoring/matrix-receiver-config.yml` | Config | Must update — "bart" in comment. Note: file appears stale (still references old IPs). Update comment only. |
+| `stacks/frigate/docker-compose.yml` | Stale Swarm def | Update for consistency — has `node.hostname == bart` placement constraint. This is an unused Swarm stack (Frigate runs as standalone Compose on hf-pdocker-01). |
+| `compose/bart/onyx/docker-compose.yml` | Active compose | **CRITICAL** — has hardcoded volume mount `/mnt/docker-data/compose/bart/onyx:/mnt/gitea_patch:ro`. Must update path AND redeploy Onyx containers. |
+| `.claude/agents/gitea-backup.md` | Agent def | Must update |
+| `.claude/agents/alert-manager.md` | Agent def | Must update |
+| `.claude/agents/documentation-keeper.md` | Agent def | Must update |
+| `DEPLOYMENT_SUMMARY.md` | Doc | Must update |
+| `docs/plans/2026-03-08-hf-pdocker-01-phase2-migration.md` | Historical | Update for consistency |
+| `docs/plans/2026-03-08-hf-pdocker-01-rebuild.md` | Historical | Update for consistency |
+| `docs/plans/2026-03-07-frigate-notify-v2-plan.md` | Historical | Update for consistency |
+| `docs/plans/2026-03-05-farm-manager.md` | Historical | Update for consistency |
+| `docs/plans/2026-03-05-farm-manager-design.md` | Historical | Update for consistency |
+
+**Memory/auto-memory files:**
+- `MEMORY.md` — ~30 references to "bart"
+- `memory/migration-details.md` — bart references
+
+### Documentation
+
+- Wiki pages — infrastructure overview, service access, changelog
+
+## Order of Operations
+
+1. **Repo file updates** — rename `compose/bart/` directory, update all file contents (including Onyx compose volume path). No service impact yet.
+2. **SCP updated Onyx compose file** to NAS at the current path (`/mnt/docker-data/compose/bart/onyx/docker-compose.yml`) with the NEW volume path (`/mnt/docker-data/compose/hf-pdocker-03/onyx`). This prepares for the rename.
+3. **NAS path rename** — `mv /mnt/docker-data/compose/bart /mnt/docker-data/compose/hf-pdocker-03` (via SSH). Atomic on NFS.
+4. **Redeploy Onyx** — `cd /mnt/docker-data/compose/hf-pdocker-03/onyx && docker compose up -d` to pick up the new volume mount path.
+5. **Server hostname change** — `sudo hostnamectl set-hostname hf-pdocker-03` + update `/etc/hosts` on 192.168.86.167.
+6. **Docker Swarm re-join** — on hf-pdocker-01: drain old bart node, remove it. On 192.168.86.167: leave swarm, re-join with new hostname. Global services auto-schedule. ~30s gap.
+7. **Prometheus + alerts** — SCP updated prometheus.yml and alerts.yml, hot-reload.
+8. **Update /etc/hosts on other nodes** — if hf-pdocker-01 or hf-pdocker-02 have "bart" in /etc/hosts, update to hf-pdocker-03.
+9. **MEMORY.md + Wiki** — update all documentation.
+10. **Commit + push to Gitea**.
+
+## Risk Assessment
+
+- **Low risk:** Running Compose services (Jellyfin, Sonarr, Radarr, etc.) are unaffected — containers keep running.
+- **Onyx restart required:** Onyx containers need redeployment after NAS rename to pick up new volume mount path. Brief downtime for Onyx (~30s).
+- **Low risk:** Swarm global services have ~30s gap during node re-join.
+- **No IP changes:** All service-to-service communication uses IPs, not hostnames. Nothing breaks at the network level.
+- **NAS path rename is atomic:** `mv` on NFS is atomic for same-filesystem renames.
+
+## Verification
+
+After completion:
+- `hostname` on 192.168.86.167 returns `hf-pdocker-03`
+- `docker node ls` on hf-pdocker-01 shows `hf-pdocker-03` instead of `bart`
+- All containers on 192.168.86.167 still running (`docker ps`)
+- Onyx containers running with new volume mount path
+- Prometheus targets for hf-pdocker-03 are UP
+- `grep -r bart` in repo returns zero matches (excluding git history)
diff --git a/stacks/traefik/dynamic/gitea.yml b/stacks/traefik/dynamic/gitea.yml
index d2d5c4f..e070532 100644
--- a/stacks/traefik/dynamic/gitea.yml
+++ b/stacks/traefik/dynamic/gitea.yml
@@ -4,6 +4,8 @@ http:
rule: "Host(`git.hobbs.farm`)"
entryPoints:
- websecure
+ middlewares:
+ - security-headers
service: gitea
tls:
certResolver: letsencrypt
diff --git a/stacks/traefik/dynamic/openwebui.yml b/stacks/traefik/dynamic/openwebui.yml
deleted file mode 100644
index 9053383..0000000
--- a/stacks/traefik/dynamic/openwebui.yml
+++ /dev/null
@@ -1,26 +0,0 @@
-http:
- routers:
- openwebui:
- rule: "Host(`open.hobbs.farm`)"
- entryPoints:
- - websecure
- service: openwebui
- tls:
- certResolver: letsencrypt
- openwebui-http:
- rule: "Host(`open.hobbs.farm`)"
- entryPoints:
- - web
- middlewares:
- - openwebui-https-redirect
- service: openwebui
- middlewares:
- openwebui-https-redirect:
- redirectScheme:
- scheme: https
- permanent: true
- services:
- openwebui:
- loadBalancer:
- servers:
- - url: "http://192.168.86.192:3010"
diff --git a/stacks/traefik/dynamic/security-headers.yml b/stacks/traefik/dynamic/security-headers.yml
new file mode 100644
index 0000000..0000216
--- /dev/null
+++ b/stacks/traefik/dynamic/security-headers.yml
@@ -0,0 +1,60 @@
+http:
+ middlewares:
+ security-headers:
+ headers:
+ # HSTS - force browsers to use HTTPS for 1 year, include subdomains
+ stsSeconds: 31536000
+ stsIncludeSubdomains: true
+ stsPreload: true
+ # Prevent MIME-type sniffing
+ contentTypeNosniff: true
+ # Block rendering in iframes from other origins
+ frameDeny: true
+ # Referrer policy - send origin only on cross-origin requests
+ referrerPolicy: "strict-origin-when-cross-origin"
+ # Permissions policy - restrict browser features
+ permissionsPolicy: "camera=(), microphone=(), geolocation=(), payment=()"
+ # Prevent browsers from caching sensitive responses
+ browserXssFilter: true
+ # Remove server identity header
+ customResponseHeaders:
+ X-Powered-By: ""
+ Server: ""
+
+ # Variant that allows microphone (for VoiceCoach)
+ security-headers-allow-mic:
+ headers:
+ stsSeconds: 31536000
+ stsIncludeSubdomains: true
+ stsPreload: true
+ contentTypeNosniff: true
+ frameDeny: true
+ referrerPolicy: "strict-origin-when-cross-origin"
+ permissionsPolicy: "camera=(), microphone=(self), geolocation=(), payment=()"
+ browserXssFilter: true
+ customResponseHeaders:
+ X-Powered-By: ""
+ Server: ""
+
+ # BasicAuth for sensitive services (Guacamole)
+ solo-basicauth:
+ basicAuth:
+ users:
+ - "glyptic:$2y$05$6AcmRIjMX6YrxY3fACeDhui4ghBGZtjmiPHbzWDb.qZ.8Fdip4GM."
+ realm: "Restricted"
+
+ # Variant that allows framing (for Guacamole, Home Assistant embeds)
+ security-headers-allow-frames:
+ headers:
+ stsSeconds: 31536000
+ stsIncludeSubdomains: true
+ stsPreload: true
+ contentTypeNosniff: true
+ frameDeny: false
+ customFrameOptionsValue: "SAMEORIGIN"
+ referrerPolicy: "strict-origin-when-cross-origin"
+ permissionsPolicy: "camera=(), microphone=(), geolocation=(), payment=()"
+ browserXssFilter: true
+ customResponseHeaders:
+ X-Powered-By: ""
+ Server: ""
diff --git a/stacks/traefik/dynamic/tls-options.yml b/stacks/traefik/dynamic/tls-options.yml
new file mode 100644
index 0000000..6df92f9
--- /dev/null
+++ b/stacks/traefik/dynamic/tls-options.yml
@@ -0,0 +1,18 @@
+tls:
+ options:
+ default:
+ minVersion: VersionTLS12
+ cipherSuites:
+ # TLS 1.3 ciphers are always enabled and not configurable
+ # These are the strong TLS 1.2 ciphers
+ - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
+ - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
+ - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
+ - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
+ - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
+ - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
+ curvePreferences:
+ - X25519
+ - CurveP256
+ - CurveP384
+ sniStrict: true
diff --git a/stacks/traefik/dynamic/vaultwarden.yml b/stacks/traefik/dynamic/vaultwarden.yml
index f99021a..16725db 100644
--- a/stacks/traefik/dynamic/vaultwarden.yml
+++ b/stacks/traefik/dynamic/vaultwarden.yml
@@ -4,6 +4,8 @@ http:
rule: "Host(`vault.hobbs.farm`)"
entryPoints:
- websecure
+ middlewares:
+ - security-headers
service: vault
tls:
certResolver: letsencrypt
diff --git a/vllm-hermes-deployment-brief.md b/vllm-hermes-deployment-brief.md
new file mode 100644
index 0000000..fd321a6
--- /dev/null
+++ b/vllm-hermes-deployment-brief.md
@@ -0,0 +1,215 @@
+# Deployment Brief: vLLM serving Hermes 4.3 for OpenClaw/Pluto
+
+**Audience:** Claude Code, executing autonomously on Jeremy's homelab.
+**Goal:** Stand up a vLLM OpenAI-compatible inference server hosting NousResearch Hermes 4.3, with native Hermes tool-call parsing, and wire it into the existing OpenClaw gateway as Pluto's reasoning model.
+
+---
+
+## 0. Why this exists (context you need)
+
+We tried serving Hermes 4.3 36B (Q4_K_M GGUF) through Ollama on the Windows box. The model loads and runs at ~98% GPU after forcing `num_gpu 64`, but **structured tool calling fails** — Ollama returns empty `content` and no `tool_calls` array, because Ollama's parser doesn't extract Hermes's `` XML format into the OpenAI-structured field on a self-imported GGUF. This is a known, recurring Ollama sharp edge.
+
+vLLM solves this directly: the `--tool-call-parser hermes` flag parses the XML `` format natively into the OpenAI-compatible response schema, no post-processing. That is the entire reason for this migration.
+
+**Do not** spend effort trying to fix the Ollama path. The decision to move to vLLM is final.
+
+---
+
+## 1. Hard constraints — read before doing anything
+
+1. **The GPU is in the Windows desktop, NOT the Linux swarm.**
+ - Host: `DESKTOP-5AHPRKR` — ASUS Z790 (TUF GAMING Z790-PLUS WIFI), i7-13700KF, 32GB RAM, **RTX 3090 (24GB VRAM)**.
+ - The Docker Swarm nodes (`hf-pdocker-01`, `hf-pdocker-02`, `bart`) are CPU-only and **cannot** run a 36B model usefully. Do not attempt to schedule vLLM onto the swarm.
+ - vLLM is **Linux/CUDA only**. On Windows this means running inside **WSL2** (Ubuntu) with CUDA passthrough, using the official vLLM Docker image via Docker Desktop's WSL2 backend, OR a native WSL2 Python install. Docker image is preferred for reproducibility.
+
+2. **24GB VRAM is the binding limit.** Hermes 4.3 36B in FP16 is ~72GB — impossible here. Options, in order of preference:
+ - **(A)** A pre-quantized **AWQ or GPTQ 4-bit** build of Hermes 4.3 36B (~18-20GB weights). Tight but workable with `--gpu-memory-utilization 0.95`, `--max-model-len 8192`, and `--enforce-eager` (skip CUDA graph capture to save VRAM). **First choice if a quant exists.**
+ - **(B)** If no 36B AWQ/GPTQ exists, fall back to **Hermes 4.3 in a smaller parameter size** if Nous published one (e.g. a 14B), served in FP16 or AWQ — fits comfortably, full context, fast. **Strongly preferred over fighting the 36B on 24GB.**
+ - **(C)** Last resort: serve the **GGUF Q4** through vLLM (vLLM supports GGUF). Weaker than AWQ in vLLM and partially defeats the point, but the hermes parser still works. Only if A and B are unavailable.
+
+3. **WSL2 will fight you for RAM.** Jeremy's box already had `VmmemWSL` consuming ~13GB. There may now be a `C:\Users\Jeremy\.wslconfig` capping it. Check it. vLLM's model loading stages through system RAM, so ensure WSL2's memory cap is high enough (≥24GB) for the load, OR raise it temporarily. Coordinate: Docker Desktop restarts WSL2 automatically, so don't rely on `wsl --shutdown` to free RAM permanently.
+
+4. **Stop the Ollama server before testing** to avoid port/VRAM contention. Ollama holds VRAM while a model is resident. `ollama stop hermes` and confirm `ollama ps` is empty, or quit the Ollama app entirely. vLLM and Ollama cannot both hold the 3090 at once.
+
+---
+
+## 2. Pre-flight verification (do these first, report results)
+
+Run inside WSL2 (Ubuntu):
+
+```bash
+# Confirm CUDA reaches WSL2 and sees the 3090
+nvidia-smi
+# Expect: "NVIDIA GeForce RTX 3090", 24576 MiB total, driver present.
+# If this fails, CUDA-on-WSL2 isn't set up — STOP and report.
+
+# Confirm Docker can see the GPU
+docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi
+# Expect same output from inside a container.
+```
+
+If `nvidia-smi` works on the Windows side but not in WSL2, the fix is installing the WSL2 CUDA toolkit (NOT a full driver — the Windows driver provides the WSL2 GPU stub). Report before proceeding so Jeremy can confirm.
+
+Also check current RAM headroom and the WSL config:
+
+```bash
+cat /mnt/c/Users/Jeremy/.wslconfig 2>/dev/null || echo "no .wslconfig"
+free -h
+```
+
+---
+
+## 3. Model selection (decide, then download)
+
+**Step 3a — Find the best-fitting quant.** Search HuggingFace for, in priority order:
+1. `Hermes-4.3-36B` AWQ (search orgs: NousResearch, and community quantizers like `casperhansen`, `solidrust`, `bartowski` for AWQ/GPTQ variants)
+2. A smaller Hermes 4.3 (14B-class) if one exists — this is genuinely the easier, faster path on 24GB
+3. GGUF Q4 as fallback (we already have `hf.co/NousResearch/Hermes-4.3-36B-GGUF:Q4_K_M`)
+
+**Report what you find and which option (A/B/C from §1.2) you're proceeding with before downloading 20GB.** If a 36B AWQ exists, confirm its repo name. If the only realistic fit is a smaller Hermes 4.3, say so — Jeremy explicitly prefers a model that runs 100% on-GPU with full context over grinding a 36B into a too-small card.
+
+**Step 3b — Download to a persistent WSL2 path** (not the container, so it survives restarts):
+
+```bash
+mkdir -p ~/models
+pip install -U "huggingface_hub[cli]"
+huggingface-cli download --local-dir ~/models/hermes-4.3
+```
+
+---
+
+## 4. Launch vLLM (Docker, OpenAI-compatible)
+
+Create `~/vllm-hermes/docker-compose.yml`. Template for **Option A (36B AWQ)** — adjust `--quantization`, `--model`, and `--max-model-len` per the resolved model:
+
+```yaml
+services:
+ vllm-hermes:
+ image: vllm/vllm-openai:latest
+ container_name: vllm-hermes
+ runtime: nvidia
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - driver: nvidia
+ count: all
+ capabilities: [gpu]
+ ports:
+ - "8000:8000"
+ volumes:
+ - ~/models/hermes-4.3:/models/hermes-4.3
+ ipc: host
+ command: >
+ --model /models/hermes-4.3
+ --served-model-name hermes
+ --quantization awq
+ --gpu-memory-utilization 0.95
+ --max-model-len 8192
+ --enforce-eager
+ --enable-auto-tool-choice
+ --tool-call-parser hermes
+ --host 0.0.0.0
+ --port 8000
+ restart: unless-stopped
+```
+
+Key flag rationale:
+- `--enable-auto-tool-choice --tool-call-parser hermes` — **the whole point.** Native Hermes XML tool-call parsing into OpenAI schema.
+- `--quantization awq` — set to `gptq` for GPTQ, omit entirely for GGUF (use `--quantization gguf` and point `--model` at the .gguf file) or for an unquantized smaller model.
+- `--enforce-eager` — disables CUDA graph capture, saving ~1-3GB VRAM. **Critical on the tight 24GB 36B path.** Remove it if you have headroom (smaller model) for a latency improvement.
+- `--max-model-len 8192` — keep KV cache small to fit. Raise cautiously if VRAM allows; watch for OOM on startup.
+- `--gpu-memory-utilization 0.95` — aggressive, appropriate for a dedicated inference box. Drop to 0.90 if the desktop also drives a display off the 3090 (it does — there's a monitor at 1680x1050 on it; budget ~0.5-1GB for the desktop, so 0.90 may be safer).
+
+Launch and watch logs:
+
+```bash
+cd ~/vllm-hermes
+docker compose up -d
+docker compose logs -f vllm-hermes
+# Wait for: "Application startup complete" and "Uvicorn running on http://0.0.0.0:8000"
+```
+
+**If it OOMs on startup:** lower `--max-model-len` to 4096, confirm `--enforce-eager` is present, drop `--gpu-memory-utilization` to 0.90. If it still won't fit, that's the signal Option A is not viable on this card — fall back to Option B (smaller Hermes 4.3) and report.
+
+---
+
+## 5. Verification — the test that actually matters
+
+This is the exact test that FAILED on Ollama. It must pass on vLLM.
+
+```bash
+curl -s http://localhost:8000/v1/chat/completions \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "hermes",
+ "messages": [{"role": "user", "content": "What is 8347 multiplied by 291? Use the calculator tool."}],
+ "tools": [{
+ "type": "function",
+ "function": {
+ "name": "calculator",
+ "description": "Multiply two numbers",
+ "parameters": {
+ "type": "object",
+ "properties": {"a": {"type": "number"}, "b": {"type": "number"}},
+ "required": ["a", "b"]
+ }
+ }
+ }],
+ "tool_choice": "auto"
+ }' | python3 -m json.tool
+```
+
+**PASS criteria:** the response `choices[0].message` has a populated `tool_calls` array containing `function.name == "calculator"` and `function.arguments` parsing to `{"a": 8347, "b": 291}` (or equivalent), with `content` empty/null. That is structured tool calling working.
+
+**FAIL:** if `tool_calls` is null/absent and the JSON is dumped in `content` as a string, the parser flag didn't take — verify `--tool-call-parser hermes` is in the running command (`docker compose logs` shows the args at boot) and that the model's chat template actually emits `` tags. Report the raw response.
+
+Also sanity-check throughput:
+
+```bash
+# Plain generation latency
+curl -s http://localhost:8000/v1/chat/completions -H "Content-Type: application/json" \
+ -d '{"model":"hermes","messages":[{"role":"user","content":"Say hello in one sentence."}]}' \
+ | python3 -m json.tool
+nvidia-smi # confirm VRAM occupancy and that it's the vLLM process
+```
+
+---
+
+## 6. Wire into OpenClaw (Pluto's brain)
+
+OpenClaw is model-agnostic and speaks the OpenAI API. Point it at the vLLM endpoint. The vLLM server is on the Windows box at `http://:8000/v1` (or `http://localhost:8000/v1` if OpenClaw runs on the same Windows box; confirm where Pluto's gateway actually runs — it may be on the swarm).
+
+In OpenClaw's model/provider config:
+- **Base URL:** `http://:8000/v1`
+- **Model name:** `hermes` (matches `--served-model-name`)
+- **API key:** any non-empty string (vLLM ignores it by default; use `EMPTY` or `sk-noauth`)
+
+**Networking notes:**
+- vLLM in the WSL2 container binds `0.0.0.0:8000`, but WSL2 networking means the port must be reachable from the Windows host and the LAN. WSL2 usually auto-forwards localhost; for LAN access you may need a `netsh interface portproxy` rule on Windows forwarding the host's LAN IP:8000 to the WSL2 IP:8000, plus a Windows Firewall inbound rule for port 8000 **scoped to the LAN subnet only**. Set this up and verify from another machine with `curl http://:8000/v1/models`.
+- Do **not** expose port 8000 to the internet. If remote access is wanted later, that goes behind Traefik with forward-auth on the swarm, tunneled to the Windows box — a separate task, not part of this brief.
+
+After wiring, restart the OpenClaw gateway and run a Pluto interaction that should trigger a tool (whatever real tools Pluto has registered) to confirm end-to-end tool execution, not just the synthetic calculator test.
+
+---
+
+## 7. Operational notes / gotchas
+
+- **Display + inference share the 3090.** The desktop's monitor runs off the same card. Under VRAM pressure this can cause display glitches or vLLM OOM. The `0.90` utilization setting accounts for this; if Jeremy ever runs the box headless (RDP only, which the earlier session showed — there was a Remote Display Adapter), more VRAM frees up and utilization can go to 0.95+.
+- **vLLM holds VRAM for its lifetime.** It does not unload like Ollama. That's expected and desirable for an always-on Pluto brain, but it means the 3090 is committed — no gaming / other GPU work while it runs.
+- **Auto-start on boot:** `restart: unless-stopped` handles container restarts, but WSL2 + Docker Desktop must themselves start on Windows login. For true always-on, enable Docker Desktop "Start on login" and consider WSL2 boot via Task Scheduler, OR (better long-term) move this whole stack to a dedicated headless Linux box with the 3090 if one becomes available. Flag this to Jeremy as a follow-up if always-on reliability matters for VoiceCoach.
+- **Context length is capped at 8192 here** for VRAM reasons. Hermes 4.3 supports far more (trained to 512K), but you can't afford the KV cache on 24GB. If Pluto workflows need longer context, that's a hardware conversation (bigger card / second GPU), not a config tweak.
+- **Version pinning:** `vllm/vllm-openai:latest` is convenient but pin to a specific tag for production stability once verified working, so an image update doesn't silently break the tool parser.
+
+---
+
+## 8. Report back to Jeremy with
+
+1. `nvidia-smi` output from inside WSL2 (GPU visible y/n).
+2. Which model option (A/B/C) you proceeded with and the exact repo ID.
+3. The full JSON from the §5 calculator tool-call test (the pass/fail evidence).
+4. Measured tokens/sec and VRAM occupancy from `nvidia-smi`.
+5. Whether OpenClaw end-to-end tool execution worked with the real Pluto toolset.
+6. Any networking steps needed for LAN access (portproxy/firewall rules added).
+
+Do not mark this complete until the §5 tool-call test passes with a structured `tool_calls` array. That single result is the definition of done for the inference layer.