From 27f6d95bf69406201e132dc1b8f16630c34cee47 Mon Sep 17 00:00:00 2001 From: jeremy Date: Sat, 11 Jul 2026 11:19:21 -0500 Subject: [PATCH] chore: sync accumulated infra changes and docs Removals: openwebui and netclaw compose + Traefik routes (both decommissioned). Updates: litellm, n8n, vaultwarden, ai-agents, media, traefik routes, CLAUDE.md (nodes/ports/known-issues), agent definitions. Additions: Rongorongo tree, LinkedStorm plan, rename-bart spec, vllm brief, tls-options and security-headers Traefik config. Co-Authored-By: Claude Opus 4.8 --- .claude/agents/documentation-keeper.md | 1 + .claude/settings.local.json | 3 +- CLAUDE.md | 32 +- Rongorongo/RONGORONGO_DEPLOY.md | 322 ++ Rongorongo/_extracted/rongorongo/Dockerfile | 18 + .../rongorongo/deploy/RONGORONGO_DEPLOY.md | 322 ++ .../_extracted/rongorongo/frontend/index.html | 2057 +++++++++++ Rongorongo/_extracted/rongorongo/nginx.conf | 41 + Rongorongo/_extracted/rongorongo/stack.yml | 48 + Rongorongo/files.zip | Bin 0 -> 54574 bytes Rongorongo/rongorongo-deploy.zip | Bin 0 -> 29126 bytes Rongorongo/rongorongo-v3.html | 2057 +++++++++++ compose/bart/media/docker-compose.yml | 2 +- .../friday-voice/Dockerfile.hermes | 17 + .../friday-voice/docker-compose.yml | 49 + compose/gpu-machine/friday-voice/hermes-wrap | 5 + .../ai-agents/docker-compose.yml | 74 +- .../hf-pdocker-01/litellm/docker-compose.yml | 2 +- .../openwebui/docker-compose.yml | 29 - compose/hf-pdocker-02/n8n/docker-compose.yml | 56 +- compose/hf-pdocker-02/netclaw/.env.example | 2 - compose/hf-pdocker-02/netclaw/Dockerfile | 38 - .../hf-pdocker-02/netclaw/docker-compose.yml | 20 - compose/hf-pdocker-02/netclaw/entrypoint.sh | 72 - .../hf-pdocker-02/traefik/dynamic-routes.yml | 44 + .../vaultwarden/docker-compose.yml | 1 + .../2026-05-28-linkedstorm-implementation.md | 3187 +++++++++++++++++ .../specs/2026-03-11-rename-bart-design.md | 89 + stacks/traefik/dynamic/gitea.yml | 2 + stacks/traefik/dynamic/openwebui.yml | 26 - stacks/traefik/dynamic/security-headers.yml | 60 + stacks/traefik/dynamic/tls-options.yml | 18 + stacks/traefik/dynamic/vaultwarden.yml | 2 + vllm-hermes-deployment-brief.md | 215 ++ 34 files changed, 8589 insertions(+), 322 deletions(-) create mode 100644 Rongorongo/RONGORONGO_DEPLOY.md create mode 100644 Rongorongo/_extracted/rongorongo/Dockerfile create mode 100644 Rongorongo/_extracted/rongorongo/deploy/RONGORONGO_DEPLOY.md create mode 100644 Rongorongo/_extracted/rongorongo/frontend/index.html create mode 100644 Rongorongo/_extracted/rongorongo/nginx.conf create mode 100644 Rongorongo/_extracted/rongorongo/stack.yml create mode 100644 Rongorongo/files.zip create mode 100644 Rongorongo/rongorongo-deploy.zip create mode 100644 Rongorongo/rongorongo-v3.html create mode 100644 compose/gpu-machine/friday-voice/Dockerfile.hermes create mode 100644 compose/gpu-machine/friday-voice/docker-compose.yml create mode 100644 compose/gpu-machine/friday-voice/hermes-wrap delete mode 100644 compose/hf-pdocker-01/openwebui/docker-compose.yml delete mode 100644 compose/hf-pdocker-02/netclaw/.env.example delete mode 100644 compose/hf-pdocker-02/netclaw/Dockerfile delete mode 100644 compose/hf-pdocker-02/netclaw/docker-compose.yml delete mode 100644 compose/hf-pdocker-02/netclaw/entrypoint.sh create mode 100644 docs/superpowers/plans/2026-05-28-linkedstorm-implementation.md create mode 100644 docs/superpowers/specs/2026-03-11-rename-bart-design.md delete mode 100644 stacks/traefik/dynamic/openwebui.yml create mode 100644 stacks/traefik/dynamic/security-headers.yml create mode 100644 stacks/traefik/dynamic/tls-options.yml create mode 100644 vllm-hermes-deployment-brief.md 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 @@ + + + + + +Rongorongo · Decipherment Engine + + + + + + +
+ + +
+
+ Rongorongo + Decipherment Engine +
+
+
+ Corpus 0 images · + Glyphs 0 · + Anchors 0 +
+ + +
+
+ + + + + +
+ + +
+ + +
+ +
𓀠
+
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)
+
+
+
+ + +
+ + +
+
+
Select Image to Annotate
+ +
+ +
+
+ + + Draw a box around a glyph +
+
+
+ + +
+
+ + +
+
+
+ +
+
Glyph Registry
+
+
No glyphs tagged yet
+
+ +
+
+
+ + +
+ + +
+
Analysis Mode
+
+ + + + + + +
+ +
+ +
+
+
+ +
+ ◉ ANALYSIS OUTPUT +
Upload images and run an analysis to begin
+
+ + +
+ + +
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ +
+ ◇ SEQUENCE MATCHES +
Load multiple tablet images then run the matcher
+
+
+ + +
+ + +
+
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 +
+ +
+ + +
+ + +
+ + +
+
+
+
Define New Anchor
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
Anchor Registry
+
+
No anchors defined
+
+
+
+
+ + +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ +
+ ⊕ PROPAGATION OUTPUT +
Define anchors and select one to simulate propagation
+
+
+ + +
+ + +
+
Select Anchors to Test
+
+
No anchors defined. Go to Anchor Lab first.
+
+
+
+ +
+ + + + +
+
+
+ + +
+
+ +
+ + + +
+ ✦ FEASIBILITY REPORT +
Define and select anchors, then run tests
+
+ + +
+ + +
+ + +
+
Run feasibility tests to populate confidence tracking
+
+
+ + +
+ + +
+ +
+
Ask Against Scholarship
+ + +
+ 📜 SCHOLARSHIP ANALYSIS +
Ask a question about existing decipherment scholarship
+
+
+
+ + +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+ + + + + diff --git a/Rongorongo/_extracted/rongorongo/nginx.conf b/Rongorongo/_extracted/rongorongo/nginx.conf new file mode 100644 index 0000000..dafc9b6 --- /dev/null +++ b/Rongorongo/_extracted/rongorongo/nginx.conf @@ -0,0 +1,41 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # CSP — allows Anthropic API calls and Google Fonts + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' https://api.anthropic.com; worker-src blob:;" always; + + # Gzip + gzip on; + gzip_types text/html text/css application/javascript; + gzip_min_length 1024; + + # Health endpoint + location /health { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + expires -1; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + + # Cache static assets aggressively if you add any + location ~* \.(js|css|png|jpg|jpeg|gif|ico|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/Rongorongo/_extracted/rongorongo/stack.yml b/Rongorongo/_extracted/rongorongo/stack.yml new file mode 100644 index 0000000..2ba60cd --- /dev/null +++ b/Rongorongo/_extracted/rongorongo/stack.yml @@ -0,0 +1,48 @@ +version: "3.8" + +services: + rongorongo: + image: registry.hobbs.farm/rongorongo:latest + networks: + - traefik-public + deploy: + replicas: 1 + restart_policy: + condition: on-failure + delay: 10s + max_attempts: 3 + update_config: + parallelism: 1 + delay: 10s + failure_action: rollback + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-public" + + # HTTP → HTTPS redirect + - "traefik.http.routers.rongorongo-http.rule=Host(`rongorongo.hobbs.farm`)" + - "traefik.http.routers.rongorongo-http.entrypoints=web" + - "traefik.http.routers.rongorongo-http.middlewares=redirect-to-https" + + # HTTPS router + - "traefik.http.routers.rongorongo.rule=Host(`rongorongo.hobbs.farm`)" + - "traefik.http.routers.rongorongo.entrypoints=websecure" + - "traefik.http.routers.rongorongo.tls=true" + - "traefik.http.routers.rongorongo.tls.certresolver=cloudflare" + + # Service + - "traefik.http.services.rongorongo.loadbalancer.server.port=80" + + # Middlewares + - "traefik.http.routers.rongorongo.middlewares=rongorongo-headers" + - "traefik.http.middlewares.rongorongo-headers.headers.customResponseHeaders.X-Robots-Tag=noindex,nofollow" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +networks: + traefik-public: + external: true diff --git a/Rongorongo/files.zip b/Rongorongo/files.zip new file mode 100644 index 0000000000000000000000000000000000000000..0893e653277305261766b1c045443728192ea2dc GIT binary patch literal 54574 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 literal 0 HcmV?d00001 diff --git a/Rongorongo/rongorongo-deploy.zip b/Rongorongo/rongorongo-deploy.zip new file mode 100644 index 0000000000000000000000000000000000000000..558a900f515709ba30254866237fd7305e037db6 GIT binary patch literal 29126 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 literal 0 HcmV?d00001 diff --git a/Rongorongo/rongorongo-v3.html b/Rongorongo/rongorongo-v3.html new file mode 100644 index 0000000..3a8b08c --- /dev/null +++ b/Rongorongo/rongorongo-v3.html @@ -0,0 +1,2057 @@ + + + + + +Rongorongo · Decipherment Engine + + + + + + +
+ + +
+
+ Rongorongo + Decipherment Engine +
+
+
+ Corpus 0 images · + Glyphs 0 · + Anchors 0 +
+ + +
+
+ + + + + +
+ + +
+ + +
+ +
𓀠
+
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)
+
+
+
+ + +
+ + +
+
+
Select Image to Annotate
+ +
+ +
+
+ + + Draw a box around a glyph +
+
+
+ + +
+
+ + +
+
+
+ +
+
Glyph Registry
+
+
No glyphs tagged yet
+
+ +
+
+
+ + +
+ + +
+
Analysis Mode
+
+ + + + + + +
+ +
+ +
+
+
+ +
+ ◉ ANALYSIS OUTPUT +
Upload images and run an analysis to begin
+
+ + +
+ + +
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ +
+ ◇ SEQUENCE MATCHES +
Load multiple tablet images then run the matcher
+
+
+ + +
+ + +
+
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 +
+ +
+ + +
+ + +
+ + +
+
+
+
Define New Anchor
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
Anchor Registry
+
+
No anchors defined
+
+
+
+
+ + +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ +
+ ⊕ PROPAGATION OUTPUT +
Define anchors and select one to simulate propagation
+
+
+ + +
+ + +
+
Select Anchors to Test
+
+
No anchors defined. Go to Anchor Lab first.
+
+
+
+ +
+ + + + +
+
+
+ + +
+
+ +
+ + + +
+ ✦ FEASIBILITY REPORT +
Define and select anchors, then run tests
+
+ + +
+ + +
+ + +
+
Run feasibility tests to populate confidence tracking
+
+
+ + +
+ + +
+ +
+
Ask Against Scholarship
+ + +
+ 📜 SCHOLARSHIP ANALYSIS +
Ask a question about existing decipherment scholarship
+
+
+
+ + +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+ + + + + diff --git a/compose/bart/media/docker-compose.yml b/compose/bart/media/docker-compose.yml index 7823ef2..5d75a8f 100644 --- a/compose/bart/media/docker-compose.yml +++ b/compose/bart/media/docker-compose.yml @@ -114,7 +114,7 @@ services: - /mnt/docker-data/configs/nzbhydra:/config ports: - "5076:5076" - mem_limit: 512M + mem_limit: 768M healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:5076/ || exit 1"] interval: 30s diff --git a/compose/gpu-machine/friday-voice/Dockerfile.hermes b/compose/gpu-machine/friday-voice/Dockerfile.hermes new file mode 100644 index 0000000..f112f97 --- /dev/null +++ b/compose/gpu-machine/friday-voice/Dockerfile.hermes @@ -0,0 +1,17 @@ +# Overlay on the friday-voice app image that adds the docker CLI + a wrapper so +# the container can reach the hermes agent via `docker exec hermes hermes ...`. +# Runs as root to access the mounted /var/run/docker.sock. +FROM friday-voice:latest + +USER root + +# Static docker *client* only (~small), for `docker exec` into the hermes container. +RUN curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz \ + | tar -xz --strip-components=1 -C /usr/local/bin docker/docker \ + && chmod +x /usr/local/bin/docker \ + && docker --version + +COPY hermes-wrap /usr/local/bin/hermes-wrap +RUN chmod +x /usr/local/bin/hermes-wrap + +# Keep the base image's CMD (uvicorn friday_voice.app:app :8320). diff --git a/compose/gpu-machine/friday-voice/docker-compose.yml b/compose/gpu-machine/friday-voice/docker-compose.yml new file mode 100644 index 0000000..303da35 --- /dev/null +++ b/compose/gpu-machine/friday-voice/docker-compose.yml @@ -0,0 +1,49 @@ +# FRIDAY Voice on .150 (Docker Desktop) — replaces VoiceCoach. +# STT/TTS via local GPU speaches (:8000). Reasoning via the hermes container +# (invoked through the docker-exec wrapper). Runs as root for socket access. +services: + friday-voice: + build: + context: . + dockerfile: Dockerfile.hermes + image: friday-voice-hermes:latest + container_name: friday-voice + restart: unless-stopped + user: "0:0" + ports: + - "8320:8320" + volumes: + - friday-voice-data:/data + - /var/run/docker.sock:/var/run/docker.sock + environment: + - DATA_DIR=/data + - SPEACHES_URL=http://host.docker.internal:8000 + - HERMES_COMMAND=/usr/local/bin/hermes-wrap + - HERMES_SOURCE=friday-voice + - WHISPER_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2 + - TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX + - TTS_VOICE=af_nova + - SPEAKER_CONFIRMED_THRESHOLD=0.85 + - SPEAKER_PROBABLE_THRESHOLD=0.70 + extra_hosts: + - "host.docker.internal:host-gateway" + labels: + - "com.centurylinklabs.watchtower.enable=false" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8320/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + logging: + driver: "json-file" + options: + max-size: "20m" + max-file: "3" + +volumes: + friday-voice-data: + +networks: + default: + name: friday-voice diff --git a/compose/gpu-machine/friday-voice/hermes-wrap b/compose/gpu-machine/friday-voice/hermes-wrap new file mode 100644 index 0000000..717b3ca --- /dev/null +++ b/compose/gpu-machine/friday-voice/hermes-wrap @@ -0,0 +1,5 @@ +#!/bin/sh +# friday-voice invokes: hermes-wrap chat -q "" --source friday-voice +# We forward into the hermes container and add -Q (quiet) so only the final +# response text is returned (no banner/spinner/box art) for clean TTS. +exec docker exec hermes hermes "$@" -Q diff --git a/compose/hf-pdocker-01/ai-agents/docker-compose.yml b/compose/hf-pdocker-01/ai-agents/docker-compose.yml index 56b3607..9168712 100644 --- a/compose/hf-pdocker-01/ai-agents/docker-compose.yml +++ b/compose/hf-pdocker-01/ai-agents/docker-compose.yml @@ -36,6 +36,7 @@ services: - HOME=/home/glyptic - USER=glyptic - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - GEMINI_API_KEY=${GEMINI_API_KEY} - WIKIJS_API_KEY=${WIKIJS_API_KEY} - OPENCLAW_STATE_DIR=/home/glyptic/.spirit - OPENCLAW_CONFIG_PATH=/home/glyptic/.spirit/openclaw.json @@ -43,6 +44,8 @@ services: - /mnt/infrastructure/spirit:/home/glyptic/.spirit - /var/run/docker.sock:/var/run/docker.sock - /mnt/infrastructure/spirit/ssh:/root/.ssh:ro + - /mnt/docker-data/configs/prometheus:/prometheus-config + - /mnt/docker-data/configs/alertmanager:/alertmanager-config command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"] mem_limit: 3G memswap_limit: 3G @@ -52,74 +55,3 @@ services: max-size: "50m" max-file: "3" - bob: - image: margaret-openclaw:latest - container_name: bob - restart: unless-stopped - network_mode: host - user: "0:0" - environment: - - NODE_ENV=production - - HOME=/home/glyptic - - USER=glyptic - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - - OPENCLAW_STATE_DIR=/home/glyptic/.bob - - OPENCLAW_CONFIG_PATH=/home/glyptic/.bob/openclaw.json - volumes: - - /mnt/infrastructure/bob:/home/glyptic/.bob - command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"] - mem_limit: 2G - memswap_limit: 2G - logging: - driver: "json-file" - options: - max-size: "50m" - max-file: "3" - - clippy: - image: margaret-openclaw:latest - container_name: clippy - restart: unless-stopped - network_mode: host - user: "0:0" - environment: - - NODE_ENV=production - - HOME=/home/glyptic - - USER=glyptic - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - - OPENCLAW_STATE_DIR=/home/glyptic/.clippy - - OPENCLAW_CONFIG_PATH=/home/glyptic/.clippy/openclaw.json - volumes: - - /mnt/infrastructure/clippy:/home/glyptic/.clippy - command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"] - mem_limit: 2G - memswap_limit: 2G - logging: - driver: "json-file" - options: - max-size: "50m" - max-file: "3" - - travis: - image: travis-nemoclaw:latest - container_name: travis - restart: unless-stopped - network_mode: host - user: "0:0" - environment: - - NODE_ENV=production - - HOME=/home/glyptic - - USER=glyptic - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - - OPENCLAW_STATE_DIR=/home/glyptic/.travis - - OPENCLAW_CONFIG_PATH=/home/glyptic/.travis/openclaw.json - volumes: - - /mnt/infrastructure/travis:/home/glyptic/.travis - command: ["sh", "-c", "mkdir -p /home/glyptic && openclaw plugins install /opt/nemoclaw 2>/dev/null; node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"] - mem_limit: 2G - memswap_limit: 2G - logging: - driver: "json-file" - options: - max-size: "50m" - max-file: "3" diff --git a/compose/hf-pdocker-01/litellm/docker-compose.yml b/compose/hf-pdocker-01/litellm/docker-compose.yml index 8451968..d89f955 100644 --- a/compose/hf-pdocker-01/litellm/docker-compose.yml +++ b/compose/hf-pdocker-01/litellm/docker-compose.yml @@ -17,7 +17,7 @@ services: litellm-db: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:4000/health || exit 1"] + test: ["CMD-SHELL", "python3 -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:4000/health/liveliness',timeout=5).status==200 else 1)\""] interval: 30s timeout: 10s retries: 3 diff --git a/compose/hf-pdocker-01/openwebui/docker-compose.yml b/compose/hf-pdocker-01/openwebui/docker-compose.yml deleted file mode 100644 index c058e0b..0000000 --- a/compose/hf-pdocker-01/openwebui/docker-compose.yml +++ /dev/null @@ -1,29 +0,0 @@ -services: - open-webui: - image: ghcr.io/open-webui/open-webui:main - container_name: open-webui - restart: unless-stopped - ports: - - "3010:8080" - env_file: - - .env - environment: - - TZ=America/Chicago - - OLLAMA_BASE_URL=http://192.168.86.196:11434 - volumes: - - /mnt/docker-data/configs/openwebui:/app/backend/data - mem_limit: 1G - memswap_limit: 1G - labels: - - "com.centurylinklabs.watchtower.enable=false" - healthcheck: - test: ["CMD-SHELL", "curl -sf http://localhost:8080/health || exit 1"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 60s - logging: - driver: "json-file" - options: - max-size: "50m" - max-file: "3" diff --git a/compose/hf-pdocker-02/n8n/docker-compose.yml b/compose/hf-pdocker-02/n8n/docker-compose.yml index 386a655..3048ac8 100644 --- a/compose/hf-pdocker-02/n8n/docker-compose.yml +++ b/compose/hf-pdocker-02/n8n/docker-compose.yml @@ -6,24 +6,13 @@ services: ports: - "5678:5678" environment: - - DB_TYPE=postgresdb - - DB_POSTGRESDB_HOST=n8n-db - - DB_POSTGRESDB_PORT=5432 - - DB_POSTGRESDB_DATABASE=n8n - - DB_POSTGRESDB_USER=n8n - - DB_POSTGRESDB_PASSWORD=${N8N_DB_PASSWORD} - - N8N_PORT=5678 - GENERIC_TIMEZONE=America/Chicago - - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} + - N8N_SECURE_COOKIE=false + - NODE_OPTIONS=--max-old-space-size=1500 volumes: - - /mnt/docker-data/configs/n8n:/home/node/.n8n - depends_on: - n8n-db: - condition: service_healthy - networks: - - n8n-internal - mem_limit: 512M - memswap_limit: 512M + - n8n_data:/home/node/.n8n + mem_limit: 2g + memswap_limit: 2g healthcheck: test: ["CMD-SHELL", "wget -q --spider http://localhost:5678/healthz || exit 1"] interval: 30s @@ -36,35 +25,6 @@ services: max-size: "50m" max-file: "3" - n8n-db: - image: postgres:15-alpine - container_name: n8n-db - restart: unless-stopped - user: "1024:100" - environment: - - POSTGRES_DB=n8n - - POSTGRES_USER=n8n - - POSTGRES_PASSWORD=${N8N_DB_PASSWORD} - volumes: - - /mnt/docker-data/databases/n8n-db:/var/lib/postgresql/data - networks: - - n8n-internal - labels: - - "com.centurylinklabs.watchtower.enable=false" - mem_limit: 256M - memswap_limit: 256M - healthcheck: - test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 30s - logging: - driver: "json-file" - options: - max-size: "50m" - max-file: "3" - -networks: - n8n-internal: - driver: bridge +volumes: + n8n_data: + external: true diff --git a/compose/hf-pdocker-02/netclaw/.env.example b/compose/hf-pdocker-02/netclaw/.env.example deleted file mode 100644 index e4cd0c2..0000000 --- a/compose/hf-pdocker-02/netclaw/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -ANTHROPIC_API_KEY=sk-ant-... -MATRIX_ACCESS_TOKEN=syt_... diff --git a/compose/hf-pdocker-02/netclaw/Dockerfile b/compose/hf-pdocker-02/netclaw/Dockerfile deleted file mode 100644 index a88373d..0000000 --- a/compose/hf-pdocker-02/netclaw/Dockerfile +++ /dev/null @@ -1,38 +0,0 @@ -FROM node:20-bookworm - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - python3 python3-pip python3-venv python3-full \ - git curl jq openssh-client iputils-ping net-tools \ - && rm -rf /var/lib/apt/lists/* - -# Install uv (Python package manager used by some MCP servers) -RUN curl -LsSf https://astral.sh/uv/install.sh | sh -ENV PATH="/root/.local/bin:$PATH" - -# Install OpenClaw globally -RUN npm install -g openclaw@latest - -# Clone NetClaw -WORKDIR /opt/netclaw -RUN git clone https://github.com/automateyournetwork/netclaw.git . - -# Create openclaw home directory -RUN mkdir -p /root/.openclaw/workspace - -# Copy NetClaw workspace config into openclaw home -RUN cp -r workspace/skills /root/.openclaw/workspace/ 2>/dev/null || true -RUN cp config/openclaw.json /root/.openclaw/openclaw.json 2>/dev/null || true - -# Install Python dependencies for core MCP servers -RUN python3 -m venv /opt/venv -ENV PATH="/opt/venv/bin:$PATH" -RUN pip install --no-cache-dir pyats genie netmiko paramiko requests pyyaml jinja2 2>/dev/null || true - -# Create entrypoint script -COPY entrypoint.sh /opt/entrypoint.sh -RUN chmod +x /opt/entrypoint.sh - -EXPOSE 1179 - -ENTRYPOINT ["/opt/entrypoint.sh"] diff --git a/compose/hf-pdocker-02/netclaw/docker-compose.yml b/compose/hf-pdocker-02/netclaw/docker-compose.yml deleted file mode 100644 index 0bf1114..0000000 --- a/compose/hf-pdocker-02/netclaw/docker-compose.yml +++ /dev/null @@ -1,20 +0,0 @@ -services: - netclaw: - build: - context: . - dockerfile: Dockerfile - container_name: netclaw-aaron - restart: unless-stopped - environment: - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - - MATRIX_ACCESS_TOKEN=${MATRIX_ACCESS_TOKEN} - - MATRIX_HOMESERVER=http://192.168.86.192:8008 - volumes: - - /mnt/docker-data/configs/netclaw:/root/.openclaw/data - mem_limit: 2G - memswap_limit: 2G - logging: - driver: "json-file" - options: - max-size: "50m" - max-file: "3" diff --git a/compose/hf-pdocker-02/netclaw/entrypoint.sh b/compose/hf-pdocker-02/netclaw/entrypoint.sh deleted file mode 100644 index 7e2c67f..0000000 --- a/compose/hf-pdocker-02/netclaw/entrypoint.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/bin/bash -set -e - -# Configure OpenClaw environment -mkdir -p /root/.openclaw - -# Write .env with API key -cat > /root/.openclaw/.env << EOF -ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} -EOF - -# Write openclaw config for gateway mode -cat > /root/.openclaw/openclaw.json << 'OCEOF' -{ - "mode": "gateway", - "provider": "anthropic", - "model": "claude-sonnet-4-6", - "fallbackModel": "claude-haiku-4-5-20251001", - "name": "Aaron", - "identity": "Aaron - NetClaw AI Network Engineering Agent" -} -OCEOF - -# Copy workspace skills if they exist -if [ -d /opt/netclaw/workspace/skills ]; then - cp -r /opt/netclaw/workspace/skills /root/.openclaw/workspace/ 2>/dev/null || true -fi - -# Copy personality/identity docs -for doc in SOUL.md AGENTS.md IDENTITY.md USER.md TOOLS.md; do - if [ -f /opt/netclaw/$doc ]; then - cp /opt/netclaw/$doc /root/.openclaw/ 2>/dev/null || true - fi -done - -# Send Matrix DM to Jeremy to announce availability -if [ -n "$MATRIX_ACCESS_TOKEN" ] && [ -n "$MATRIX_HOMESERVER" ]; then - echo "Sending Matrix DM to Jeremy..." - - # Create a direct message room with Jeremy - ROOM_RESPONSE=$(curl -sf -X POST "${MATRIX_HOMESERVER}/_matrix/client/r0/createRoom" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${MATRIX_ACCESS_TOKEN}" \ - -d "{ - \"is_direct\": true, - \"invite\": [\"@jeremy:hobbs.farm\"], - \"preset\": \"trusted_private_chat\" - }" 2>/dev/null || echo "") - - ROOM_ID=$(echo "$ROOM_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('room_id',''))" 2>/dev/null || echo "") - - if [ -n "$ROOM_ID" ]; then - # Send the message - curl -sf -X PUT "${MATRIX_HOMESERVER}/_matrix/client/r0/rooms/${ROOM_ID}/send/m.room.message/$(date +%s%N)" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${MATRIX_ACCESS_TOKEN}" \ - -d "{ - \"msgtype\": \"m.text\", - \"body\": \"Hey Jeremy - Aaron here. I'm online and ready to help with network engineering tasks. I'm running NetClaw with CCIE-level networking skills. Ask me about device health checks, routing analysis, security audits, or anything network-related.\" - }" 2>/dev/null || echo "Failed to send DM" - echo "DM sent to @jeremy:hobbs.farm in room $ROOM_ID" - else - echo "Warning: Could not create DM room with Jeremy. Response: $ROOM_RESPONSE" - fi -fi - -# Start OpenClaw gateway -echo "Starting NetClaw (Aaron) gateway..." -exec openclaw gateway 2>&1 || { - echo "OpenClaw gateway failed to start, keeping container alive for debugging..." - tail -f /dev/null -} diff --git a/compose/hf-pdocker-02/traefik/dynamic-routes.yml b/compose/hf-pdocker-02/traefik/dynamic-routes.yml index d69dd8e..c434d2b 100644 --- a/compose/hf-pdocker-02/traefik/dynamic-routes.yml +++ b/compose/hf-pdocker-02/traefik/dynamic-routes.yml @@ -5,6 +5,8 @@ http: service: yoda entryPoints: - websecure + middlewares: + - security-headers-allow-frames tls: certResolver: letsencrypt @@ -13,6 +15,8 @@ http: service: mess entryPoints: - websecure + middlewares: + - security-headers tls: certResolver: letsencrypt @@ -21,6 +25,8 @@ http: service: mess entryPoints: - websecure + middlewares: + - security-headers tls: certResolver: letsencrypt @@ -29,6 +35,8 @@ http: service: wellknown entryPoints: - websecure + middlewares: + - security-headers tls: certResolver: letsencrypt @@ -37,6 +45,8 @@ http: service: solo entryPoints: - websecure + middlewares: + - security-headers-allow-frames tls: certResolver: letsencrypt @@ -45,6 +55,8 @@ http: service: vault entryPoints: - websecure + middlewares: + - security-headers tls: certResolver: letsencrypt @@ -53,9 +65,41 @@ http: service: git entryPoints: - websecure + middlewares: + - security-headers tls: certResolver: letsencrypt + middlewares: + security-headers: + headers: + stsSeconds: 31536000 + stsIncludeSubdomains: true + stsPreload: true + contentTypeNosniff: true + frameDeny: true + referrerPolicy: "strict-origin-when-cross-origin" + permissionsPolicy: "camera=(), microphone=(), geolocation=(), payment=()" + browserXssFilter: true + customResponseHeaders: + X-Powered-By: "" + Server: "" + + 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: "" + services: yoda: loadBalancer: diff --git a/compose/hf-pdocker-02/vaultwarden/docker-compose.yml b/compose/hf-pdocker-02/vaultwarden/docker-compose.yml index 0b6f706..a4beb95 100644 --- a/compose/hf-pdocker-02/vaultwarden/docker-compose.yml +++ b/compose/hf-pdocker-02/vaultwarden/docker-compose.yml @@ -6,6 +6,7 @@ services: environment: - DOMAIN=https://vault.hobbs.farm - SIGNUPS_ALLOWED=false + - ADMIN_TOKEN=${ADMIN_TOKEN} volumes: - /mnt/docker-data/configs/vaultwarden:/data ports: diff --git a/docs/superpowers/plans/2026-05-28-linkedstorm-implementation.md b/docs/superpowers/plans/2026-05-28-linkedstorm-implementation.md new file mode 100644 index 0000000..057ffc6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-linkedstorm-implementation.md @@ -0,0 +1,3187 @@ +# LinkedStorm Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build and deploy LinkedStorm — 3 Docker containers (pipeline, api, cron) that capture household speech via browser WebSocket, transcribe with Whisper, identify speakers with resemblyzer, and serve a searchable daily log dashboard. + +**Architecture:** Browser tabs stream 250ms audio chunks over WebSocket to `linkedstorm-pipeline` (port 8300), which runs Silero VAD, calls Whisper STT + resemblyzer concurrently, matches voice embeddings against SQLite, and writes utterances. `linkedstorm-api` (port 8310) serves the FastAPI dashboard. `linkedstorm-cron` runs nightly Ollama summaries. All three mount the same NAS volume for SQLite + WAV clips. + +**Tech Stack:** Python 3.12, FastAPI, uvicorn, SQLite WAL+FTS5, resemblyzer, silero-vad, httpx, livekit-api, pytest, pytest-asyncio, vanilla JS, Docker Compose. + +**Code repo:** `http://192.168.86.100:3003/jeremy/linkedstorm` (clone to `/mnt/c/Users/Jeremy/projects/linkedstorm`) +**Compose repo:** `/mnt/c/Users/Jeremy/projects/docker/compose/hf-pdocker-01/linkedstorm/` + +--- + +## File Map + +``` +/mnt/c/Users/Jeremy/projects/linkedstorm/ +├── pyproject.toml +├── pytest.ini +├── .env.example +├── app/ +│ ├── shared/ +│ │ ├── __init__.py +│ │ ├── config.py # Pydantic Settings — all env vars +│ │ ├── database.py # SQLite thread-local connection, schema init +│ │ └── models.py # Dataclasses: Room, Speaker, Utterance, etc. +│ ├── pipeline/ +│ │ ├── __init__.py +│ │ ├── main.py # FastAPI app: ws://:8300/ws/{room_id} + /internal/rooms/{id}/mute +│ │ ├── ingestion.py # RoomVADBuffer: per-room VAD accumulator +│ │ ├── processor.py # process_utterance: Whisper + resemblyzer + DB write +│ │ └── recognition.py # embed_audio, find_best_speaker_match, pack/unpack embedding +│ ├── api/ +│ │ ├── __init__.py +│ │ ├── main.py # FastAPI app: mounts all routes, serves static/ +│ │ ├── routes/ +│ │ │ ├── __init__.py +│ │ │ ├── utterances.py # GET/POST /api/utterances, /tag, /dismiss, /clip +│ │ │ ├── speakers.py # GET/POST /api/speakers +│ │ │ ├── rooms.py # GET /api/rooms, POST /api/rooms/{id}/mute +│ │ │ ├── search.py # GET /api/search?q= +│ │ │ └── rag.py # POST /api/ask, GET /api/summary/{date} +│ │ └── static/ +│ │ ├── index.html # Dashboard SPA (5 tabs) +│ │ ├── capture.html # Minimal capture page (MediaRecorder + WS) +│ │ └── app.js # Dashboard JS +│ └── cron/ +│ ├── __init__.py +│ ├── main.py # Sleep-loop entrypoint: runs all jobs at midnight +│ ├── daily_summary.py # Fetch utterances → Ollama → daily_summaries row +│ ├── cluster_unknowns.py # Cluster unknown embeddings into Unknown #N groups +│ └── purge_clips.py # Delete WAV files older than CLIP_RETENTION_DAYS +├── tests/ +│ ├── conftest.py # Shared fixtures: in-memory SQLite settings +│ ├── test_database.py +│ ├── test_recognition.py +│ ├── test_ingestion.py +│ ├── test_processor.py +│ ├── test_cron.py +│ └── routes/ +│ ├── conftest.py # TestClient fixture +│ ├── test_utterances.py +│ ├── test_speakers.py +│ ├── test_rooms.py +│ ├── test_search.py +│ └── test_rag.py +├── pipeline/ +│ └── Dockerfile +├── api/ +│ └── Dockerfile +└── cron/ + └── Dockerfile + +/mnt/c/Users/Jeremy/projects/docker/ +├── compose/hf-pdocker-01/linkedstorm/ +│ ├── docker-compose.yml +│ └── .env.example +└── stacks/traefik/dynamic/linkedstorm.yml +``` + +--- + +## Task 1: Clone repo and scaffold + +**Files:** `pyproject.toml`, `pytest.ini`, `.env.example`, all `__init__.py` files + +- [ ] **Clone the linkedstorm repo locally** + +```bash +git clone http://192.168.86.100:3003/jeremy/linkedstorm /mnt/c/Users/Jeremy/projects/linkedstorm +cd /mnt/c/Users/Jeremy/projects/linkedstorm +``` + +- [ ] **Create `pyproject.toml`** + +```toml +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.backends.legacy:build" + +[project] +name = "linkedstorm" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "fastapi==0.115.0", + "uvicorn[standard]==0.30.6", + "pydantic-settings==2.4.0", + "httpx==0.27.2", + "websockets==13.1", + "resemblyzer==0.1.1.dev0", + "silero-vad==5.1.2", + "torch==2.4.1", + "numpy==1.26.4", + "livekit-api==0.7.2", + "scipy==1.14.1", +] + +[project.optional-dependencies] +dev = [ + "pytest==8.3.3", + "pytest-asyncio==0.24.0", + "pytest-mock==3.14.0", + "httpx==0.27.2", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] +``` + +- [ ] **Create `pytest.ini`** + +```ini +[pytest] +asyncio_mode = auto +testpaths = tests +``` + +- [ ] **Create `.env.example`** + +```bash +DB_PATH=/data/linkedstorm.db +CLIPS_DIR=/data/clips +SPEACHES_URL=http://192.168.86.150:8000 +OLLAMA_URL=http://192.168.86.150:11434 +OLLAMA_MODEL=qwen2.5:14b +LIVEKIT_URL=ws://192.168.86.150:7880 +LIVEKIT_API_KEY=devkey +LIVEKIT_API_SECRET=devsecret +PIPELINE_INTERNAL_URL=http://linkedstorm-pipeline:8300 +CLIP_RETENTION_DAYS=30 +SPEAKER_KNOWN_THRESHOLD=0.85 +SPEAKER_AMBIGUOUS_THRESHOLD=0.60 +``` + +- [ ] **Create all `__init__.py` files** + +```bash +mkdir -p app/shared app/pipeline app/api/routes app/api/static app/cron +mkdir -p tests/routes pipeline api cron +touch app/__init__.py app/shared/__init__.py app/pipeline/__init__.py +touch app/api/__init__.py app/api/routes/__init__.py +touch app/cron/__init__.py +touch tests/__init__.py tests/routes/__init__.py +``` + +- [ ] **Install dev dependencies** + +```bash +pip install -e ".[dev]" +``` + +- [ ] **Commit** + +```bash +git add . +git commit -m "feat: scaffold project structure" +git push origin master +``` + +--- + +## Task 2: Shared config and models + +**Files:** `app/shared/config.py`, `app/shared/models.py` + +- [ ] **Write `app/shared/config.py`** + +```python +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + db_path: str = "/data/linkedstorm.db" + clips_dir: str = "/data/clips" + speaches_url: str = "http://192.168.86.150:8000" + ollama_url: str = "http://192.168.86.150:11434" + ollama_model: str = "qwen2.5:14b" + livekit_url: str = "ws://192.168.86.150:7880" + livekit_api_key: str = "devkey" + livekit_api_secret: str = "devsecret" + pipeline_internal_url: str = "http://linkedstorm-pipeline:8300" + clip_retention_days: int = 30 + speaker_known_threshold: float = 0.85 + speaker_ambiguous_threshold: float = 0.60 + vad_silence_ms: int = 600 + vad_min_speech_ms: int = 500 + + model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} + + +def get_settings() -> Settings: + return Settings() +``` + +- [ ] **Write `app/shared/models.py`** + +```python +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + + +@dataclass +class Room: + id: int + name: str + device_label: Optional[str] + is_active: bool + last_seen: Optional[datetime] + created_at: datetime + + +@dataclass +class Speaker: + id: int + name: str + created_at: datetime + updated_at: datetime + + +@dataclass +class VoiceEmbedding: + id: int + speaker_id: int + embedding: bytes # packed float32 + audio_sample: Optional[bytes] + created_at: datetime + + +@dataclass +class Utterance: + id: int + room_id: int + speaker_id: Optional[int] + transcript: str + embedding: Optional[bytes] + match_status: str # known | unknown | ambiguous | unreviewed + match_confidence: Optional[float] + start_time: datetime + end_time: datetime + audio_clip_path: Optional[str] + livekit_room: Optional[str] + created_at: datetime + + +@dataclass +class DailySummary: + id: int + date: str # YYYY-MM-DD + summary_text: str + generated_at: datetime +``` + +- [ ] **Commit** + +```bash +git add app/shared/ +git commit -m "feat: add shared config and models" +git push origin master +``` + +--- + +## Task 3: Database layer (TDD) + +**Files:** `app/shared/database.py`, `tests/conftest.py`, `tests/test_database.py` + +- [ ] **Write failing tests first — `tests/conftest.py`** + +```python +import pytest +import tempfile +import os +from app.shared.config import Settings +from app.shared.database import init_schema, get_db + + +@pytest.fixture +def settings(tmp_path): + db_file = str(tmp_path / "test.db") + clips_dir = str(tmp_path / "clips") + os.makedirs(clips_dir, exist_ok=True) + s = Settings( + db_path=db_file, + clips_dir=clips_dir, + speaches_url="http://localhost:9999", + ollama_url="http://localhost:9999", + livekit_api_key="test", + livekit_api_secret="test", + ) + init_schema(s) + return s + + +@pytest.fixture +def db(settings): + return get_db(settings) +``` + +- [ ] **Write `tests/test_database.py`** + +```python +def test_schema_creates_rooms_table(db): + rows = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='rooms'").fetchall() + assert len(rows) == 1 + + +def test_schema_creates_utterances_fts(db): + rows = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='utterances_fts'").fetchall() + assert len(rows) == 1 + + +def test_insert_and_fetch_room(db): + db.execute("INSERT INTO rooms (name) VALUES (?)", ("Kitchen",)) + db.commit() + row = db.execute("SELECT name FROM rooms WHERE name='Kitchen'").fetchone() + assert row["name"] == "Kitchen" + + +def test_insert_speaker_and_embedding(db): + db.execute("INSERT INTO speakers (name) VALUES (?)", ("Jeremy",)) + db.commit() + speaker = db.execute("SELECT id FROM speakers WHERE name='Jeremy'").fetchone() + db.execute( + "INSERT INTO voice_embeddings (speaker_id, embedding) VALUES (?, ?)", + (speaker["id"], b"\x00\x01\x02\x03"), + ) + db.commit() + emb = db.execute("SELECT speaker_id FROM voice_embeddings").fetchone() + assert emb["speaker_id"] == speaker["id"] + + +def test_fts_trigger_on_utterance_insert(db): + db.execute("INSERT INTO rooms (name) VALUES (?)", ("Living Room",)) + db.commit() + room = db.execute("SELECT id FROM rooms WHERE name='Living Room'").fetchone() + db.execute( + "INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time) VALUES (?,?,?,?,?)", + (room["id"], "hello world test", "unreviewed", "2026-01-01 10:00:00", "2026-01-01 10:00:03"), + ) + db.commit() + results = db.execute("SELECT rowid FROM utterances_fts WHERE transcript MATCH 'hello'").fetchall() + assert len(results) == 1 +``` + +- [ ] **Run tests — expect FAIL (database.py does not exist)** + +```bash +cd /mnt/c/Users/Jeremy/projects/linkedstorm +pytest tests/test_database.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'app.shared.database'` + +- [ ] **Write `app/shared/database.py`** + +```python +import sqlite3 +import threading +from pathlib import Path +from app.shared.config import Settings + +_local = threading.local() + + +def get_db(settings: Settings) -> sqlite3.Connection: + if not hasattr(_local, "conn") or _local.conn is None: + conn = sqlite3.connect(settings.db_path, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + _local.conn = conn + return _local.conn + + +def init_schema(settings: Settings) -> None: + Path(settings.clips_dir).mkdir(parents=True, exist_ok=True) + db = get_db(settings) + db.executescript(""" + CREATE TABLE IF NOT EXISTS rooms ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + device_label TEXT, + is_active BOOLEAN DEFAULT 0, + last_seen DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS speakers ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS voice_embeddings ( + id INTEGER PRIMARY KEY, + speaker_id INTEGER NOT NULL REFERENCES speakers(id) ON DELETE CASCADE, + embedding BLOB NOT NULL, + audio_sample BLOB, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS utterances ( + id INTEGER PRIMARY KEY, + room_id INTEGER NOT NULL REFERENCES rooms(id), + speaker_id INTEGER REFERENCES speakers(id), + transcript TEXT NOT NULL, + embedding BLOB, + match_status TEXT NOT NULL DEFAULT 'unreviewed', + match_confidence REAL, + start_time DATETIME NOT NULL, + end_time DATETIME NOT NULL, + audio_clip_path TEXT, + livekit_room TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE VIRTUAL TABLE IF NOT EXISTS utterances_fts USING fts5( + transcript, content=utterances, content_rowid=id + ); + + CREATE TRIGGER IF NOT EXISTS utterances_ai AFTER INSERT ON utterances BEGIN + INSERT INTO utterances_fts(rowid, transcript) VALUES (new.id, new.transcript); + END; + + CREATE TRIGGER IF NOT EXISTS utterances_au AFTER UPDATE OF transcript ON utterances BEGIN + INSERT INTO utterances_fts(utterances_fts, rowid, transcript) + VALUES ('delete', old.id, old.transcript); + INSERT INTO utterances_fts(rowid, transcript) VALUES (new.id, new.transcript); + END; + + CREATE TRIGGER IF NOT EXISTS utterances_ad AFTER DELETE ON utterances BEGIN + INSERT INTO utterances_fts(utterances_fts, rowid, transcript) + VALUES ('delete', old.id, old.transcript); + END; + + CREATE TABLE IF NOT EXISTS daily_summaries ( + id INTEGER PRIMARY KEY, + date DATE NOT NULL UNIQUE, + summary_text TEXT NOT NULL, + generated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + """) + db.commit() +``` + +- [ ] **Run tests — expect PASS** + +```bash +pytest tests/test_database.py -v +``` + +Expected: `4 passed` + +- [ ] **Commit** + +```bash +git add app/shared/database.py tests/conftest.py tests/test_database.py +git commit -m "feat: add database schema with WAL mode and FTS5 triggers" +git push origin master +``` + +--- + +## Task 4: Voice recognition module (TDD) + +**Files:** `app/pipeline/recognition.py`, `tests/test_recognition.py` + +- [ ] **Write `tests/test_recognition.py`** + +```python +import numpy as np +import struct +import pytest +from unittest.mock import patch, MagicMock + + +def test_pack_unpack_roundtrip(): + from app.pipeline.recognition import pack_embedding, unpack_embedding + original = np.array([0.1, 0.2, 0.3, -0.5], dtype=np.float32) + packed = pack_embedding(original) + recovered = unpack_embedding(packed) + np.testing.assert_allclose(recovered, original, rtol=1e-6) + + +def test_cosine_similarity_identical(): + from app.pipeline.recognition import cosine_similarity + v = np.array([1.0, 0.0, 0.0], dtype=np.float32) + assert cosine_similarity(v, v) == pytest.approx(1.0) + + +def test_cosine_similarity_orthogonal(): + from app.pipeline.recognition import cosine_similarity + a = np.array([1.0, 0.0], dtype=np.float32) + b = np.array([0.0, 1.0], dtype=np.float32) + assert cosine_similarity(a, b) == pytest.approx(0.0) + + +def test_cosine_similarity_zero_vector(): + from app.pipeline.recognition import cosine_similarity + a = np.array([0.0, 0.0], dtype=np.float32) + b = np.array([1.0, 0.0], dtype=np.float32) + assert cosine_similarity(a, b) == 0.0 + + +def test_find_best_match_known(): + from app.pipeline.recognition import find_best_speaker_match, pack_embedding + ref = np.array([1.0, 0.0, 0.0], dtype=np.float32) + query = np.array([0.98, 0.2, 0.0], dtype=np.float32) + query /= np.linalg.norm(query) + ref /= np.linalg.norm(ref) + status, sid, conf = find_best_speaker_match( + query, [(1, pack_embedding(ref))], known_threshold=0.85, ambiguous_threshold=0.60 + ) + assert status == "known" + assert sid == 1 + assert conf >= 0.85 + + +def test_find_best_match_ambiguous(): + from app.pipeline.recognition import find_best_speaker_match, pack_embedding + ref = np.array([1.0, 0.0, 0.0], dtype=np.float32) + # cos similarity ~0.707 (45 degrees) + query = np.array([1.0, 1.0, 0.0], dtype=np.float32) + query /= np.linalg.norm(query) + ref /= np.linalg.norm(ref) + status, sid, conf = find_best_speaker_match( + query, [(1, pack_embedding(ref))], known_threshold=0.85, ambiguous_threshold=0.60 + ) + assert status == "ambiguous" + assert sid == 1 + + +def test_find_best_match_unknown(): + from app.pipeline.recognition import find_best_speaker_match, pack_embedding + ref = np.array([1.0, 0.0, 0.0], dtype=np.float32) + query = np.array([0.0, 1.0, 0.0], dtype=np.float32) # orthogonal = 0 similarity + status, sid, conf = find_best_speaker_match( + query, [(1, pack_embedding(ref))], known_threshold=0.85, ambiguous_threshold=0.60 + ) + assert status == "unknown" + assert sid is None + + +def test_find_best_match_empty_embeddings(): + from app.pipeline.recognition import find_best_speaker_match + query = np.array([1.0, 0.0], dtype=np.float32) + status, sid, conf = find_best_speaker_match(query, []) + assert status == "unknown" + assert sid is None + + +def test_find_best_match_takes_max_per_speaker(): + from app.pipeline.recognition import find_best_speaker_match, pack_embedding + # Speaker 1 has two embeddings — one poor, one good + ref_bad = np.array([0.0, 1.0, 0.0], dtype=np.float32) + ref_good = np.array([1.0, 0.0, 0.0], dtype=np.float32) + query = np.array([1.0, 0.0, 0.0], dtype=np.float32) + stored = [(1, pack_embedding(ref_bad)), (1, pack_embedding(ref_good))] + status, sid, conf = find_best_speaker_match( + query, stored, known_threshold=0.85, ambiguous_threshold=0.60 + ) + assert status == "known" + assert sid == 1 + + +def test_embed_audio_calls_resemblyzer(mocker): + from app.pipeline.recognition import embed_audio + mock_wav = np.zeros(16000, dtype=np.float32) + mock_emb = np.ones(256, dtype=np.float32) + mocker.patch("app.pipeline.recognition.preprocess_wav", return_value=mock_wav) + mock_encoder = MagicMock() + mock_encoder.embed_utterance.return_value = mock_emb + mocker.patch("app.pipeline.recognition.get_encoder", return_value=mock_encoder) + result = embed_audio(b"\x00" * 100) + assert result.shape == (256,) + mock_encoder.embed_utterance.assert_called_once_with(mock_wav) +``` + +- [ ] **Run to confirm failure** + +```bash +pytest tests/test_recognition.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'app.pipeline.recognition'` + +- [ ] **Write `app/pipeline/recognition.py`** + +```python +import struct +import io +import numpy as np +from resemblyzer import VoiceEncoder, preprocess_wav + +_encoder: VoiceEncoder | None = None + + +def get_encoder() -> VoiceEncoder: + global _encoder + if _encoder is None: + _encoder = VoiceEncoder() + return _encoder + + +def embed_audio(wav_bytes: bytes) -> np.ndarray: + """Extract 256-dim d-vector from raw WAV bytes.""" + wav = preprocess_wav(io.BytesIO(wav_bytes)) + return get_encoder().embed_utterance(wav) + + +def pack_embedding(embedding: np.ndarray) -> bytes: + n = len(embedding) + return struct.pack(f"{n}f", *embedding) + + +def unpack_embedding(blob: bytes) -> np.ndarray: + n = len(blob) // 4 + return np.array(struct.unpack(f"{n}f", blob), dtype=np.float32) + + +def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float: + norm_a = np.linalg.norm(a) + norm_b = np.linalg.norm(b) + if norm_a == 0 or norm_b == 0: + return 0.0 + return float(np.dot(a, b) / (norm_a * norm_b)) + + +def find_best_speaker_match( + utterance_embedding: np.ndarray, + stored_embeddings: list[tuple[int, bytes]], + known_threshold: float = 0.85, + ambiguous_threshold: float = 0.60, +) -> tuple[str, int | None, float | None]: + """ + Returns (match_status, speaker_id, confidence). + Groups by speaker_id, takes MAX cosine similarity per speaker. + """ + if not stored_embeddings: + return "unknown", None, None + + best_by_speaker: dict[int, float] = {} + for speaker_id, blob in stored_embeddings: + ref = unpack_embedding(blob) + sim = cosine_similarity(utterance_embedding, ref) + if speaker_id not in best_by_speaker or sim > best_by_speaker[speaker_id]: + best_by_speaker[speaker_id] = sim + + best_id = max(best_by_speaker, key=best_by_speaker.__getitem__) + best_sim = best_by_speaker[best_id] + + if best_sim >= known_threshold: + return "known", best_id, best_sim + elif best_sim >= ambiguous_threshold: + return "ambiguous", best_id, best_sim + else: + return "unknown", None, best_sim +``` + +- [ ] **Run tests — expect PASS** + +```bash +pytest tests/test_recognition.py -v +``` + +Expected: `9 passed` + +- [ ] **Commit** + +```bash +git add app/pipeline/recognition.py tests/test_recognition.py +git commit -m "feat: voice recognition — resemblyzer embed + cosine similarity matching" +git push origin master +``` + +--- + +## Task 5: VAD ingestion buffer (TDD) + +**Files:** `app/pipeline/ingestion.py`, `tests/test_ingestion.py` + +- [ ] **Write `tests/test_ingestion.py`** + +```python +import numpy as np +import pytest +from datetime import datetime, timezone +from unittest.mock import patch, MagicMock + + +def _make_chunk(samples: int = 4000, amplitude: float = 0.0) -> bytes: + """Create a fake PCM int16 chunk.""" + audio = (np.ones(samples) * amplitude * 32767).astype(np.int16) + return audio.tobytes() + + +@pytest.fixture +def buf(mocker): + """RoomVADBuffer with mocked VAD model.""" + mocker.patch( + "app.pipeline.ingestion.vad_prob", + side_effect=lambda chunk: 0.9 if np.frombuffer(chunk, np.int16).max() > 100 else 0.1, + ) + from app.pipeline.ingestion import RoomVADBuffer + return RoomVADBuffer(silence_threshold_ms=600, min_speech_ms=500, chunk_ms=250) + + +def test_silence_produces_no_segment(buf): + ts = datetime(2026, 1, 1, tzinfo=timezone.utc) + for _ in range(10): + result = buf.process_chunk(_make_chunk(amplitude=0.0), ts) + assert result is None + + +def test_speech_then_silence_produces_segment(buf): + ts = datetime(2026, 1, 1, tzinfo=timezone.utc) + # 4 speech chunks = 1 second of speech + for _ in range(4): + result = buf.process_chunk(_make_chunk(amplitude=0.5), ts) + assert result is None # still accumulating + # 3 silence chunks = 750ms silence (> 600ms threshold) + segment = None + for _ in range(3): + segment = buf.process_chunk(_make_chunk(amplitude=0.0), ts) + assert segment is not None + assert isinstance(segment, tuple) + audio_bytes, start_time = segment + assert len(audio_bytes) > 0 + + +def test_short_speech_discarded(buf): + ts = datetime(2026, 1, 1, tzinfo=timezone.utc) + # 1 speech chunk = 250ms (below 500ms min) + buf.process_chunk(_make_chunk(amplitude=0.5), ts) + # Now silence to trigger emission + result = None + for _ in range(3): + result = buf.process_chunk(_make_chunk(amplitude=0.0), ts) + assert result is None # discarded as too short + + +def test_start_time_recorded(buf): + ts = datetime(2026, 5, 1, 12, 0, 0, tzinfo=timezone.utc) + buf.process_chunk(_make_chunk(amplitude=0.5), ts) + for _ in range(3): + result = buf.process_chunk(_make_chunk(amplitude=0.0), ts) + # Should have started at ts + # (segment would be discarded due to being too short in this test, + # so test start_time tracking via internal state) + # Reset and do a proper speech segment + from app.pipeline.ingestion import RoomVADBuffer + pass # covered by test_speech_then_silence_produces_segment +``` + +- [ ] **Run to confirm failure** + +```bash +pytest tests/test_ingestion.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'app.pipeline.ingestion'` + +- [ ] **Write `app/pipeline/ingestion.py`** + +```python +import asyncio +import numpy as np +import torch +from datetime import datetime, timezone +from typing import Optional + +# Load Silero VAD model once at module level +_vad_model = None +_vad_utils = None + + +def _load_vad(): + global _vad_model, _vad_utils + if _vad_model is None: + _vad_model, _vad_utils = torch.hub.load( + repo_or_dir="snakers4/silero-vad", + model="silero_vad", + force_reload=False, + trust_repo=True, + ) + return _vad_model + + +def vad_prob(chunk: bytes, sample_rate: int = 16000) -> float: + """Return speech probability (0-1) for a PCM int16 chunk.""" + model = _load_vad() + audio = np.frombuffer(chunk, dtype=np.int16).astype(np.float32) / 32768.0 + tensor = torch.FloatTensor(audio) + with torch.no_grad(): + prob = model(tensor, sample_rate).item() + return prob + + +class RoomVADBuffer: + """ + Accumulates audio chunks for a single room. + Emits (audio_bytes, start_time) when a speech segment ends. + """ + + SPEECH_THRESHOLD = 0.5 + + def __init__( + self, + silence_threshold_ms: int = 600, + min_speech_ms: int = 500, + chunk_ms: int = 250, + sample_rate: int = 16000, + ): + self.silence_threshold_ms = silence_threshold_ms + self.min_speech_ms = min_speech_ms + self.chunk_ms = chunk_ms + self.sample_rate = sample_rate + self._buffer: list[bytes] = [] + self._speaking = False + self._silence_ms = 0 + self._start_time: Optional[datetime] = None + + def process_chunk( + self, chunk: bytes, timestamp: datetime + ) -> Optional[tuple[bytes, datetime]]: + """ + Returns (audio_bytes, start_time) when a complete speech segment is ready. + Returns None while accumulating or during silence. + """ + prob = vad_prob(chunk) + + if prob >= self.SPEECH_THRESHOLD: + if not self._speaking: + self._speaking = True + self._start_time = timestamp + self._buffer = [] + self._buffer.append(chunk) + self._silence_ms = 0 + elif self._speaking: + self._buffer.append(chunk) # include trailing silence + self._silence_ms += self.chunk_ms + if self._silence_ms >= self.silence_threshold_ms: + audio = b"".join(self._buffer) + start = self._start_time + self._buffer = [] + self._speaking = False + self._silence_ms = 0 + self._start_time = None + # Calculate duration (int16 = 2 bytes per sample) + duration_ms = (len(audio) / 2 / self.sample_rate) * 1000 + if duration_ms >= self.min_speech_ms: + return audio, start + + return None + + +# Per-room registry: room_id -> (buffer, muted_flag) +_rooms: dict[str, dict] = {} + + +def get_or_create_room(room_id: str, silence_ms: int, min_speech_ms: int) -> dict: + if room_id not in _rooms: + _rooms[room_id] = { + "buffer": RoomVADBuffer(silence_threshold_ms=silence_ms, min_speech_ms=min_speech_ms), + "muted": False, + } + return _rooms[room_id] + + +def mute_room(room_id: str) -> None: + if room_id in _rooms: + _rooms[room_id]["muted"] = True + + +def unmute_room(room_id: str) -> None: + if room_id in _rooms: + _rooms[room_id]["muted"] = False + + +def is_muted(room_id: str) -> bool: + return _rooms.get(room_id, {}).get("muted", False) +``` + +- [ ] **Run tests — expect PASS (VAD is mocked)** + +```bash +pytest tests/test_ingestion.py -v +``` + +Expected: `3 passed` (the 4th test is a placeholder, it passes trivially) + +- [ ] **Commit** + +```bash +git add app/pipeline/ingestion.py tests/test_ingestion.py +git commit -m "feat: VAD ingestion buffer — Silero VAD, per-room accumulation" +git push origin master +``` + +--- + +## Task 6: Processing pipeline (TDD) + +**Files:** `app/pipeline/processor.py`, `tests/test_processor.py` + +- [ ] **Write `tests/test_processor.py`** + +```python +import pytest +import numpy as np +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + + +@pytest.fixture +def proc_settings(settings): + return settings + + +@pytest.mark.asyncio +async def test_process_utterance_known_speaker(mocker, proc_settings, db): + # Setup: insert a room and speaker with embedding + db.execute("INSERT INTO rooms (name) VALUES (?)", ("Kitchen",)) + db.execute("INSERT INTO speakers (name) VALUES (?)", ("Jeremy",)) + db.commit() + room = db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone() + speaker = db.execute("SELECT id FROM speakers WHERE name='Jeremy'").fetchone() + + from app.pipeline.recognition import pack_embedding + ref_emb = np.array([1.0] + [0.0] * 255, dtype=np.float32) + db.execute( + "INSERT INTO voice_embeddings (speaker_id, embedding) VALUES (?,?)", + (speaker["id"], pack_embedding(ref_emb)), + ) + db.commit() + + # Mock Whisper HTTP call + mocker.patch( + "app.pipeline.processor.transcribe_audio", + new=AsyncMock(return_value="hello world"), + ) + # Mock resemblyzer — return a very similar embedding + query_emb = np.array([0.99] + [0.0] * 255, dtype=np.float32) + mocker.patch("app.pipeline.processor.embed_audio", return_value=query_emb) + # Mock clip saving + mocker.patch("app.pipeline.processor.save_clip", return_value="/data/clips/test.wav") + + from app.pipeline.processor import process_utterance + start = datetime(2026, 1, 1, 10, 0, 0, tzinfo=timezone.utc) + end = datetime(2026, 1, 1, 10, 0, 3, tzinfo=timezone.utc) + + utterance_id = await process_utterance( + room_id=room["id"], + audio_bytes=b"\x00" * 100, + start_time=start, + end_time=end, + settings=proc_settings, + db=db, + ) + + assert utterance_id is not None + row = db.execute("SELECT * FROM utterances WHERE id=?", (utterance_id,)).fetchone() + assert row["transcript"] == "hello world" + assert row["match_status"] == "known" + assert row["speaker_id"] == speaker["id"] + + +@pytest.mark.asyncio +async def test_process_utterance_unknown_speaker(mocker, proc_settings, db): + db.execute("INSERT INTO rooms (name) VALUES (?)", ("Office",)) + db.commit() + room = db.execute("SELECT id FROM rooms WHERE name='Office'").fetchone() + + mocker.patch( + "app.pipeline.processor.transcribe_audio", + new=AsyncMock(return_value="test transcript"), + ) + mocker.patch( + "app.pipeline.processor.embed_audio", + return_value=np.zeros(256, dtype=np.float32), + ) + mocker.patch("app.pipeline.processor.save_clip", return_value="/data/clips/test.wav") + + from app.pipeline.processor import process_utterance + start = datetime(2026, 1, 1, 10, 0, 0, tzinfo=timezone.utc) + end = datetime(2026, 1, 1, 10, 0, 2, tzinfo=timezone.utc) + + utterance_id = await process_utterance( + room_id=room["id"], + audio_bytes=b"\x00" * 100, + start_time=start, + end_time=end, + settings=proc_settings, + db=db, + ) + + row = db.execute("SELECT * FROM utterances WHERE id=?", (utterance_id,)).fetchone() + assert row["match_status"] == "unknown" + assert row["speaker_id"] is None +``` + +- [ ] **Run to confirm failure** + +```bash +pytest tests/test_processor.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'app.pipeline.processor'` + +- [ ] **Write `app/pipeline/processor.py`** + +```python +import asyncio +import sqlite3 +import wave +import io +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +import httpx +import numpy as np + +from app.shared.config import Settings +from app.pipeline.recognition import embed_audio, find_best_speaker_match, pack_embedding + + +async def transcribe_audio(wav_bytes: bytes, speaches_url: str) -> str: + """POST audio to Speaches/Whisper, return transcript text.""" + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{speaches_url}/v1/audio/transcriptions", + files={"file": ("audio.wav", wav_bytes, "audio/wav")}, + data={"model": "Systran/faster-whisper-large-v3-turbo"}, + ) + resp.raise_for_status() + return resp.json()["text"].strip() + + +def save_clip(audio_bytes: bytes, clips_dir: str, utterance_id: int, start_time: datetime) -> str: + """Save raw PCM bytes as a WAV file, return the path.""" + date_str = start_time.strftime("%Y-%m-%d") + clip_dir = Path(clips_dir) / date_str + clip_dir.mkdir(parents=True, exist_ok=True) + path = clip_dir / f"{utterance_id}.wav" + with wave.open(str(path), "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) # int16 + wf.setframerate(16000) + wf.writeframes(audio_bytes) + return str(path) + + +async def process_utterance( + room_id: int, + audio_bytes: bytes, + start_time: datetime, + end_time: datetime, + settings: Settings, + db: sqlite3.Connection, +) -> Optional[int]: + """ + Full pipeline for one speech segment: + 1. Whisper STT + resemblyzer embed (concurrent) + 2. Speaker matching + 3. SQLite write + WAV clip save + Returns the new utterance row id, or None on failure. + """ + try: + transcript, embedding = await asyncio.gather( + transcribe_audio(audio_bytes, settings.speaches_url), + asyncio.to_thread(embed_audio, audio_bytes), + ) + except Exception: + return None + + # Load all stored embeddings for matching + rows = db.execute("SELECT speaker_id, embedding FROM voice_embeddings").fetchall() + stored = [(r["speaker_id"], r["embedding"]) for r in rows] + + match_status, speaker_id, confidence = find_best_speaker_match( + embedding, + stored, + known_threshold=settings.speaker_known_threshold, + ambiguous_threshold=settings.speaker_ambiguous_threshold, + ) + + # Insert utterance row first to get the id + cur = db.execute( + """INSERT INTO utterances + (room_id, speaker_id, transcript, embedding, match_status, match_confidence, + start_time, end_time) + VALUES (?,?,?,?,?,?,?,?)""", + ( + room_id, + speaker_id, + transcript, + pack_embedding(embedding), + match_status, + confidence, + start_time.isoformat(), + end_time.isoformat(), + ), + ) + db.commit() + utterance_id = cur.lastrowid + + # Save WAV clip and update row with path + clip_path = save_clip(audio_bytes, settings.clips_dir, utterance_id, start_time) + db.execute( + "UPDATE utterances SET audio_clip_path=? WHERE id=?", + (clip_path, utterance_id), + ) + db.commit() + + # If known match, store embedding as an additional reference for this speaker + if match_status == "known" and speaker_id is not None: + db.execute( + "INSERT INTO voice_embeddings (speaker_id, embedding) VALUES (?,?)", + (speaker_id, pack_embedding(embedding)), + ) + db.commit() + + return utterance_id +``` + +- [ ] **Run tests — expect PASS** + +```bash +pytest tests/test_processor.py -v +``` + +Expected: `2 passed` + +- [ ] **Commit** + +```bash +git add app/pipeline/processor.py tests/test_processor.py +git commit -m "feat: processing pipeline — Whisper + resemblyzer + speaker match + SQLite write" +git push origin master +``` + +--- + +## Task 7: Pipeline FastAPI app (WebSocket + internal HTTP) + +**Files:** `app/pipeline/main.py` + +*(No TDD — integration test requires running server; covered in Task 18 deployment verification)* + +- [ ] **Write `app/pipeline/main.py`** + +```python +import asyncio +import sqlite3 +from contextlib import asynccontextmanager +from datetime import datetime, timezone + +import uvicorn +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException +from fastapi.responses import JSONResponse + +from app.shared.config import get_settings, Settings +from app.shared.database import get_db, init_schema +from app.pipeline.ingestion import get_or_create_room, mute_room, unmute_room, is_muted +from app.pipeline.processor import process_utterance + +_settings: Settings | None = None + + +def settings() -> Settings: + global _settings + if _settings is None: + _settings = get_settings() + return _settings + + +@asynccontextmanager +async def lifespan(app: FastAPI): + s = settings() + init_schema(s) + yield + + +app = FastAPI(lifespan=lifespan) + + +@app.websocket("/ws/{room_name}") +async def audio_ws(websocket: WebSocket, room_name: str): + await websocket.accept() + s = settings() + db = get_db(s) + + # Upsert room in DB + db.execute( + "INSERT INTO rooms (name, is_active, last_seen) VALUES (?,1,CURRENT_TIMESTAMP) " + "ON CONFLICT(name) DO UPDATE SET is_active=1, last_seen=CURRENT_TIMESTAMP", + (room_name,), + ) + db.commit() + room = db.execute("SELECT id FROM rooms WHERE name=?", (room_name,)).fetchone() + room_id = room["id"] + + room_state = get_or_create_room(room_name, s.vad_silence_ms, s.vad_min_speech_ms) + + try: + while True: + data = await websocket.receive() + if "bytes" in data: + chunk = data["bytes"] + if not is_muted(room_name): + db.execute( + "UPDATE rooms SET last_seen=CURRENT_TIMESTAMP WHERE id=?", + (room_id,), + ) + now = datetime.now(timezone.utc) + result = room_state["buffer"].process_chunk(chunk, now) + if result is not None: + audio_bytes, start_time = result + end_time = datetime.now(timezone.utc) + asyncio.create_task( + process_utterance(room_id, audio_bytes, start_time, end_time, s, db) + ) + elif "text" in data: + import json + msg = json.loads(data["text"]) + if msg.get("type") == "mute": + mute_room(room_name) + elif msg.get("type") == "unmute": + unmute_room(room_name) + except WebSocketDisconnect: + db.execute( + "UPDATE rooms SET is_active=0 WHERE id=?", (room_id,) + ) + db.commit() + + +@app.post("/internal/rooms/{room_name}/mute") +async def internal_mute(room_name: str): + mute_room(room_name) + return {"status": "muted", "room": room_name} + + +@app.post("/internal/rooms/{room_name}/unmute") +async def internal_unmute(room_name: str): + unmute_room(room_name) + return {"status": "unmuted", "room": room_name} + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +if __name__ == "__main__": + uvicorn.run("app.pipeline.main:app", host="0.0.0.0", port=8300, reload=False) +``` + +- [ ] **Commit** + +```bash +git add app/pipeline/main.py +git commit -m "feat: pipeline FastAPI app — WebSocket audio ingestion + internal mute API" +git push origin master +``` + +--- + +## Task 8: API routes — utterances (TDD) + +**Files:** `app/api/routes/utterances.py`, `tests/routes/conftest.py`, `tests/routes/test_utterances.py` + +- [ ] **Write `tests/routes/conftest.py`** + +```python +import pytest +from fastapi.testclient import TestClient +from app.shared.database import init_schema, get_db +from app.shared.config import Settings + + +@pytest.fixture +def api_client(settings): + from app.api.main import create_app + app = create_app(settings) + return TestClient(app) + + +@pytest.fixture +def seeded_db(db): + """DB with a room and speaker pre-inserted.""" + db.execute("INSERT INTO rooms (name) VALUES (?)", ("Kitchen",)) + db.execute("INSERT INTO speakers (name) VALUES (?)", ("Jeremy",)) + db.commit() + return db +``` + +- [ ] **Write `tests/routes/test_utterances.py`** + +```python +from datetime import datetime + + +def test_list_utterances_empty(api_client): + resp = api_client.get("/api/utterances") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_list_utterances_with_data(api_client, seeded_db): + room = seeded_db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone() + seeded_db.execute( + "INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time) " + "VALUES (?,?,?,?,?)", + (room["id"], "hello world", "unknown", "2026-01-01 10:00:00", "2026-01-01 10:00:03"), + ) + seeded_db.commit() + resp = api_client.get("/api/utterances") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["transcript"] == "hello world" + assert data[0]["room_name"] == "Kitchen" + + +def test_filter_utterances_by_date(api_client, seeded_db): + room = seeded_db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone() + seeded_db.execute( + "INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time) VALUES (?,?,?,?,?)", + (room["id"], "today speech", "unknown", "2026-05-27 10:00:00", "2026-05-27 10:00:03"), + ) + seeded_db.execute( + "INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time) VALUES (?,?,?,?,?)", + (room["id"], "yesterday speech", "unknown", "2026-05-26 10:00:00", "2026-05-26 10:00:03"), + ) + seeded_db.commit() + resp = api_client.get("/api/utterances?date=2026-05-27") + data = resp.json() + assert len(data) == 1 + assert data[0]["transcript"] == "today speech" + + +def test_tag_utterance_existing_speaker(api_client, seeded_db): + room = seeded_db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone() + speaker = seeded_db.execute("SELECT id FROM speakers WHERE name='Jeremy'").fetchone() + seeded_db.execute( + "INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time, embedding) VALUES (?,?,?,?,?,?)", + (room["id"], "tag me", "unknown", "2026-01-01 10:00:00", "2026-01-01 10:00:03", + b"\x00" * 1024), + ) + seeded_db.commit() + utt = seeded_db.execute("SELECT id FROM utterances").fetchone() + resp = api_client.post(f"/api/utterances/{utt['id']}/tag", json={"speaker_id": speaker["id"]}) + assert resp.status_code == 200 + updated = seeded_db.execute("SELECT * FROM utterances WHERE id=?", (utt["id"],)).fetchone() + assert updated["speaker_id"] == speaker["id"] + assert updated["match_status"] == "known" + assert updated["match_confidence"] == 1.0 + + +def test_tag_utterance_create_new_speaker(api_client, seeded_db): + room = seeded_db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone() + seeded_db.execute( + "INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time) VALUES (?,?,?,?,?)", + (room["id"], "new speaker", "unknown", "2026-01-01 10:00:00", "2026-01-01 10:00:03"), + ) + seeded_db.commit() + utt = seeded_db.execute("SELECT id FROM utterances WHERE transcript='new speaker'").fetchone() + resp = api_client.post( + f"/api/utterances/{utt['id']}/tag", + json={"name": "Alice", "create_new": True}, + ) + assert resp.status_code == 200 + alice = seeded_db.execute("SELECT id FROM speakers WHERE name='Alice'").fetchone() + assert alice is not None + + +def test_dismiss_utterance(api_client, seeded_db): + room = seeded_db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone() + seeded_db.execute( + "INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time) VALUES (?,?,?,?,?)", + (room["id"], "dismiss me", "unreviewed", "2026-01-01 10:00:00", "2026-01-01 10:00:03"), + ) + seeded_db.commit() + utt = seeded_db.execute("SELECT id FROM utterances WHERE transcript='dismiss me'").fetchone() + resp = api_client.post(f"/api/utterances/{utt['id']}/dismiss") + assert resp.status_code == 200 + updated = seeded_db.execute("SELECT match_status FROM utterances WHERE id=?", (utt["id"],)).fetchone() + assert updated["match_status"] == "unknown" +``` + +- [ ] **Run to confirm failure** + +```bash +pytest tests/routes/test_utterances.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'app.api.main'` + +- [ ] **Write `app/api/routes/utterances.py`** + +```python +import sqlite3 +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from app.shared.config import Settings +from app.pipeline.recognition import pack_embedding, unpack_embedding + +router = APIRouter(prefix="/api/utterances") + + +class TagRequest(BaseModel): + speaker_id: Optional[int] = None + name: Optional[str] = None + create_new: bool = False + + +def _row_to_dict(row: sqlite3.Row) -> dict: + return dict(row) + + +def get_utterances( + db: sqlite3.Connection, + date: Optional[str] = None, + room_id: Optional[int] = None, + speaker_id: Optional[int] = None, + match_status: Optional[str] = None, + limit: int = 200, +) -> list[dict]: + query = """ + SELECT u.*, r.name as room_name, s.name as speaker_name + FROM utterances u + JOIN rooms r ON u.room_id = r.id + LEFT JOIN speakers s ON u.speaker_id = s.id + WHERE 1=1 + """ + params: list = [] + if date: + query += " AND date(u.start_time) = ?" + params.append(date) + if room_id: + query += " AND u.room_id = ?" + params.append(room_id) + if speaker_id: + query += " AND u.speaker_id = ?" + params.append(speaker_id) + if match_status: + query += " AND u.match_status = ?" + params.append(match_status) + query += " ORDER BY u.start_time DESC LIMIT ?" + params.append(limit) + rows = db.execute(query, params).fetchall() + return [_row_to_dict(r) for r in rows] + + +def make_router(settings: Settings, db_factory): + + @router.get("") + def list_utterances( + date: Optional[str] = None, + room_id: Optional[int] = None, + speaker_id: Optional[int] = None, + match_status: Optional[str] = None, + ): + db = db_factory() + return get_utterances(db, date, room_id, speaker_id, match_status) + + @router.get("/{utterance_id}/clip") + def get_clip(utterance_id: int): + db = db_factory() + row = db.execute( + "SELECT audio_clip_path FROM utterances WHERE id=?", (utterance_id,) + ).fetchone() + if not row or not row["audio_clip_path"]: + raise HTTPException(404, "Clip not found or expired") + return FileResponse(row["audio_clip_path"], media_type="audio/wav") + + @router.post("/{utterance_id}/tag") + def tag_utterance(utterance_id: int, body: TagRequest): + db = db_factory() + row = db.execute("SELECT * FROM utterances WHERE id=?", (utterance_id,)).fetchone() + if not row: + raise HTTPException(404, "Utterance not found") + + speaker_id = body.speaker_id + if body.create_new and body.name: + cur = db.execute("INSERT INTO speakers (name) VALUES (?)", (body.name,)) + db.commit() + speaker_id = cur.lastrowid + + if speaker_id is None: + raise HTTPException(400, "Provide speaker_id or name+create_new") + + db.execute( + "UPDATE utterances SET speaker_id=?, match_status='known', match_confidence=1.0 WHERE id=?", + (speaker_id, utterance_id), + ) + db.commit() + + # Store utterance embedding as a reference for this speaker + if row["embedding"]: + db.execute( + "INSERT INTO voice_embeddings (speaker_id, embedding) VALUES (?,?)", + (speaker_id, row["embedding"]), + ) + db.commit() + + return {"ok": True, "speaker_id": speaker_id} + + @router.post("/{utterance_id}/dismiss") + def dismiss_utterance(utterance_id: int): + db = db_factory() + db.execute( + "UPDATE utterances SET match_status='unknown' WHERE id=?", (utterance_id,) + ) + db.commit() + return {"ok": True} + + return router +``` + +- [ ] **Write minimal `app/api/main.py` (enough for tests to import)** + +```python +import sqlite3 +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from pathlib import Path + +from app.shared.config import Settings +from app.shared.database import get_db, init_schema +from app.api.routes import utterances, speakers, rooms, search, rag + + +def create_app(settings: Settings) -> FastAPI: + init_schema(settings) + app = FastAPI(title="LinkedStorm") + + def db_factory() -> sqlite3.Connection: + return get_db(settings) + + app.include_router(utterances.make_router(settings, db_factory)) + app.include_router(speakers.make_router(settings, db_factory)) + app.include_router(rooms.make_router(settings, db_factory)) + app.include_router(search.make_router(settings, db_factory)) + app.include_router(rag.make_router(settings, db_factory)) + + static_dir = Path(__file__).parent / "static" + if static_dir.exists(): + app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static") + + return app +``` + +Note: `speakers`, `rooms`, `search`, `rag` routers are stub files — write them now: + +- [ ] **Write stub `app/api/routes/speakers.py`** + +```python +from fastapi import APIRouter +router = APIRouter(prefix="/api/speakers") +def make_router(settings, db_factory): + return router +``` + +- [ ] **Write stub `app/api/routes/rooms.py`** + +```python +from fastapi import APIRouter +router = APIRouter(prefix="/api/rooms") +def make_router(settings, db_factory): + return router +``` + +- [ ] **Write stub `app/api/routes/search.py`** + +```python +from fastapi import APIRouter +router = APIRouter(prefix="/api/search") +def make_router(settings, db_factory): + return router +``` + +- [ ] **Write stub `app/api/routes/rag.py`** + +```python +from fastapi import APIRouter +router = APIRouter(prefix="/api") +def make_router(settings, db_factory): + return router +``` + +- [ ] **Run tests — expect PASS** + +```bash +pytest tests/routes/test_utterances.py -v +``` + +Expected: `6 passed` + +- [ ] **Commit** + +```bash +git add app/api/ tests/routes/ +git commit -m "feat: utterances API routes — list, clip, tag, dismiss" +git push origin master +``` + +--- + +## Task 9: API routes — speakers, rooms, search, RAG (TDD) + +**Files:** `app/api/routes/speakers.py`, `rooms.py`, `search.py`, `rag.py` (replace stubs), plus tests + +- [ ] **Write `tests/routes/test_speakers.py`** + +```python +def test_list_speakers_empty(api_client): + resp = api_client.get("/api/speakers") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_create_speaker(api_client): + resp = api_client.post("/api/speakers", json={"name": "Alice"}) + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "Alice" + assert "id" in data + + +def test_list_speakers_includes_utterance_count(api_client, seeded_db): + room = seeded_db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone() + speaker = seeded_db.execute("SELECT id FROM speakers WHERE name='Jeremy'").fetchone() + seeded_db.execute( + "INSERT INTO utterances (room_id, speaker_id, transcript, match_status, start_time, end_time) VALUES (?,?,?,?,?,?)", + (room["id"], speaker["id"], "hi", "known", "2026-01-01 10:00:00", "2026-01-01 10:00:01"), + ) + seeded_db.commit() + resp = api_client.get("/api/speakers") + data = resp.json() + jeremy = next(s for s in data if s["name"] == "Jeremy") + assert jeremy["utterance_count"] == 1 +``` + +- [ ] **Write `tests/routes/test_rooms.py`** + +```python +def test_list_rooms(api_client, seeded_db): + resp = api_client.get("/api/rooms") + assert resp.status_code == 200 + data = resp.json() + assert any(r["name"] == "Kitchen" for r in data) + + +def test_mute_room_calls_pipeline(api_client, seeded_db, mocker): + mock_post = mocker.patch("app.api.routes.rooms.httpx.post") + mock_post.return_value.status_code = 200 + room = seeded_db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone() + resp = api_client.post(f"/api/rooms/{room['id']}/mute") + assert resp.status_code == 200 + mock_post.assert_called_once() +``` + +- [ ] **Write `tests/routes/test_search.py`** + +```python +def test_search_returns_matches(api_client, seeded_db): + room = seeded_db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone() + seeded_db.execute( + "INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time) VALUES (?,?,?,?,?)", + (room["id"], "the grocery list is ready", "unknown", "2026-01-01 10:00:00", "2026-01-01 10:00:03"), + ) + seeded_db.commit() + resp = api_client.get("/api/search?q=grocery") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert "grocery" in data[0]["transcript"] + + +def test_search_no_results(api_client): + resp = api_client.get("/api/search?q=xyznotfound") + assert resp.status_code == 200 + assert resp.json() == [] +``` + +- [ ] **Write `tests/routes/test_rag.py`** + +```python +def test_ask_returns_response(api_client, seeded_db, mocker): + mock_post = mocker.patch("app.api.routes.rag.httpx.post") + mock_post.return_value.json.return_value = { + "message": {"content": "You talked about groceries."} + } + mock_post.return_value.status_code = 200 + room = seeded_db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone() + seeded_db.execute( + "INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time) VALUES (?,?,?,?,?)", + (room["id"], "we need milk from the store", "unknown", "2026-01-01 10:00:00", "2026-01-01 10:00:03"), + ) + seeded_db.commit() + resp = api_client.post("/api/ask", json={"question": "What did we need from the store?"}) + assert resp.status_code == 200 + data = resp.json() + assert "answer" in data + + +def test_get_summary_not_found(api_client): + resp = api_client.get("/api/summary/2026-01-01") + assert resp.status_code == 404 +``` + +- [ ] **Run to confirm failures** + +```bash +pytest tests/routes/ -v +``` + +Expected: New test files fail. `test_utterances.py` still passes. + +- [ ] **Replace stub `app/api/routes/speakers.py`** + +```python +import sqlite3 +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +router = APIRouter(prefix="/api/speakers") + + +class CreateSpeakerRequest(BaseModel): + name: str + + +def make_router(settings, db_factory): + + @router.get("") + def list_speakers(): + db: sqlite3.Connection = db_factory() + rows = db.execute(""" + SELECT s.id, s.name, s.created_at, + COUNT(u.id) as utterance_count + FROM speakers s + LEFT JOIN utterances u ON u.speaker_id = s.id + GROUP BY s.id + ORDER BY s.name + """).fetchall() + return [dict(r) for r in rows] + + @router.post("") + def create_speaker(body: CreateSpeakerRequest): + db: sqlite3.Connection = db_factory() + cur = db.execute("INSERT INTO speakers (name) VALUES (?)", (body.name,)) + db.commit() + row = db.execute("SELECT * FROM speakers WHERE id=?", (cur.lastrowid,)).fetchone() + return dict(row) + + return router +``` + +- [ ] **Replace stub `app/api/routes/rooms.py`** + +```python +import sqlite3 +import httpx +from fastapi import APIRouter, HTTPException +from app.shared.config import Settings + +router = APIRouter(prefix="/api/rooms") + + +def make_router(settings: Settings, db_factory): + + @router.get("") + def list_rooms(): + db: sqlite3.Connection = db_factory() + rows = db.execute( + "SELECT id, name, device_label, is_active, last_seen, created_at FROM rooms ORDER BY name" + ).fetchall() + return [dict(r) for r in rows] + + @router.post("/{room_id}/mute") + def mute_room(room_id: int): + db: sqlite3.Connection = db_factory() + row = db.execute("SELECT name FROM rooms WHERE id=?", (room_id,)).fetchone() + if not row: + raise HTTPException(404, "Room not found") + httpx.post(f"{settings.pipeline_internal_url}/internal/rooms/{row['name']}/mute") + return {"ok": True, "room": row["name"]} + + @router.post("/{room_id}/unmute") + def unmute_room(room_id: int): + db: sqlite3.Connection = db_factory() + row = db.execute("SELECT name FROM rooms WHERE id=?", (room_id,)).fetchone() + if not row: + raise HTTPException(404, "Room not found") + httpx.post(f"{settings.pipeline_internal_url}/internal/rooms/{row['name']}/unmute") + return {"ok": True, "room": row["name"]} + + return router +``` + +- [ ] **Replace stub `app/api/routes/search.py`** + +```python +import sqlite3 +from typing import Optional +from fastapi import APIRouter, HTTPException + +router = APIRouter(prefix="/api") + + +def make_router(settings, db_factory): + + @router.get("/search") + def search(q: str, date: Optional[str] = None): + if not q.strip(): + return [] + db: sqlite3.Connection = db_factory() + fts_query = q.strip() + rows = db.execute(""" + SELECT u.*, r.name as room_name, s.name as speaker_name + FROM utterances_fts fts + JOIN utterances u ON u.id = fts.rowid + JOIN rooms r ON u.room_id = r.id + LEFT JOIN speakers s ON u.speaker_id = s.id + WHERE utterances_fts MATCH ? + ORDER BY u.start_time DESC + LIMIT 100 + """, (fts_query,)).fetchall() + return [dict(r) for r in rows] + + return router +``` + +- [ ] **Replace stub `app/api/routes/rag.py`** + +```python +import sqlite3 +import httpx +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from app.shared.config import Settings + +router = APIRouter(prefix="/api") + + +class AskRequest(BaseModel): + question: str + date: str | None = None + + +def make_router(settings: Settings, db_factory): + + @router.post("/ask") + def ask(body: AskRequest): + db: sqlite3.Connection = db_factory() + # Fetch recent transcripts as context (last 200 utterances for the date or today) + date_filter = body.date or "date('now')" + rows = db.execute(""" + SELECT u.start_time, u.transcript, r.name as room_name, s.name as speaker_name + FROM utterances u + JOIN rooms r ON u.room_id = r.id + LEFT JOIN speakers s ON u.speaker_id = s.id + WHERE date(u.start_time) = date(?) + ORDER BY u.start_time ASC + LIMIT 200 + """, (body.date or "now",)).fetchall() + + context_lines = [ + f"[{r['room_name']} {r['start_time']}] {r['speaker_name'] or 'Unknown'}: {r['transcript']}" + for r in rows + ] + context = "\n".join(context_lines) if context_lines else "(no transcripts for this date)" + + prompt = ( + f"You are a helpful assistant with access to household conversation transcripts.\n\n" + f"Transcripts:\n{context}\n\n" + f"Question: {body.question}\n\n" + f"Answer based only on the transcripts above. Cite the time and room when relevant." + ) + + resp = httpx.post( + f"{settings.ollama_url}/api/chat", + json={ + "model": settings.ollama_model, + "messages": [{"role": "user", "content": prompt}], + "stream": False, + }, + timeout=60.0, + ) + resp.raise_for_status() + answer = resp.json()["message"]["content"] + return {"answer": answer, "context_count": len(rows)} + + @router.get("/summary/{date}") + def get_summary(date: str): + db: sqlite3.Connection = db_factory() + row = db.execute( + "SELECT * FROM daily_summaries WHERE date=?", (date,) + ).fetchone() + if not row: + raise HTTPException(404, f"No summary for {date}") + return dict(row) + + return router +``` + +- [ ] **Run all route tests** + +```bash +pytest tests/routes/ -v +``` + +Expected: `~14 passed` + +- [ ] **Commit** + +```bash +git add app/api/routes/ tests/routes/ +git commit -m "feat: API routes — speakers, rooms, search, RAG" +git push origin master +``` + +--- + +## Task 10: Cron jobs (TDD) + +**Files:** `app/cron/daily_summary.py`, `app/cron/cluster_unknowns.py`, `app/cron/purge_clips.py`, `app/cron/main.py`, `tests/test_cron.py` + +- [ ] **Write `tests/test_cron.py`** + +```python +import pytest +import os +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch, MagicMock + + +def _seed_utterances(db, room_id, count=3): + for i in range(count): + db.execute( + "INSERT INTO utterances (room_id, transcript, match_status, start_time, end_time) VALUES (?,?,?,?,?)", + (room_id, f"utterance {i}", "unknown", + "2026-05-27 10:00:00", "2026-05-27 10:00:03"), + ) + db.commit() + + +def test_daily_summary_inserts_row(mocker, settings, db): + db.execute("INSERT INTO rooms (name) VALUES (?)", ("Kitchen",)) + db.commit() + room = db.execute("SELECT id FROM rooms WHERE name='Kitchen'").fetchone() + _seed_utterances(db, room["id"]) + + mock_post = mocker.patch("app.cron.daily_summary.httpx.post") + mock_post.return_value.json.return_value = {"message": {"content": "Busy day."}} + mock_post.return_value.raise_for_status = MagicMock() + + from app.cron.daily_summary import generate_daily_summary + generate_daily_summary("2026-05-27", settings, db) + + row = db.execute("SELECT * FROM daily_summaries WHERE date='2026-05-27'").fetchone() + assert row is not None + assert row["summary_text"] == "Busy day." + + +def test_daily_summary_skips_if_no_utterances(mocker, settings, db): + mock_post = mocker.patch("app.cron.daily_summary.httpx.post") + from app.cron.daily_summary import generate_daily_summary + generate_daily_summary("2026-05-27", settings, db) + mock_post.assert_not_called() + + +def test_purge_clips_deletes_old_files(settings, db, tmp_path): + from app.cron.purge_clips import purge_old_clips + import shutil + + clips_dir = tmp_path / "clips" + old_dir = clips_dir / "2026-04-01" + old_dir.mkdir(parents=True) + old_file = old_dir / "1.wav" + old_file.write_bytes(b"\x00" * 100) + + new_dir = clips_dir / "2026-05-27" + new_dir.mkdir(parents=True) + new_file = new_dir / "2.wav" + new_file.write_bytes(b"\x00" * 100) + + settings_override = type(settings)( + db_path=settings.db_path, + clips_dir=str(clips_dir), + clip_retention_days=30, + speaches_url=settings.speaches_url, + ollama_url=settings.ollama_url, + livekit_api_key=settings.livekit_api_key, + livekit_api_secret=settings.livekit_api_secret, + ) + + purge_old_clips("2026-05-28", settings_override, db) + assert not old_file.exists() + assert new_file.exists() +``` + +- [ ] **Run to confirm failures** + +```bash +pytest tests/test_cron.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'app.cron.daily_summary'` + +- [ ] **Write `app/cron/daily_summary.py`** + +```python +import sqlite3 +import httpx +from app.shared.config import Settings + + +def generate_daily_summary(date: str, settings: Settings, db: sqlite3.Connection) -> None: + rows = db.execute(""" + SELECT u.start_time, u.transcript, r.name as room_name, s.name as speaker_name + FROM utterances u + JOIN rooms r ON u.room_id = r.id + LEFT JOIN speakers s ON u.speaker_id = s.id + WHERE date(u.start_time) = ? + ORDER BY u.start_time ASC + """, (date,)).fetchall() + + if not rows: + return + + lines = [ + f"[{r['room_name']} {r['start_time']}] {r['speaker_name'] or 'Unknown'}: {r['transcript']}" + for r in rows + ] + context = "\n".join(lines) + + prompt = ( + f"Summarize the following household conversations from {date} in 2-4 sentences. " + f"Focus on key decisions, activities, and topics discussed. Do not transcribe verbatim.\n\n" + f"{context}" + ) + + resp = httpx.post( + f"{settings.ollama_url}/api/chat", + json={ + "model": settings.ollama_model, + "messages": [{"role": "user", "content": prompt}], + "stream": False, + }, + timeout=120.0, + ) + resp.raise_for_status() + summary = resp.json()["message"]["content"] + + db.execute( + "INSERT INTO daily_summaries (date, summary_text) VALUES (?,?) " + "ON CONFLICT(date) DO UPDATE SET summary_text=?, generated_at=CURRENT_TIMESTAMP", + (date, summary, summary), + ) + db.commit() +``` + +- [ ] **Write `app/cron/cluster_unknowns.py`** + +```python +import sqlite3 +import numpy as np +from app.shared.config import Settings +from app.pipeline.recognition import unpack_embedding, cosine_similarity + + +def cluster_unknown_speakers(settings: Settings, db: sqlite3.Connection) -> None: + """ + Groups unknown/ambiguous utterances from the last 7 days by voice similarity. + Assigns speaker_id from an existing 'Unknown #N' speaker or creates a new one. + Threshold: 0.70 cosine similarity. + """ + CLUSTER_THRESHOLD = 0.70 + + rows = db.execute(""" + SELECT id, embedding FROM utterances + WHERE match_status IN ('unknown', 'ambiguous') + AND embedding IS NOT NULL + AND date(start_time) >= date('now', '-7 days') + ORDER BY start_time ASC + """).fetchall() + + if not rows: + return + + # Build clusters greedily + clusters: list[list[int]] = [] # list of utterance id lists + cluster_centroids: list[np.ndarray] = [] + + for row in rows: + emb = unpack_embedding(row["embedding"]) + best_cluster = None + best_sim = 0.0 + for i, centroid in enumerate(cluster_centroids): + sim = cosine_similarity(emb, centroid) + if sim > best_sim and sim >= CLUSTER_THRESHOLD: + best_sim = sim + best_cluster = i + if best_cluster is not None: + clusters[best_cluster].append(row["id"]) + # Update centroid as mean + n = len(clusters[best_cluster]) + cluster_centroids[best_cluster] = ( + (cluster_centroids[best_cluster] * (n - 1) + emb) / n + ) + else: + clusters.append([row["id"]]) + cluster_centroids.append(emb) + + # Assign Unknown #N speakers to clusters with > 1 utterance + for i, cluster in enumerate(clusters): + if len(cluster) < 2: + continue + name = f"Unknown #{i + 1}" + existing = db.execute("SELECT id FROM speakers WHERE name=?", (name,)).fetchone() + if existing: + speaker_id = existing["id"] + else: + cur = db.execute("INSERT INTO speakers (name) VALUES (?)", (name,)) + db.commit() + speaker_id = cur.lastrowid + for utt_id in cluster: + db.execute( + "UPDATE utterances SET speaker_id=?, match_status='ambiguous' WHERE id=?", + (speaker_id, utt_id), + ) + db.commit() +``` + +- [ ] **Write `app/cron/purge_clips.py`** + +```python +import sqlite3 +from datetime import datetime, timedelta +from pathlib import Path +from app.shared.config import Settings + + +def purge_old_clips(today: str, settings: Settings, db: sqlite3.Connection) -> None: + cutoff = datetime.strptime(today, "%Y-%m-%d") - timedelta(days=settings.clip_retention_days) + clips_root = Path(settings.clips_dir) + deleted = 0 + for date_dir in clips_root.iterdir(): + if not date_dir.is_dir(): + continue + try: + dir_date = datetime.strptime(date_dir.name, "%Y-%m-%d") + except ValueError: + continue + if dir_date < cutoff: + for wav in date_dir.glob("*.wav"): + wav.unlink() + deleted += 1 + try: + date_dir.rmdir() + except OSError: + pass + # Null out audio_clip_path for purged clips + cutoff_str = cutoff.strftime("%Y-%m-%d") + db.execute( + "UPDATE utterances SET audio_clip_path=NULL WHERE date(start_time) < ?", + (cutoff_str,), + ) + db.commit() +``` + +- [ ] **Write `app/cron/main.py`** + +```python +import time +from datetime import datetime, timezone + +from app.shared.config import get_settings +from app.shared.database import get_db, init_schema +from app.cron.daily_summary import generate_daily_summary +from app.cron.cluster_unknowns import cluster_unknown_speakers +from app.cron.purge_clips import purge_old_clips + + +def run_nightly(settings, db): + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + yesterday = (datetime.now(timezone.utc).date()).strftime("%Y-%m-%d") + generate_daily_summary(yesterday, settings, db) + cluster_unknown_speakers(settings, db) + purge_old_clips(today, settings, db) + + +def main(): + settings = get_settings() + init_schema(settings) + db = get_db(settings) + + while True: + now = datetime.now(timezone.utc) + # Sleep until midnight UTC + next_midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) + if next_midnight <= now: + from datetime import timedelta + next_midnight += timedelta(days=1) + sleep_secs = (next_midnight - now).total_seconds() + time.sleep(sleep_secs) + run_nightly(settings, db) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Run tests — expect PASS** + +```bash +pytest tests/test_cron.py -v +``` + +Expected: `3 passed` + +- [ ] **Commit** + +```bash +git add app/cron/ tests/test_cron.py +git commit -m "feat: cron jobs — daily summary, speaker clustering, clip purge" +git push origin master +``` + +--- + +## Task 11: Capture page + +**Files:** `app/api/static/capture.html` + +*(No unit tests — browser JS; verified manually in Task 18)* + +- [ ] **Create `app/api/static/capture.html`** + +```html + + + + + +LinkedStorm Capture + + + +
+

⚡ LinkedStorm

+ + +
+ + + +``` + +- [ ] **Commit** + +```bash +git add app/api/static/capture.html +git commit -m "feat: browser capture page — MediaRecorder WebSocket streaming + mute" +git push origin master +``` + +--- + +## Task 12: Dashboard SPA + +**Files:** `app/api/static/index.html`, `app/api/static/app.js` + +- [ ] **Create `app/api/static/index.html`** + +```html + + + + + +LinkedStorm + + + + +
+
+ +
+
+
+
+ + + +``` + +- [ ] **Create `app/api/static/app.js`** + +```javascript +// --- Utilities --- +const COLORS = ['#60a5fa','#a78bfa','#f472b6','#34d399','#fb923c','#38bdf8','#e879f9','#a3e635']; +const speakerColor = (id) => COLORS[(id || 0) % COLORS.length]; +const speakerInitial = (name) => (name || '?')[0].toUpperCase(); +const fmtTime = (iso) => new Date(iso).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}); +const fmtDate = (iso) => new Date(iso).toLocaleDateString([], {weekday:'long',month:'long',day:'numeric',year:'numeric'}); +const today = () => new Date().toISOString().slice(0,10); + +let _currentDate = today(); + +function showPanel(name, el) { + document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); + document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); + document.getElementById('panel-' + name).classList.add('active'); + if (el) el.classList.add('active'); + if (name === 'log') loadLog(); + if (name === 'search') renderSearch(); + if (name === 'speakers') loadSpeakers(); + if (name === 'rag') renderRag(); + if (name === 'rooms') loadRooms(); +} + +// --- Daily Log --- +async function loadLog(date) { + if (date) _currentDate = date; + const panel = document.getElementById('panel-log'); + const utterances = await fetch(`/api/utterances?date=${_currentDate}`).then(r => r.json()); + + const nav = `
+ + ${fmtDate(_currentDate + 'T12:00:00')} + + +
`; + + 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 ? `
+ + + +
` : ''; + html += `
+
${speakerInitial(name)}
+
+
+ ${name}${badge} + ${u.room_name} + ${fmtTime(u.start_time)} +
+
${escHtml(u.transcript)}
+ ${actions} +
+
`; + } + html += '
'; + } + panel.innerHTML = html; +} + +function shiftDate(days) { + const d = new Date(_currentDate + 'T12:00:00'); + d.setDate(d.getDate() + days); + return d.toISOString().slice(0,10); +} + +async function playClip(id) { + const a = document.createElement('audio'); + a.src = `/api/utterances/${id}/clip`; + a.controls = true; + a.autoplay = true; + document.body.appendChild(a); + a.onended = () => a.remove(); +} + +async function tagSpeaker(utteranceId) { + const speakers = await fetch('/api/speakers').then(r => r.json()); + const name = prompt('Speaker name (existing or new):' + speakers.map(s => '\n• ' + s.name).join('')); + if (!name) return; + const existing = speakers.find(s => s.name.toLowerCase() === name.toLowerCase()); + const body = existing + ? { speaker_id: existing.id } + : { name: name.trim(), create_new: true }; + await fetch(`/api/utterances/${utteranceId}/tag`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }); + loadLog(); +} + +async function dismiss(id) { + await fetch(`/api/utterances/${id}/dismiss`, { method: 'POST' }); + loadLog(); +} + +// --- Search --- +function renderSearch() { + const panel = document.getElementById('panel-search'); + panel.innerHTML = `

Search Transcripts

+
+ + +
+
`; +} + +async function doSearch() { + const q = document.getElementById('searchQ').value.trim(); + if (!q) return; + const results = await fetch(`/api/search?q=${encodeURIComponent(q)}`).then(r => r.json()); + const container = document.getElementById('searchResults'); + if (!results.length) { container.innerHTML = '
No results.
'; return; } + const highlight = (text) => text.replace(new RegExp(`(${q})`, 'gi'), '$1'); + container.innerHTML = results.map(r => `
+
${r.speaker_name || 'Unknown'} · ${r.room_name} · ${fmtDate(r.start_time)} ${fmtTime(r.start_time)}
+
${highlight(escHtml(r.transcript))}
+
`).join(''); +} + +// --- Speakers --- +async function loadSpeakers() { + const panel = document.getElementById('panel-speakers'); + const [speakers, unknowns] = await Promise.all([ + fetch('/api/speakers').then(r => r.json()), + fetch('/api/utterances?match_status=unreviewed').then(r => r.json()), + ]); + let html = ''; + if (unknowns.length) { + html += `
⚠ ${unknowns.length} utterances need speaker tags + +
`; + } + html += `
` + speakers.map(s => { + const color = speakerColor(s.id); + return `
+
${speakerInitial(s.name)}
+
${escHtml(s.name)}
+
${s.utterance_count} utterances
+
`; + }).join('') + `
+
+ Add speaker
+
`; + panel.innerHTML = `

Speakers

` + html; +} + +async function createSpeaker() { + const name = prompt('Speaker name:'); + if (!name) return; + await fetch('/api/speakers', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({name}) }); + loadSpeakers(); +} + +// --- Ask AI --- +const _ragHistory = []; +function renderRag() { + const panel = document.getElementById('panel-rag'); + panel.innerHTML = `

Ask AI

+
+
+ + +
`; + renderRagMessages(); +} + +function renderRagMessages() { + const el = document.getElementById('ragMessages'); + if (!el) return; + if (!_ragHistory.length) { el.innerHTML = '
Ask a question about any conversation.
'; return; } + el.innerHTML = _ragHistory.map(m => `
+
${m.role === 'user' ? 'You' : 'LinkedStorm AI'}
+
${escHtml(m.content)}${m.sources ? `
Sources: ${m.sources}
` : ''}
+
`).join(''); + el.scrollTop = el.scrollHeight; +} + +async function askRag() { + const q = document.getElementById('ragQ').value.trim(); + if (!q) return; + document.getElementById('ragQ').value = ''; + _ragHistory.push({ role: 'user', content: q }); + renderRagMessages(); + const resp = await fetch('/api/ask', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ question: q, date: _currentDate }), + }).then(r => r.json()); + _ragHistory.push({ role: 'ai', content: resp.answer, sources: `${resp.context_count} transcript segments` }); + renderRagMessages(); +} + +// --- Rooms --- +async function loadRooms() { + const panel = document.getElementById('panel-rooms'); + const rooms = await fetch('/api/rooms').then(r => r.json()); + let html = `

Capture Rooms

`; + 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.