{"key":"haip_ws6_handoff","title":"HAIP WS6 Handoff — Web Control UI (svc-apps)","content":"# HAIP WS6 Handoff — Web Control UI\nDate: 2026-04-04\nAuthor: Claude (Sonnet 4.6) via haip build session\n\n---\n\n## Platform State as of WS5 Complete\n\nThe Homelab AI Platform (package: `haip`) is a shared, self-hosted AI agent\norchestration foundation for four sub-projects: The Arena, Family\nRoots/Genealogy, Ohio History Channel, Stoned.AI.\n\nAll code through WS5 is complete and import-tested. No services are running as\nsystemd units yet — that is WS7. The platform is buildable and the Python\npackage is installed in its venv.\n\n---\n\n## VM Topology\n\n| VM | IP | Role |\n|----|-----|------|\n| svc-ai | 192.168.4.117 | Agent runtime, orchestration, IRC gateway, Brain/MCP host |\n| svc-db-01 | 192.168.4.113 | PostgreSQL 16.13 |\n| svc-apps | 192.168.4.114 | Web Control UI (appstack Docker pattern) |\n| svc-dev | 192.168.4.123 | Dev/test |\n\nSSH key for all VMs: `/home/svc-admin/.ssh/id_ed25519_homelab`\nSSH user: `svc-admin`\n\n---\n\n## Source and Workspace Paths\n\n### On svc-ai (primary build host — this is where you ARE when building):\n- Source: `/home/svc-admin/ai-projects/projects/homelab_ai_platform/`\n- Package root: `.../homelab_ai_platform/haip/`\n- Venv: `.../homelab_ai_platform/.venv/`\n- Runtime workspace: `/home/svc-admin/ai-platform/`\n  - `config/environments/local.env` — runtime env vars\n  - `config/systemd/haip-irc.service` — IRC gateway systemd unit (not installed yet)\n  - `secrets/platform_db_password` — chmod 600, plaintext password\n  - `runtime/` — provider state dirs (codex/, gemini/)\n  - `logs/`, `artifacts/`, `backups/`, `tmp/`\n\n### Architecture reference docs (READ ONLY — do not modify):\n- `/home/svc-admin/ai-projects/projects/homelab_ai_platform_architecture/`\n- Key docs for WS6:\n  - `docs/02-runtime/WEB_CONTROL_UI.md`\n  - `docs/04-infrastructure/SERVICE_LAYOUT.md`\n  - `docs/04-infrastructure/NETWORK_AND_PORTS.md`\n\n---\n\n## haip Package Structure (complete through WS5)\n\n```\nhaip/\n├── __init__.py\n├── db.py                          # asyncpg pool: init_pool(), get_pool(), close_pool()\n├── adapters/\n│   ├── base.py                    # Abstract ProviderAdapter\n│   ├── codex.py                   # CodexAdapter (JSON-lines, thread resumption)\n│   ├── gemini.py                  # GeminiAdapter (JSON output, session resumption)\n│   ├── registry.py                # AdapterRegistry, sync_provider_families(pool)\n│   └── __init__.py\n├── models/\n│   ├── provider.py                # AdapterCapabilities, ProviderFamily (frozen dataclasses)\n│   ├── session.py                 # SessionScope, SessionStatus, Session\n│   ├── invocation.py              # InvocationResult, InvocationError\n│   └── __init__.py\n├── sessions/\n│   ├── manager.py                 # SessionManager: get_or_create(), update_after_invocation(), mark_broken(), archive()\n│   └── __init__.py\n├── rooms/\n│   ├── models.py                  # RoomMode enum, Room dataclass, RosterMember dataclass\n│   ├── manager.py                 # RoomManager: full CRUD for rooms + roster memberships\n│   └── __init__.py\n├── orchestration/\n│   ├── turn_selector.py           # TurnSelector: weighted-random with dominance suppression\n│   ├── orchestrator.py            # Orchestrator.handle_turn() — main runtime entry point\n│   └── __init__.py\n├── memory/\n│   ├── models.py                  # NamespaceTier, ContributionType, WriteDecision, CONFIDENCE_THRESHOLDS, MemoryCandidate, ClassificationResult, PipelineOutcome\n│   ├── classifier.py              # MemoryClassifier: noise detection, tier routing, confidence gating\n│   ├── brain_client.py            # BrainClient: REST client for ContextKeep (store/retrieve/search/delete)\n│   ├── store.py                   # MemoryStore: PG-backed canonical_memories + memory_contributions\n│   ├── pipeline.py                # MemoryPipeline: 6-stage process(), retrieve_for_agent()\n│   └── __init__.py\n└── irc/\n    ├── client.py                  # IrcClient: asyncio raw TCP, NICK/USER/JOIN/PRIVMSG/PING, auto-reconnect\n    ├── commands.py                 # CommandParser: ! prefix commands\n    ├── formatter.py               # IrcFormatter: line splitting, paced output\n    ├── gateway.py                  # IrcGateway: orchestrates clients, routes msgs, handles commands\n    ├── service.py                  # Entry point: python -m haip.irc.service\n    └── __init__.py\n```\n\n### pyproject.toml\n```toml\n[build-system]\nrequires = [\"setuptools>=68\"]\nbuild-backend = \"setuptools.backends.legacy:build\"\n\n[project]\nname = \"homelab-ai-platform\"\nversion = \"0.1.0\"\nrequires-python = \">=3.12\"\ndependencies = [\"asyncpg>=0.29\"]\n\n[tool.setuptools.packages.find]\nwhere = [\".\"]\ninclude = [\"haip*\"]\n```\n\n---\n\n## Database\n\n- Host: 192.168.4.113 (svc-db-01)\n- Port: 5432\n- Database: `homelab_ai_platform`\n- User: `platform_runtime`\n- Password: in `/home/svc-admin/ai-platform/secrets/platform_db_password`\n\n### Key tables for the Web UI to query:\n- `agents` — agent_key, nick, display_name, provider_family_id, persona_id\n- `agent_aliases` — alias_value per agent (for @-mention matching)\n- `rooms` — room_key, display_name, surface_type, surface_address, room_mode, status, project_id\n- `room_roster_memberships` — room_id, agent_id, priority_weight, speak_enabled, removed_at\n- `team_presets` — preset_key, display_name, project_id, default_room_mode\n- `team_preset_agents` — preset_id, agent_id, role_label, default_weight\n- `backend_sessions` — room_id, agent_id, provider_family_id, provider_session_ref, status, scope_type, state_path\n- `agent_invocations` — room_id, agent_id, input_text, output_text, started_at, completed_at, error_class\n- `turn_decisions` — room_id, turn_type, selected_agent_id, rationale, trigger_source, decided_at\n- `canonical_memories` — memory_key, namespace_id, title, summary, status, tags_json\n- `memory_contributions` — canonical_memory_id, agent_id, contribution_type, content, confidence, rationale\n- `memory_namespaces` — namespace_key, namespace_type, owner_agent_id\n\n---\n\n## Runtime Environment Variables (local.env on svc-ai)\n\n```\nPLATFORM_ENV=local\nPLATFORM_LOG_LEVEL=info\nPLATFORM_RUNTIME_STATE_ROOT=/home/svc-admin/ai-platform/runtime\nPLATFORM_DB_HOST=192.168.4.113\nPLATFORM_DB_PORT=5432\nPLATFORM_DB_NAME=homelab_ai_platform\nPLATFORM_DB_USER=platform_runtime\nPLATFORM_DB_PASSWORD_FILE=/home/svc-admin/ai-platform/secrets/platform_db_password\nPLATFORM_BRAIN_API_URL=http://127.0.0.1:5000\nPLATFORM_BRAIN_NAMESPACE_SHARED=shared:platform\nPLATFORM_BRAIN_WRITE_ENABLED=true\nPLATFORM_BRAIN_TOKEN_FILE=/home/svc-admin/ai-platform/secrets/platform_brain_token\nPLATFORM_ORCHESTRATOR_BIND_HOST=127.0.0.1\nPLATFORM_ORCHESTRATOR_BIND_PORT=8080\nPLATFORM_UI_BASE_URL=http://127.0.0.1:3000   # PLACEHOLDER — update when UI is live on svc-apps\nCODEX_CLI_BIN=/usr/local/bin/codex\nCODEX_STATE_ROOT=/home/svc-admin/ai-platform/runtime/providers/codex\nGEMINI_CLI_BIN=/usr/local/bin/gemini\nGEMINI_STATE_ROOT=/home/svc-admin/ai-platform/runtime/providers/gemini\nPLATFORM_ARTIFACT_ROOT=/home/svc-admin/ai-platform/artifacts\nPLATFORM_LOG_ROOT=/home/svc-admin/ai-platform/logs\nPLATFORM_ENABLE_STREAMING=true\nPLATFORM_ENABLE_TTS=false\nPLATFORM_ENABLE_ROOM_SESSION_MEMORY=true\n```\n\nNote: `PLATFORM_UI_BASE_URL` is currently a placeholder pointing at port 3000\non svc-ai. Port 3000 conflicts with linkwarden on svc-apps. Use port **3010**\nfor the Web Control UI on svc-apps (3000–3009 are all occupied).\n\n---\n\n## Key Architectural Decisions Made During Build\n\n1. **Package named `haip`** (not `platform`) — Python stdlib has a `platform`\n   module; naming our package `platform` caused `AttributeError: module\n   'platform' has no attribute 'uname'` inside asyncpg. Package must stay `haip`.\n\n2. **DB password read from file inside Python** — heredoc env expansion does not\n   work with `<<'EOF'` in bash. The pattern used throughout is:\n   ```python\n   DB_PASS = open(\"/home/svc-admin/ai-platform/secrets/platform_db_password\").read().strip()\n   ```\n\n3. **Bare metal systemd user services on svc-ai** — the existing Ergo IRC server\n   and bridge run as bare metal `systemd --user` services. All platform services\n   on svc-ai follow the same pattern. No Docker on svc-ai for platform components.\n\n4. **Docker appstack pattern on svc-apps** — all services on svc-apps use\n   Docker Compose under `/home/svc-admin/appstack/<service>/docker-compose.yml`\n   with a shared external network `appstack_default`. The Web Control UI must\n   follow this exact pattern.\n\n5. **RoomManager roster methods take agent_id (int), not agent_key (str)** —\n   `add_agent()`, `remove_agent()`, `set_speak_enabled()`, `update_weight()` all\n   require numeric agent_id. Resolve agent_key → agent_id by querying\n   `SELECT id FROM agents WHERE agent_key = $1` before calling these.\n\n6. **Room model field is `room_mode`, not `mode`** — the `Room` dataclass uses\n   `room.room_mode`. `RosterMember` uses `priority_weight`, not `base_weight`.\n\n7. **Turn selection is weighted-random with dominance suppression** — not\n   deterministic round-robin. Direct address always wins. Debate/synthesis mode\n   falls back to weighted-random for now (full structured flow is WS8).\n\n8. **Memory tiers and thresholds**:\n   - `core_shared`: confidence >= 0.70\n   - `project_team`, `agent_private`: confidence >= 0.50\n   - `room_session`: confidence >= 0.30 (below → discard, not review)\n   - Higher tiers below threshold → `WriteDecision.REVIEW` (written to PG only,\n     not Brain, until human confirms)\n\n9. **BrainClient uses urllib (no extra deps)** and runs in `ThreadPoolExecutor`\n   to avoid blocking the asyncio event loop. Brain REST at `http://127.0.0.1:5000`.\n\n10. **IRC gateway supports multi-nick and single-nick mode** — normally one\n    IrcClient per agent nick plus opsbot. `IRC_SINGLE_NICK_MODE=1` env var\n    routes all output via opsbot with `[agent_key]` prefix.\n\n11. **Ergo IRC on svc-ai** — listens on `:6667` (plaintext, all interfaces) and\n    `:6697` (TLS). Platform IRC client connects to `127.0.0.1:6667`.\n    Server name: `irc.accursedbinkie.lan`. Network name: `HomelabAI`.\n\n---\n\n## Existing Services and Ports on svc-ai (192.168.4.117)\n\n- ContextKeep REST: `http://127.0.0.1:5000`\n- ContextKeep MCP SSE: `http://127.0.0.1:5100`\n- Ergo IRC: `127.0.0.1:6667` (plain), `*:6697` (TLS)\n- Existing TypeScript bridge health: `http://127.0.0.1:3000/health`\n- Kokoro TTS: port 8765\n- XTTS Docker: port 8020\n- Gramps MCP: port 8000 (genealogy project, unrelated)\n- Planned orchestration API: `127.0.0.1:8080`\n\n---\n\n## Occupied Ports on svc-apps (192.168.4.114)\n\n- 3000: linkwarden\n- 3001: gitea (also 2222 for SSH)\n- 3002: trilium\n- 3003: openproject proxy\n- 3004: mealie\n- 3005: open-webui\n- 3006: nextcloud\n- 3007: vaultwarden\n- 3009: actualbudget\n- 3456: vikunja\n- 8000: paperless-ngx\n- 2283: immich\n\n**Use port 3010 for the Web Control UI.**\n\n---\n\n## WS6 Task: Web Control UI on svc-apps\n\n### Goal\nBuild and deploy a minimal but functional operator control panel as a Docker\nCompose service in the appstack on svc-apps. It must be a real, working UI —\nnot a stub or placeholder.\n\n### Architecture Decision\nThe architecture doc (`SERVICE_LAYOUT.md`) says the Web Control UI backend\ncan live on svc-ai with the frontend on svc-apps. For the first build, the\nsimplest approach is a **single self-contained service** that runs on svc-apps\nand queries PostgreSQL directly (192.168.4.113:5432). No dependency on an\norchestration API that doesn't exist yet.\n\nThe UI can grow into a two-tier (API on svc-ai, frontend on svc-apps)\narchitecture later. For WS6, keep it simple: one container, reads DB, renders\nviews.\n\n### Technology Choice\nUse **FastAPI + Jinja2 templates + plain HTML/CSS** — no JavaScript framework.\nThis matches the \"clarity over visual complexity\" principle from the arch doc\nand keeps the container small.\n\nDependencies: `fastapi`, `uvicorn`, `asyncpg`, `jinja2`, `python-multipart`\n\n### Port\n**3010** on svc-apps (host port 3010 → container port 8000).\n\n### Appstack Location on svc-apps\n`/home/svc-admin/appstack/haip-ui/`\n  - `docker-compose.yml`\n  - `.env` (DB credentials, not committed)\n  - `data/` (any persistent data if needed)\n  - The app code lives in a Docker image or bind-mounted source directory.\n\n### Views to Build (from WEB_CONTROL_UI.md, in priority order)\n\n1. **Room list** (`/rooms`) — all active rooms, mode badge, surface address, roster count\n2. **Room detail** (`/rooms/{room_key}`) — mode, project, roster table with weights/speak status, last 10 turn decisions\n3. **Roster panel** (within room detail, POST actions) — add agent, remove agent, mute/unmute, adjust weight\n4. **Preset browser** (`/presets`) — list presets with project association, default mode, agent list\n5. **Session inspection** (`/sessions`) — sessions by room+agent, provider, status, staleness\n6. **Agent catalog** (`/agents`) — all agents, persona, provider family, private namespace\n7. **Memory inspection** (`/memory`) — canonical memory records by namespace, with contributions\n\nOperational/diagnostic views (lower priority for WS6 but include if time allows):\n- `/health` — provider family health summary (query agent_invocations for recent errors)\n- `/invocations` — recent invocation log with success/error status\n\n### DB Connection in the Container\nRead DB password from an env var `PLATFORM_DB_PASSWORD` injected via `.env`\nfile in the appstack directory. Do NOT use the file-based secret path (that's\nonly accessible on svc-ai).\n\n### appstack_default Network\nThe container must join the `appstack_default` external Docker network (already\nexists on svc-apps). It does NOT need to reach svc-ai — it talks directly to\nsvc-db-01 on 192.168.4.113:5432.\n\n### docker-compose.yml pattern to follow\n```yaml\nservices:\n  haip-ui:\n    build: .         # or image: if pre-built\n    container_name: haip-ui\n    restart: unless-stopped\n    ports:\n      - \"3010:8000\"\n    env_file:\n      - .env\n    volumes:\n      - /home/svc-admin/appstack/haip-ui/data:/app/data\n    networks:\n      - appstack_default\n\nnetworks:\n  appstack_default:\n    external: true\n```\n\n### .env file on svc-apps (create manually, chmod 600)\n```\nPLATFORM_DB_HOST=192.168.4.113\nPLATFORM_DB_PORT=5432\nPLATFORM_DB_NAME=homelab_ai_platform\nPLATFORM_DB_USER=platform_runtime\nPLATFORM_DB_PASSWORD=<contents of /home/svc-admin/ai-platform/secrets/platform_db_password on svc-ai>\nPLATFORM_UI_TITLE=Homelab AI Platform\n```\n\n### Suggested App Structure (in the appstack dir or a bind-mounted src/)\n```\nhaip-ui/\n├── docker-compose.yml\n├── .env\n├── Dockerfile\n├── app/\n│   ├── main.py              # FastAPI app, lifespan for asyncpg pool\n│   ├── db.py                # asyncpg pool helper\n│   ├── routers/\n│   │   ├── rooms.py\n│   │   ├── presets.py\n│   │   ├── sessions.py\n│   │   ├── agents.py\n│   │   └── memory.py\n│   └── templates/\n│       ├── base.html        # nav, layout\n│       ├── rooms/\n│       │   ├── list.html\n│       │   └── detail.html\n│       ├── presets/list.html\n│       ├── sessions/list.html\n│       ├── agents/list.html\n│       └── memory/list.html\n└── requirements.txt\n```\n\n### Dockerfile\n```dockerfile\nFROM python:3.12-slim\nWORKDIR /app\nCOPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\nCOPY app/ ./app/\nCMD [\"uvicorn\", \"app.main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\"]\n```\n\n### requirements.txt\n```\nfastapi>=0.110\nuvicorn[standard]>=0.29\nasyncpg>=0.29\njinja2>=3.1\npython-multipart>=0.0.9\n```\n\n### DB Query Patterns for Each View\n\n**Room list:**\n```sql\nSELECT r.id, r.room_key, r.display_name, r.surface_type, r.surface_address,\n       r.room_mode, r.status, p.project_key,\n       COUNT(m.id) FILTER (WHERE m.removed_at IS NULL) AS roster_count\nFROM rooms r\nLEFT JOIN projects p ON p.id = r.project_id\nLEFT JOIN room_roster_memberships m ON m.room_id = r.id\nWHERE r.status = 'active'\nGROUP BY r.id, p.project_key\nORDER BY r.room_key\n```\n\n**Room detail roster:**\n```sql\nSELECT a.agent_key, a.nick, m.priority_weight, m.speak_enabled, m.joined_at\nFROM room_roster_memberships m\nJOIN agents a ON a.id = m.agent_id\nWHERE m.room_id = $1 AND m.removed_at IS NULL\nORDER BY m.priority_weight DESC\n```\n\n**Recent turn decisions for a room:**\n```sql\nSELECT td.turn_type, a.agent_key, td.rationale, td.trigger_source, td.decided_at\nFROM turn_decisions td\nLEFT JOIN agents a ON a.id = td.selected_agent_id\nWHERE td.room_id = $1\nORDER BY td.decided_at DESC\nLIMIT 10\n```\n\n**Sessions:**\n```sql\nSELECT r.room_key, a.agent_key, pf.family_key, bs.status, bs.scope_type,\n       bs.provider_session_ref, bs.created_at, bs.last_used_at\nFROM backend_sessions bs\nJOIN rooms r ON r.id = bs.room_id\nJOIN agents a ON a.id = bs.agent_id\nJOIN provider_families pf ON pf.id = bs.provider_family_id\nWHERE bs.status NOT IN ('archived')\nORDER BY bs.last_used_at DESC NULLS LAST\n```\n\n**Agent catalog:**\n```sql\nSELECT a.agent_key, a.nick, a.display_name, pf.family_key,\n       pv.version_tag, pv.persona_name\nFROM agents a\nLEFT JOIN provider_families pf ON pf.id = a.provider_family_id\nLEFT JOIN persona_versions pv ON pv.id = a.active_persona_version_id\nORDER BY a.agent_key\n```\n\n**Canonical memories:**\n```sql\nSELECT cm.memory_key, cm.title, cm.summary, cm.status,\n       mn.namespace_key, mn.namespace_type,\n       COUNT(mc.id) AS contribution_count\nFROM canonical_memories cm\nJOIN memory_namespaces mn ON mn.id = cm.namespace_id\nLEFT JOIN memory_contributions mc ON mc.canonical_memory_id = cm.id\nWHERE cm.status = 'active'\nGROUP BY cm.id, mn.id\nORDER BY cm.updated_at DESC\nLIMIT 100\n```\n\n### POST Actions (Roster Management)\nThe room detail page should include HTML forms for:\n- `POST /rooms/{room_key}/roster/add` — body: `agent_key`\n  → lookup agent_id, call INSERT INTO room_roster_memberships\n- `POST /rooms/{room_key}/roster/remove` — body: `agent_key`\n  → UPDATE room_roster_memberships SET removed_at = NOW()\n- `POST /rooms/{room_key}/roster/speak` — body: `agent_key`, `enabled`\n  → UPDATE room_roster_memberships SET speak_enabled = $1\n- `POST /rooms/{room_key}/roster/weight` — body: `agent_key`, `weight`\n  → UPDATE room_roster_memberships SET priority_weight = $1\n- `POST /rooms/{room_key}/mode` — body: `mode`\n  → UPDATE rooms SET room_mode = $1\n\nThese write directly to the DB (no orchestration API layer for WS6 — that\ncomes later in WS7).\n\n### Note on PLATFORM_UI_BASE_URL\nAfter WS6 is deployed, update `local.env` on svc-ai:\n```\nPLATFORM_UI_BASE_URL=http://192.168.4.114:3010\n```\n\n---\n\n## Remaining Workstreams After WS6\n\n### WS7: Operational Foundations\n- Install systemd user units on svc-ai (haip-irc.service, and a future haip-orchestrator.service)\n- Logging config (structured JSON logs to /home/svc-admin/ai-platform/logs/)\n- Health check endpoints\n- Backup/restore runbooks for DB and Brain state\n- Security review (DB user permissions, secret file permissions)\n- Validate all env var paths are correct (CLI bins, state roots)\n- Smoke-test IRC gateway connecting to Ergo\n- Smoke-test memory pipeline writing to Brain (ContextKeep on :5000)\n\n### WS8: Project Profile Realization\n- The Arena: agents, personas, rooms, presets for the debate/performance surface\n- Family Roots/Genealogy: agents with Gramps MCP access, genealogy room config\n- Ohio History Channel: research agent configs, content production presets\n- Stoned.AI: Kokoro TTS integration, persona config for that project's voice\n\n---\n\n## Critical Notes for Any Agent Picking This Up\n\n1. **You are likely running ON svc-ai (192.168.4.117)** — `hostname` confirms.\n   WS6 work happens by SSHing to svc-apps: `ssh -i /home/svc-admin/.ssh/id_ed25519_homelab svc-admin@192.168.4.114`\n\n2. **The haip package is NOT used in the Web UI** — the UI is a separate\n   FastAPI app in its own Docker container on svc-apps. It queries the same\n   PostgreSQL DB directly. Do not try to import haip inside the UI container.\n\n3. **Do not touch the existing bridge** at\n   `/home/svc-admin/ai-projects/projects/irc-openclaw-bridge/`. It is a\n   separate TypeScript/Node.js project running on svc-ai. Platform IRC gateway\n   (haip.irc) will eventually replace or complement it, but for now leave it alone.\n\n4. **appstack_default network already exists** on svc-apps — `docker network ls`\n   confirms. All services use `external: true` for this network.\n\n5. **No reverse proxy yet** — access the UI directly on port 3010. Caddy or\n   nginx reverse proxy is a WS7+ concern.\n\n6. **Brain MCP is on svc-ai only** — accessible from svc-ai at\n   `http://127.0.0.1:5000`. The Web UI does not need to talk to Brain.\n\n7. **DB schema was applied** using the migration script from the architecture\n   docs. All 22 tables and 11 indexes are present in `homelab_ai_platform`.\n   Verify with:\n   ```sql\n   SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename;\n   ```\n","summary":"# HAIP WS6 Handoff — Web Control UI\nDate: 2026-04-04\nAuthor: Claude (Sonnet 4.6) via haip build session\n\n---\n\n## Platform State as of WS5 Complete\n\nThe Homelab AI Platform (package: `haip`) is a shared, self-hosted AI agent\norchestration foundation for four sub-projects: The Arena, Family\nRoots/Genealogy, Ohio History Channel, Stoned.AI.\n\nAll code through WS5 is complete and import-tested. No services are running as\nsystemd units yet — that is WS7. The platform is buildable and the Python\npackage is installed in its venv.\n\n---\n\n## VM Topology\n\n| VM | IP | Role |\n|----|-----|------|\n| svc-ai | 192.168.4.117 | Agent runtime, orchestration, IRC gateway, Brain/MCP host |\n| svc-db-01 | 192.168.4.113 | PostgreSQL 16.13 |\n| svc-apps | 192.168.4.114 | Web Control UI (appstack Docker pattern) |\n| svc-dev | 192.168.4.123 | Dev/test |\n\nSSH key for all VMs: `/home/svc-admin/.ssh/id_ed25519_homelab`\nSSH user: `svc-admin`\n\n---\n\n## Source and Workspace Paths\n\n### On svc-ai (primary build host — this is where you ARE when building):\n- Source: `/home/svc-admin/ai-projects/projects/homelab_ai_platform/`\n- Package root: `.../homelab_ai_platform/haip/`\n- Venv: `.../homelab_ai_platform/.venv/`\n- Runtime workspace: `/home/svc-admin/ai-platform/`\n  - `config/environments/local.env` — runtime env vars\n  - `config/systemd/haip-irc.service` — IRC gateway systemd unit (not installed yet)\n  - `secrets/platform_db_password` — chmod 600, plaintext password\n  - `runtime/` — provider state dirs (codex/, gemini/)\n  - `logs/`, `artifacts/`, `backups/`, `tmp/`\n\n### Architecture reference docs (READ ONLY — do not modify):\n- `/home/svc-admin/ai-projects/projects/homelab_ai_platform_architecture/`\n- Key docs for WS6:\n  - `docs/02-runtime/WEB_CONTROL_UI.md`\n  - `docs/04-infrastructure/SERVICE_LAYOUT.md`\n  - `docs/04-infrastructure/NETWORK_AND_PORTS.md`\n\n---\n\n## haip Package Structure (complete through WS5)\n\n```\nhaip/\n├── __init__.py\n├── db.py                          # asyncpg pool: init_pool(), get_pool(), close_pool()\n├── adapters/\n│   ├── base.py                    # Abstract ProviderAdapter\n│   ├── codex.py                   # CodexAdapter (JSON-lines, thread resumption)\n│   ├── gemini.py                  # GeminiAdapter (JSON output, session resumption)\n│   ├── registry.py                # AdapterRegistry, sync_provider_families(pool)\n│   └── __init__.py\n├── models/\n│   ├── provider.py                # AdapterCapabilities, ProviderFamily (frozen dataclasses)\n│   ├── session.py                 # SessionScope, SessionStatus, Session\n│   ├── invocation.py              # InvocationResult, InvocationError\n│   └── __init__.py\n├── sessions/\n│   ├── manager.py                 # SessionManager: get_or_create(), update_after_invocation(), mark_broken(), archive()\n│   └── __init__.py\n├── rooms/\n│   ├── models.py                  # RoomMode enum, Room dataclass, RosterMember dataclass\n│   ├── manager.py                 # RoomManager: full CRUD for rooms + roster memberships\n│   └── __init__.py\n├── orchestration/\n│   ├── turn_selector.py           # TurnSelector: weighted-random with dominance suppression\n│   ├── orchestrator.py            # Orchestrator.handle_turn() — main runtime entry point\n│   └── __init__.py\n├── memory/\n│   ├── models.py                  # NamespaceTier, ContributionType, WriteDecision, CONFIDENCE_THRESHOLDS, MemoryCandidate, ClassificationResult, PipelineOutcome\n│   ├── classifier.py              # MemoryClassifier: noise detection, tier routing, confidence gating\n│   ├── brain_client.py            # BrainClient: REST client for ContextKeep (store/retrieve/search/delete)\n│   ├── store.py                   # MemoryStore: PG-backed canonical_memories + memory_contributions\n│   ├── pipeline.py                # MemoryPipeline: 6-stage process(), retrieve_for_agent()\n│   └── __init__.py\n└── irc/\n    ├── client.py                  # IrcClient: asyncio raw TCP, NICK/USER/JOIN/PRIVMSG/PING, auto-reconnect\n    ├── commands.py                 # CommandParser: ! prefix commands\n    ├── formatter.py               # IrcFormatter: line splitting, paced output\n    ├── gateway.py                  # IrcGateway: orchestrates clients, routes msgs, handles commands\n    ├── service.py                  # Entry point: python -m haip.irc.service\n    └── __init__.py\n```\n\n### pyproject.toml\n```toml\n[build-system]\nrequires = [\"setuptools>=68\"]\nbuild-backend = \"setuptools.backends.legacy:build\"\n\n[project]\nname = \"homelab-ai-platform\"\nversion = \"0.1.0\"\nrequires-python = \">=3.12\"\ndependencies = [\"asyncpg>=0.29\"]\n\n[tool.setuptools.packages.find]\nwhere = [\".\"]\ninclude = [\"haip*\"]\n```\n\n---\n\n## Database\n\n- Host: 192.168.4.113 (svc-db-01)\n- Port: 5432\n- Database: `homelab_ai_platform`\n- User: `platform_runtime`\n- Password: in `/home/svc-admin/ai-platform/secrets/platform_db_password`\n\n### Key tables for the Web UI to query:\n- `agents` — agent_key, nick, display_name, provider_family_id, persona_id\n- `agent_aliases` — alias_value per agent (for @-mention matching)\n- `rooms` — room_key, display_name, surface_type, surface_address, room_mode, status, project_id\n- `room_roster_memberships` — room_id, agent_id, priority_weight, speak_enabled, removed_at\n- `team_presets` — preset_key, display_name, project_id, default_room_mode\n- `team_preset_agents` — preset_id, agent_id, role_label, default_weight\n- `backend_sessions` — room_id, agent_id, provider_family_id, provider_session_ref, status, scope_type, state_path\n- `agent_invocations` — room_id, agent_id, input_text, output_text, started_at, completed_at, error_class\n- `turn_decisions` — room_id, turn_type, selected_agent_id, rationale, trigger_source, decided_at\n- `canonical_memories` — memory_key, namespace_id, title, summary, status, tags_json\n- `memory_contributions` — canonical_memory_id, agent_id, contribution_type, content, confidence, rationale\n- `memory_namespaces` — namespace_key, namespace_type, owner_agent_id\n\n---\n\n## Runtime Environment Variables (local.env on svc-ai)\n\n```\nPLATFORM_ENV=local\nPLATFORM_LOG_LEVEL=info\nPLATFORM_RUNTIME_STATE_ROOT=/home/svc-admin/ai-platform/runtime\nPLATFORM_DB_HOST=192.168.4.113\nPLATFORM_DB_PORT=5432\nPLATFORM_DB_NAME=homelab_ai_platform\nPLATFORM_DB_USER=platform_runtime\nPLATFORM_DB_PASSWORD_FILE=/home/svc-admin/ai-platform/secrets/platform_db_password\nPLATFORM_BRAIN_API_URL=http://127.0.0.1:5000\nPLATFORM_BRAIN_NAMESPACE_SHARED=shared:platform\nPLATFORM_BRAIN_WRITE_ENABLED=true\nPLATFORM_BRAIN_TOKEN_FILE=/home/svc-admin/ai-platform/secrets/platform_brain_token\nPLATFORM_ORCHESTRATOR_BIND_HOST=127.0.0.1\nPLATFORM_ORCHESTRATOR_BIND_PORT=8080\nPLATFORM_UI_BASE_URL=http://127.0.0.1:3000   # PLACEHOLDER — update when UI is live on svc-apps\nCODEX_CLI_BIN=/usr/local/bin/codex\nCODEX_STATE_ROOT=/home/svc-admin/ai-platform/runtime/providers/codex\nGEMINI_CLI_BIN=/usr/local/bin/gemini\nGEMINI_STATE_ROOT=/home/svc-admin/ai-platform/runtime/providers/gemini\nPLATFORM_ARTIFACT_ROOT=/home/svc-admin/ai-platform/artifacts\nPLATFORM_LOG_ROOT=/home/svc-admin/ai-platform/logs\nPLATFORM_ENABLE_STREAMING=true\nPLATFORM_ENABLE_TTS=false\nPLATFORM_ENABLE_ROOM_SESSION_MEMORY=true\n```\n\nNote: `PLATFORM_UI_BASE_URL` is currently a placeholder pointing at port 3000\non svc-ai. Port 3000 conflicts with linkwarden on svc-apps. Use port **3010**\nfor the Web Control UI on svc-apps (3000–3009 are all occupied).\n\n---\n\n## Key Architectural Decisions Made During Build\n\n1. **Package named `haip`** (not `platform`) — Python stdlib has a `platform`\n   module; naming our package `platform` caused `AttributeError: module\n   'platform' has no attribute 'uname'` inside asyncpg. Package must stay `haip`.\n\n2. **DB password read from file inside Python** — heredoc env expansion does not\n   work with `<<'EOF'` in bash. The pattern used throughout is:\n   ```python\n   DB_PASS = open(\"/home/svc-admin/ai-platform/secrets/platform_db_password\").read().strip()\n   ```\n\n3. **Bare metal systemd user services on svc-ai** — the existing Ergo IRC server\n   and bridge run as bare metal `systemd --user` services. All platform services\n   on svc-ai follow the same pattern. No Docker on svc-ai for platform components.\n\n4. **Docker appstack pattern on svc-apps** — all services on svc-apps use\n   Docker Compose under `/home/svc-admin/appstack/<service>/docker-compose.yml`\n   with a shared external network `appstack_default`. The Web Control UI must\n   follow this exact pattern.\n\n5. **RoomManager roster methods take agent_id (int), not agent_key (str)** —\n   `add_agent()`, `remove_agent()`, `set_speak_enabled()`, `update_weight()` all\n   require numeric agent_id. Resolve agent_key → agent_id by querying\n   `SELECT id FROM agents WHERE agent_key = $1` before calling these.\n\n6. **Room model field is `room_mode`, not `mode`** — the `Room` dataclass uses\n   `room.room_mode`. `RosterMember` uses `priority_weight`, not `base_weight`.\n\n7. **Turn selection is weighted-random with dominance suppression** — not\n   deterministic round-robin. Direct address always wins. Debate/synthesis mode\n   falls back to weighted-random for now (full structured flow is WS8).\n\n8. **Memory tiers and thresholds**:\n   - `core_shared`: confidence >= 0.70\n   - `project_team`, `agent_private`: confidence >= 0.50\n   - `room_session`: confidence >= 0.30 (below → discard, not review)\n   - Higher tiers below threshold → `WriteDecision.REVIEW` (written to PG only,\n     not Brain, until human confirms)\n\n9. **BrainClient uses urllib (no extra deps)** and runs in `ThreadPoolExecutor`\n   to avoid blocking the asyncio event loop. Brain REST at `http://127.0.0.1:5000`.\n\n10. **IRC gateway supports multi-nick and single-nick mode** — normally one\n    IrcClient per agent nick plus opsbot. `IRC_SINGLE_NICK_MODE=1` env var\n    routes all output via opsbot with `[agent_key]` prefix.\n\n11. **Ergo IRC on svc-ai** — listens on `:6667` (plaintext, all interfaces) and\n    `:6697` (TLS). Platform IRC client connects to `127.0.0.1:6667`.\n    Server name: `irc.accursedbinkie.lan`. Network name: `HomelabAI`.\n\n---\n\n## Existing Services and Ports on svc-ai (192.168.4.117)\n\n- ContextKeep REST: `http://127.0.0.1:5000`\n- ContextKeep MCP SSE: `http://127.0.0.1:5100`\n- Ergo IRC: `127.0.0.1:6667` (plain), `*:6697` (TLS)\n- Existing TypeScript bridge health: `http://127.0.0.1:3000/health`\n- Kokoro TTS: port 8765\n- XTTS Docker: port 8020\n- Gramps MCP: port 8000 (genealogy project, unrelated)\n- Planned orchestration API: `127.0.0.1:8080`\n\n---\n\n## Occupied Ports on svc-apps (192.168.4.114)\n\n- 3000: linkwarden\n- 3001: gitea (also 2222 for SSH)\n- 3002: trilium\n- 3003: openproject proxy\n- 3004: mealie\n- 3005: open-webui\n- 3006: nextcloud\n- 3007: vaultwarden\n- 3009: actualbudget\n- 3456: vikunja\n- 8000: paperless-ngx\n- 2283: immich\n\n**Use port 3010 for the Web Control UI.**\n\n---\n\n## WS6 Task: Web Control UI on svc-apps\n\n### Goal\nBuild and deploy a minimal but functional operator control panel as a Docker\nCompose service in the appstack on svc-apps. It must be a real, working UI —\nnot a stub or placeholder.\n\n### Architecture Decision\nThe architecture doc (`SERVICE_LAYOUT.md`) says the Web Control UI backend\ncan live on svc-ai with the frontend on svc-apps. For the first build, the\nsimplest approach is a **single self-contained service** that runs on svc-apps\nand queries PostgreSQL directly (192.168.4.113:5432). No dependency on an\norchestration API that doesn't exist yet.\n\nThe UI can grow into a two-tier (API on svc-ai, frontend on svc-apps)\narchitecture later. For WS6, keep it simple: one container, reads DB, renders\nviews.\n\n### Technology Choice\nUse **FastAPI + Jinja2 templates + plain HTML/CSS** — no JavaScript framework.\nThis matches the \"clarity over visual complexity\" principle from the arch doc\nand keeps the container small.\n\nDependencies: `fastapi`, `uvicorn`, `asyncpg`, `jinja2`, `python-multipart`\n\n### Port\n**3010** on svc-apps (host port 3010 → container port 8000).\n\n### Appstack Location on svc-apps\n`/home/svc-admin/appstack/haip-ui/`\n  - `docker-compose.yml`\n  - `.env` (DB credentials, not committed)\n  - `data/` (any persistent data if needed)\n  - The app code lives in a Docker image or bind-mounted source directory.\n\n### Views to Build (from WEB_CONTROL_UI.md, in priority order)\n\n1. **Room list** (`/rooms`) — all active rooms, mode badge, surface address, roster count\n2. **Room detail** (`/rooms/{room_key}`) — mode, project, roster table with weights/speak status, last 10 turn decisions\n3. **Roster panel** (within room detail, POST actions) — add agent, remove agent, mute/unmute, adjust weight\n4. **Preset browser** (`/presets`) — list presets with project association, default mode, agent list\n5. **Session inspection** (`/sessions`) — sessions by room+agent, provider, status, staleness\n6. **Agent catalog** (`/agents`) — all agents, persona, provider family, private namespace\n7. **Memory inspection** (`/memory`) — canonical memory records by namespace, with contributions\n\nOperational/diagnostic views (lower priority for WS6 but include if time allows):\n- `/health` — provider family health summary (query agent_invocations for recent errors)\n- `/invocations` — recent invocation log with success/error status\n\n### DB Connection in the Container\nRead DB password from an env var `PLATFORM_DB_PASSWORD` injected via `.env`\nfile in the appstack directory. Do NOT use the file-based secret path (that's\nonly accessible on svc-ai).\n\n### appstack_default Network\nThe container must join the `appstack_default` external Docker network (already\nexists on svc-apps). It does NOT need to reach svc-ai — it talks directly to\nsvc-db-01 on 192.168.4.113:5432.\n\n### docker-compose.yml pattern to follow\n```yaml\nservices:\n  haip-ui:\n    build: .         # or image: if pre-built\n    container_name: haip-ui\n    restart: unless-stopped\n    ports:\n      - \"3010:8000\"\n    env_file:\n      - .env\n    volumes:\n      - /home/svc-admin/appstack/haip-ui/data:/app/data\n    networks:\n      - appstack_default\n\nnetworks:\n  appstack_default:\n    external: true\n```\n\n### .env file on svc-apps (create manually, chmod 600)\n```\nPLATFORM_DB_HOST=192.168.4.113\nPLATFORM_DB_PORT=5432\nPLATFORM_DB_NAME=homelab_ai_platform\nPLATFORM_DB_USER=platform_runtime\nPLATFORM_DB_PASSWORD=<contents of /home/svc-admin/ai-platform/secrets/platform_db_password on svc-ai>\nPLATFORM_UI_TITLE=Homelab AI Platform\n```\n\n### Suggested App Structure (in the appstack dir or a bind-mounted src/)\n```\nhaip-ui/\n├── docker-compose.yml\n├── .env\n├── Dockerfile\n├── app/\n│   ├── main.py              # FastAPI app, lifespan for asyncpg pool\n│   ├── db.py                # asyncpg pool helper\n│   ├── routers/\n│   │   ├── rooms.py\n│   │   ├── presets.py\n│   │   ├── sessions.py\n│   │   ├── agents.py\n│   │   └── memory.py\n│   └── templates/\n│       ├── base.html        # nav, layout\n│       ├── rooms/\n│       │   ├── list.html\n│       │   └── detail.html\n│       ├── presets/list.html\n│       ├── sessions/list.html\n│       ├── agents/list.html\n│       └── memory/list.html\n└── requirements.txt\n```\n\n### Dockerfile\n```dockerfile\nFROM python:3.12-slim\nWORKDIR /app\nCOPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\nCOPY app/ ./app/\nCMD [\"uvicorn\", \"app.main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\"]\n```\n\n### requirements.txt\n```\nfastapi>=0.110\nuvicorn[standard]>=0.29\nasyncpg>=0.29\njinja2>=3.1\npython-multipart>=0.0.9\n```\n\n### DB Query Patterns for Each View\n\n**Room list:**\n```sql\nSELECT r.id, r.room_key, r.display_name, r.surface_type, r.surface_address,\n       r.room_mode, r.status, p.project_key,\n       COUNT(m.id) FILTER (WHERE m.removed_at IS NULL) AS roster_count\nFROM rooms r\nLEFT JOIN projects p ON p.id = r.project_id\nLEFT JOIN room_roster_memberships m ON m.room_id = r.id\nWHERE r.status = 'active'\nGROUP BY r.id, p.project_key\nORDER BY r.room_key\n```\n\n**Room detail roster:**\n```sql\nSELECT a.agent_key, a.nick, m.priority_weight, m.speak_enabled, m.joined_at\nFROM room_roster_memberships m\nJOIN agents a ON a.id = m.agent_id\nWHERE m.room_id = $1 AND m.removed_at IS NULL\nORDER BY m.priority_weight DESC\n```\n\n**Recent turn decisions for a room:**\n```sql\nSELECT td.turn_type, a.agent_key, td.rationale, td.trigger_source, td.decided_at\nFROM turn_decisions td\nLEFT JOIN agents a ON a.id = td.selected_agent_id\nWHERE td.room_id = $1\nORDER BY td.decided_at DESC\nLIMIT 10\n```\n\n**Sessions:**\n```sql\nSELECT r.room_key, a.agent_key, pf.family_key, bs.status, bs.scope_type,\n       bs.provider_session_ref, bs.created_at, bs.last_used_at\nFROM backend_sessions bs\nJOIN rooms r ON r.id = bs.room_id\nJOIN agents a ON a.id = bs.agent_id\nJOIN provider_families pf ON pf.id = bs.provider_family_id\nWHERE bs.status NOT IN ('archived')\nORDER BY bs.last_used_at DESC NULLS LAST\n```\n\n**Agent catalog:**\n```sql\nSELECT a.agent_key, a.nick, a.display_name, pf.family_key,\n       pv.version_tag, pv.persona_name\nFROM agents a\nLEFT JOIN provider_families pf ON pf.id = a.provider_family_id\nLEFT JOIN persona_versions pv ON pv.id = a.active_persona_version_id\nORDER BY a.agent_key\n```\n\n**Canonical memories:**\n```sql\nSELECT cm.memory_key, cm.title, cm.summary, cm.status,\n       mn.namespace_key, mn.namespace_type,\n       COUNT(mc.id) AS contribution_count\nFROM canonical_memories cm\nJOIN memory_namespaces mn ON mn.id = cm.namespace_id\nLEFT JOIN memory_contributions mc ON mc.canonical_memory_id = cm.id\nWHERE cm.status = 'active'\nGROUP BY cm.id, mn.id\nORDER BY cm.updated_at DESC\nLIMIT 100\n```\n\n### POST Actions (Roster Management)\nThe room detail page should include HTML forms for:\n- `POST /rooms/{room_key}/roster/add` — body: `agent_key`\n  → lookup agent_id, call INSERT INTO room_roster_memberships\n- `POST /rooms/{room_key}/roster/remove` — body: `agent_key`\n  → UPDATE room_roster_memberships SET removed_at = NOW()\n- `POST /rooms/{room_key}/roster/speak` — body: `agent_key`, `enabled`\n  → UPDATE room_roster_memberships SET speak_enabled = $1\n- `POST /rooms/{room_key}/roster/weight` — body: `agent_key`, `weight`\n  → UPDATE room_roster_memberships SET priority_weight = $1\n- `POST /rooms/{room_key}/mode` — body: `mode`\n  → UPDATE rooms SET room_mode = $1\n\nThese write directly to the DB (no orchestration API layer for WS6 — that\ncomes later in WS7).\n\n### Note on PLATFORM_UI_BASE_URL\nAfter WS6 is deployed, update `local.env` on svc-ai:\n```\nPLATFORM_UI_BASE_URL=http://192.168.4.114:3010\n```\n\n---\n\n## Remaining Workstreams After WS6\n\n### WS7: Operational Foundations\n- Install systemd user units on svc-ai (haip-irc.service, and a future haip-orchestrator.service)\n- Logging config (structured JSON logs to /home/svc-admin/ai-platform/logs/)\n- Health check endpoints\n- Backup/restore runbooks for DB and Brain state\n- Security review (DB user permissions, secret file permissions)\n- Validate all env var paths are correct (CLI bins, state roots)\n- Smoke-test IRC gateway connecting to Ergo\n- Smoke-test memory pipeline writing to Brain (ContextKeep on :5000)\n\n### WS8: Project Profile Realization\n- The Arena: agents, personas, rooms, presets for the debate/performance surface\n- Family Roots/Genealogy: agents with Gramps MCP access, genealogy room config\n- Ohio History Channel: research agent configs, content production presets\n- Stoned.AI: Kokoro TTS integration, persona config for that project's voice\n\n---\n\n## Critical Notes for Any Agent Picking This Up\n\n1. **You are likely running ON svc-ai (192.168.4.117)** — `hostname` confirms.\n   WS6 work happens by SSHing to svc-apps: `ssh -i /home/svc-admin/.ssh/id_ed25519_homelab svc-admin@192.168.4.114`\n\n2. **The haip package is NOT used in the Web UI** — the UI is a separate\n   FastAPI app in its own Docker container on svc-apps. It queries the same\n   PostgreSQL DB directly. Do not try to import haip inside the UI container.\n\n3. **Do not touch the existing bridge** at\n   `/home/svc-admin/ai-projects/projects/irc-openclaw-bridge/`. It is a\n   separate TypeScript/Node.js project running on svc-ai. Platform IRC gateway\n   (haip.irc) will eventually replace or complement it, but for now leave it alone.\n\n4. **appstack_default network already exists** on svc-apps — `docker network ls`\n   confirms. All services use `external: true` for this network.\n\n5. **No reverse proxy yet** — access the UI directly on port 3010. Caddy or\n   nginx reverse proxy is a WS7+ concern.\n\n6. **Brain MCP is on svc-ai only** — accessible from svc-ai at\n   `http://127.0.0.1:5000`. The Web UI does not need to talk to Brain.\n\n7. **DB schema was applied** using the migration script from the architecture\n   docs. All 22 tables and 11 indexes are present in `homelab_ai_platform`.\n   Verify with:\n   ```sql\n   SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename;\n   ```\n","status":"active","namespace":"shared:platform","namespace_name":"shared","namespace_tier":"platform","tags":[]}