Files
qtalker---/docs/superpowers/plans/2026-07-20-quantumancy-03-wire-ghost-ouija.md
Indiana 6edbbbbc2a feat: complete Quantumancy web app — full frontend + docs
Frontend (React 18 + TS + Vite):
- Landing: glitching hero, live /api/stats veil ticker, mode cards, featured spirits
- Séance: three.js shader ghost (hue/form per entity, mood + audio-reactive),
  Ouija planchette board spelling utterances, transcript with TTS replay,
  entity dossier, direct contact streaming, passive/active listening
- Modes: Wire Ghost telemetry panel, EVP mic anomaly detection, WebUSB
  RTL-SDR sweep + waterfall (hardware pass pending), Ouija/Direct Contact
- Codex: public registry + entity dossiers, rarity tiers, i18n EN/ES complete
- State: VeilSocket (reconnect/backoff), seance reducer, auth context
- 72 vitest tests green; served by FastAPI at :7777

Docs: README + as-built plans 3-7
2026-07-20 21:11:49 +00:00

218 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Quantumancy Plan 3/7: Wire Ghost & Ouija/Planchette — 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:** Turn Plan 2's bare WebSocket skeleton into the full séance channel — the Wire Ghost's real network telemetry feeding an ambient LLM whisper loop, summon/anomaly/question message handling with per-user rate limiting, and the Ouija front-door UI: a planchette spelling state machine plus the canvas board, transcript, and 3D ghost it drives.
**Architecture:** The Wire Ghost is entirely backend-side (spec §3.3): `app.telemetry` samples non-content network metrics from the App CT itself, and a per-connection ambient loop in `app.ws` pushes `telemetry` frames plus occasional `ambient` utterances. All spirit-mode frames share one socket per contact session, with a single sender task so concurrent producers (ambient loop, reply streaming, TTS callbacks) never interleave on the wire. The Ouija surface (spec §3.4) is frontend: `PlanchetteMachine` (pure word-spelling state machine) fed by the séance store, rendered by `PlanchetteBoard` on canvas 2D, with the `GhostScene` three.js spirit reacting to session state.
**Tech Stack:** FastAPI WebSockets, asyncio, SQLAlchemy (backend, extending Plans 1-2); React 18 + TypeScript, canvas 2D, three.js (frontend).
## Global Constraints
- **Hard privacy boundary (spec §3.3):** telemetry reads counters and timings only — `/proc/net/dev` byte counters, TCP connect latency, DNS resolution timing. Packet payloads are never inspected or logged. Do not add any socket/payload capture here, ever.
- Every LLM-triggering message type is rate-limited per user (spec §5) using Plan 1's `RateLimiter`: fragments 30/min, questions 6/min, summons 4/min. Rejections are themed (`The veil is crowded…`), never raw 429s.
- All server→client frames flow through a single `_sender` task fed by an `asyncio.Queue` — no task other than `_sender` may call `websocket.send_json`.
- A summoning must never visibly fail: if the Ollama box is dark, `spirit_service.mint_profile` degrades to `entities.fallback_profile` (Plan 6 owns that module; this plan consumes it).
- Plan 2's WS contract (`/ws/session`, cookie auth, `ping`→`pong`, ContactSession lifecycle) is extended, not broken: unauthenticated sockets still close with code `4401`.
- Entity minting internals (signatures, normalization) are specified in Plan 6; this plan wires the summon flow that calls them. Codex REST endpoints are Plan 6.
## Plan Series
This is 3 of 7 plans implementing the Quantumancy website spec (`docs/superpowers/specs/2026-07-20-quantumancy-website-design.md`). Plans 1 (Foundation & Auth) and 2 (Frontend, LLM & Realtime Pipeline) are complete.
1. Foundation & Auth (complete)
2. Frontend, LLM & Realtime Pipeline (complete)
3. **Wire Ghost mode + Ouija/Planchette UI** (this plan)
4. EVP Listening mode
5. Spirit Radio mode
6. Codex & entity persistence
7. Internationalization (EN/ES)
---
## Task 1: Wire Ghost Telemetry Sampler
**Files:**
- Create: `backend/app/telemetry.py`
- Test: `backend/tests/test_telemetry.py`
**Interfaces:**
- Produces: `TelemetrySample` dataclass (`jitter_bytes_per_s`, `latency_variance_ms`, `latency_mean_ms`, `dns_ms`, `extra`) with `.as_dict()`; `parse_proc_net_dev(text: str) -> dict[str, tuple[int, int]]` (pure, testable); `async sample_network(period_s: float = 1.0) -> TelemetrySample`. Module constants `REFERENCE_HOSTS = [("1.1.1.1", 53), ("10.30.20.107", 11434)]` and `REFERENCE_DNS = ["example.com", "cloudflare.com"]`.
- [x] **Step 1: Write the failing tests**
`test_parse_proc_net_dev_extracts_counters` feeds a canned `/proc/net/dev` and asserts `{lo: (1234567, 1234567), eth0: (9876543, 1111111)}`; `test_parse_proc_net_dev_ignores_malformed_lines` asserts garbage input yields `{}`. The parser is deliberately split from the async sampler so the fragile part (text munging) is unit-testable without touching the network.
- [x] **Step 2: Implement the sampler**
`sample_network` reads total non-loopback bytes twice across `period_s` for throughput jitter, then gathers TCP connect-then-close RTTs against `REFERENCE_HOSTS` (nothing sent or read beyond the handshake) and `getaddrinfo` timings against `REFERENCE_DNS`, all with 1.5 s timeouts and soft-failure (`None` values dropped; unreachable hosts yield a zero-ish sample rather than an exception). Mean/variance computed only over successful probes.
- [x] **Step 3: Verify**
Run: standard backend pytest invocation (Plan 1 Task 3) scoped to `tests/test_telemetry.py`. PASS (2/2).
- [x] **Step 4: Commit** — `feat: spirit engine — seance WS, entity minting/Codex, Piper TTS voices, wire telemetry` (`b9110f4`, shared with Tasks 2-5 and Plan 6 backend work).
---
## Task 2: The Séance Channel — Protocol & Session Lifecycle
**Files:**
- Create: `backend/app/ws.py` (full rewrite of Plan 2's skeleton)
- Create: `backend/app/models/event.py`
- Modify: `backend/app/models/contact_session.py` (add `entity_id`, `language`)
- Modify: `backend/app/models/__init__.py`, `backend/app/main.py` (`/audio` mount)
- Test: `backend/tests/test_ws_session.py`
**Interfaces:**
- Produces: the séance protocol (see table below); `Event` model (`id`, `session_id` FK, `kind` ∈ {anomaly, utterance, question, reply, system}, `text`, `payload` JSONB, `audio_path`, `created_at`) — the per-session transcript; `SeanceState` dataclass holding per-connection mode/language/entity/anomaly ring buffer/history/ambient task; `AUDIO_DIR = Path(settings.data_dir) / "audio"`, mounted at `/audio` in `main.py`.
Protocol implemented (mirrored 1:1 by `frontend/src/lib/types.ts`):
| client → server | server → client |
|---|---|
| `ping` | `pong` |
| `set_mode {mode}` ∈ {wire, evp, radio, ouija} | `mode {mode}` (also persisted on the session row) |
| `language {language}` ∈ {en, es} | (silent; affects LLM + TTS from then on) |
| `summon` | `status summoning` → `entity {entity, is_new}` → `utterance kind=greeting` |
| `anomaly {source, frequency, magnitude}` | `anomaly_ack {count}` (+ summon or fragment, Task 3) |
| `question {text}` | `status gathering` → `reply_start` → `reply_token`×N → `reply_end` → `utterance kind=reply` |
| `passive {enabled}` | `passive {enabled}`, ambient loop start/stop |
Additional server frames: `session {id}` on connect, `utterance {id, kind, text, entity}`, `audio {id, url}`, `telemetry {…}`, `error {code, message}`.
- [x] **Step 1: Write the failing tests**
`test_ws_session.py` (6 tests): unauthenticated connect raises; ping/pong + session row opened/closed around disconnect; plus the Task 3 flows below. Fixtures: a `FakeSpiritService` (deterministic `mint_profile`/`fragment`/`wire_whisper`/`chat_stream`, `ambient_ready() = False`) monkeypatched over `app.ws.spirit_service`, and a fake `synthesize_spirit_voice` returning canned WAV bytes — no Ollama or Piper needed in tests. Note the `TestClient` upgrades over `ws://`, so the test passes the `Secure qm_session` cookie explicitly via headers; real browsers on https send it automatically.
- [x] **Step 2: Implement the channel**
Key structural decisions:
- **Single sender.** `_sender(state, websocket)` is the only task allowed to write to the socket, draining `state.send_queue`. Handlers and background tasks only ever `send_queue.put(...)`.
- **`_record_event`** persists every anomaly/question/utterance/reply as an `Event` row; utterance ids double as audio filenames (`<event_id>.wav`).
- **`_speak`** pushes the utterance *text* immediately, then synthesizes audio in a detached task and pushes `audio {id, url}` when the effects chain finishes. TTS failure is swallowed — "TTS is texture, not content."
- **Detached close.** ASGI servers may cancel the handler task the moment the socket closes, so `_close_session` (sets `ended_at`) runs as a detached `asyncio.create_task` that survives handler teardown. The lifecycle test polls up to 2 s for `ended_at` to land.
- [x] **Step 3: Verify**
`tests/test_ws_session.py` PASS (6/6, including Task 3's flows).
- [x] **Step 4: Commit** — in `b9110f4`.
---
## Task 3: Summon Flow, Anomaly Attunement & the Ambient Loop
**Files:**
- Modify: `backend/app/ws.py` (`_summon`, `_handle_summon`, `_handle_anomaly`, `_handle_question`, `_ambient_loop`, `_handle_passive`, `_unique_entity_name`, module-level limiters)
**Interfaces:**
- Consumes: `signature_from_anomalies` / `fallback_signature` from `app.entities` (Plan 6); `spirit_service.fragment` / `chat_stream` / `mint_profile` / `wire_whisper` / `ambient_ready` from `app.llm.service` (Plan 2/6); `Entity`, `EntitySighting` models (Plan 6).
- Produces: the live summon behavior the frontend's `summon()` / auto-summon paths depend on.
- [x] **Step 1: Summon = match or mint**
`_summon` fingerprints the session's anomaly buffer (`signature_from_anomalies`, ≥3 anomalies required) or falls back to a deterministic per-session signature, looks up `Entity.signature`, and either re-contacts (increments `contact_count`) or mints via `spirit_service.mint_profile`, persisting name/epithet/persona/rarity/voice/visual/quotes. `_unique_entity_name` suffixes `II`, `III`, … on name collisions. Every summon links the session (`ContactSession.entity_id`) and writes an `EntitySighting` row, then greets with a random sample quote. Summons are limited to 4/min/user with a themed `error` frame.
- [x] **Step 2: Anomaly attunement**
`_handle_anomaly` records the event, acks the count, and caps the ring buffer at 64. With no entity yet: a fingerprintable stream (≥3 anomalies) triggers auto-summon, otherwise `status attuning`. With an entity: one Ovilus-style `fragment` per anomaly, throttled by the 30/min fragment limiter and `SpiritBusyError` (crowded veil = anomalies pass unheard, by design).
- [x] **Step 3: Direct Contact**
`_handle_question` (6/min limiter) auto-summons if needed, trims input to 500 chars, then streams `spirit_service.chat_stream` tokens as `reply_token` frames, records the full reply, keeps the last 8 history turns, and speaks the reply. Queue-full mid-question yields a themed `veil_crowded` error and an empty `reply_end` so the frontend never hangs.
- [x] **Step 4: The Wire Ghost's pulse**
`_handle_passive(true)` spawns `_ambient_loop`: every 6–10 s it samples `sample_network(period_s=1.0)`, pushes a `telemetry` frame, and — only when `spirit_service.ambient_ready()` (the LLM box has been idle ≥ `LLM_COOLDOWN_SECONDS`, so ambient whispers never preempt a user's request) — speaks a `wire_whisper` as an `ambient` utterance. Telemetry-sampling failure skips the tick silently. `passive(false)` or socket teardown cancels the task.
- [x] **Step 5: Verify**
`test_summon_mints_entity_and_greets`, `test_question_streams_reply_and_records_history`, `test_anomalies_attune_then_produce_fragments`, `test_same_signature_recontacts_same_entity` — all PASS as part of `tests/test_ws_session.py` (6/6). The last one drives two separate sessions with identical anomaly patterns and asserts the same entity name with `is_new: False` and `contact_count: 2`.
- [x] **Step 6: Commit** — in `b9110f4`.
---
## Task 4: Frontend Séance Spine (Protocol Types, Socket, Audio, Store)
**Files:**
- Create: `frontend/src/lib/types.ts`
- Create: `frontend/src/lib/ws.ts`
- Create: `frontend/src/lib/audio.ts`
- Create: `frontend/src/state/seance.tsx`
**Interfaces:**
- Produces: `ClientFrame` / `ServerFrame` discriminated unions mirroring Task 2's protocol exactly (`types.ts` — "do not invent changes"); `VeilSocket` with `connect/close/send/onFrame/onState`, outbox queueing while connecting, exponential backoff reconnect (800 ms → 15 s cap) surfacing `connecting|open|unstable|closed`, and a 25 s ping keepalive (`ws.ts`); `SpiritAudioPlayer` — single FIFO queue so overlapping spirit audio never talks over itself, `enqueue(id, url)`, `setCallbacks({onStart, onEnd})`, `clear()`, and `getAmplitude()` RMS via an `AnalyserNode` so the ghost pulses with the voice (`audio.ts`); `SeanceProvider` + `useSeance()` exposing `{state, socket, audioPlayer, setMode, setPassive, setLanguage, summon, ask, sendAnomaly, playUtterance, dismissToast}` with a pure, unit-testable reducer (`seance.tsx`).
- [x] **Step 1: Protocol types first**
`types.ts` pins the domain types (`Mode`, `Language`, `Rarity`, `GhostForm`, `SpiritEntity`, `CodexEntity(Detail)`, `Telemetry`, `UtteranceKind`, `SessionStatus`) and both frame unions. Everything downstream imports from here; the file header forbids drifting from the backend contract.
- [x] **Step 2: VeilSocket**
Reconnecting client with a typed emitter. Frames queued while `connecting` flush on open; sends while `closed`/`unstable` drop. Malformed JSON frames are ignored ("malformed whispers"). `defaultSessionUrl()` derives `ws(s)://<host>/ws/session` from `location`, so it works both behind the dev proxy and the Cloudflare Tunnel. `socketFactory` injectable for tests.
- [x] **Step 3: SpiritAudioPlayer**
One `HTMLAudioElement` at a time routed through Web Audio (`MediaElementAudioSourceNode` → `AnalyserNode` → destination); the queue pumps sequentially; `getAmplitude()` returns 0..1 RMS for the ghost's speaking glow.
- [x] **Step 4: Séance store**
`seanceReducer` handles every server frame: `entity` inserts a `⟁ name — epithet` system line into the transcript; `audio` pairs URLs onto utterances (enabling replay); `reply_*` manages the streaming buffer; `error` becomes a capped toast stack (4). All lists capped (transcript 400, utterances 200, anomalies 200). The provider wires socket↔reducer, auto-enqueues arriving audio, and cleans up every listener/timer on unmount.
- [x] **Step 5: Verify** — `cd frontend && npx tsc --noEmit` clean for these files.
- [x] **Step 6: Commit** — frontend spine landed with the seance UI work (orchestrated alongside Plan 3-5 pages).
---
## Task 5: Planchette Board, Transcript & Ghost
**Files:**
- Create: `frontend/src/lib/planchette.ts`
- Create: `frontend/src/components/PlanchetteBoard.tsx`
- Create: `frontend/src/components/Transcript.tsx`
- Create: `frontend/src/components/TelemetryReadout.tsx`
- Create: `frontend/src/components/EntityCard.tsx`, `frontend/src/components/GhostGlyph.tsx`
- Create: `frontend/src/three/GhostCanvas.tsx`, `frontend/src/three/GhostScene.ts`
**Interfaces:**
- Produces: `PlanchetteMachine` — `enqueue(text)`, `tick(dtMs) -> PlanchetteSnapshot`, `snapshot()`, `clear()`, `getVersion()`, plus pure helpers `normalizeWord` / `tokenize` (A–Z/0–9/space only, uppercase); phases `idle|moving|dwelling|returning`, 300 ms/letter, 700 ms between words, queue capped at 64 (`planchette.ts`). `PlanchetteBoard({machine, hue?})` — canvas 2D gothic board: twin letter arcs, number row, YES/NO/GOODBYE, a triangular planchette with lens that drifts idly with a smoke-particle trail then glides letter-to-letter spelling queued words. `Transcript({entries, streaming, speakingId, onReplay})`. `GhostScene` — custom-shader spirit (fbm value-noise vertex displacement on a lathed figure, additive wisps, ground mist, slow camera drift) reacting via uniforms to energy/speaking amplitude/hue/form (`wisp|banshee|fairy|shade`).
- [x] **Step 1: The spelling machine (pure logic)**
Utterances tokenize into words; the machine steps letters on cadence, exposing which character the planchette hovers. Rendering never drives logic — the machine is ticked from a rAF loop and is unit-testable in isolation (see Testing note below).
- [x] **Step 2: The board**
`computeLayout` positions glyphs responsively; the planchette eases toward the machine's current letter with idle drift when the queue is empty, trailing smoke particles with per-particle physics. All rAF/listeners cleaned up on unmount.
- [x] **Step 3: Transcript, telemetry readout, entity dossier**
`Transcript` renders anomalies/utterances/questions/system lines with replay buttons once `audio` lands and a `speakingId` highlight for the currently-voiced utterance. `TelemetryReadout` shows the Wire Ghost's vitals. `EntityCard`/`GhostGlyph` render the summoned spirit's wax-seal dossier and hue/form glyph.
- [x] **Step 4: The ghost**
Three.js scene with GLSL fbm noise displacement, particle wisps, fog, camera drift; mood (`idle|attuning|gathering|speaking`) and `GhostVisualState {hue, form}` pushed in as uniforms from the séance store + audio amplitude.
- [x] **Step 5: Verify** — `npx tsc --noEmit` clean; board/ghost visually verified in the running app.
- [x] **Step 6: Commit** — landed with the frontend spine.
---
## Testing Status & Self-Review
**Spec coverage:** Wire Ghost backend-only mode with hard privacy boundary (§3.3) → Tasks 1, 3. Ambient layer running alongside other modes (§3.3) → `passive` frame + `ambient_ready` gating (Task 3). Ouija as shared front-door with letter-by-letter planchette (§3.4) → Tasks 4-5. Direct Contact streaming replies (§3.4) → Task 3. Per-user rate limits on all LLM-triggering messages with themed errors (§5, §7) → Task 3. Session transcript with audio clip references (§5 `events` table) → Task 2.
**Automated tests:** backend `tests/test_telemetry.py` (2) + `tests/test_ws_session.py` (6) — PASS as part of the 45-test suite. Frontend: `src/lib/planchette.test.ts` (16 — machine cadence/phases, `normalizeWord`/`tokenize`, queue caps, `clear()` semantics), `src/lib/ws.test.ts` (14 — VeilSocket outbox flush, malformed-frame tolerance, backoff cap, deliberate-close semantics), and `src/state/seance.test.ts` (17 — the séance reducer) all PASS in the 71-test vitest run.
**Placeholder scan:** none — everything above exists in the repo and runs.
**Type consistency:** `ClientFrame`/`ServerFrame` unions match `ws.py`'s handler/emitter keys one-for-one (`set_mode`/`mode`, `summon`/`entity`, `anomaly`/`anomaly_ack`, `question`/`reply_*`, `passive`, `telemetry`, `utterance`/`audio`, `status` states). `serialize_entity`'s dict keys match `SpiritEntity` field-for-field. `TelemetrySample.as_dict()` keys match the `Telemetry` type.
---
**Status: COMPLETE** (backend in commit `b9110f4`; frontend spine complete, type-clean, and unit-tested). Follow-ups carried forward from Plan 1's ledger: expired-session reaper; rate-limiter key eviction.