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

18 KiB
Raw Blame History

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"].

  • 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.

  • 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.

  • Step 3: Verify

Run: standard backend pytest invocation (Plan 1 Task 3) scoped to tests/test_telemetry.py. PASS (2/2).

  • 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}.

  • 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.

  • 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.

  • Step 3: Verify

tests/test_ws_session.py PASS (6/6, including Task 3's flows).

  • 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.

  • 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.

  • 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).

  • 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.

  • 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.

  • 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.

  • 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).

  • 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.

  • 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.

  • 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.

  • 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.

  • Step 5: Verify — cd frontend && npx tsc --noEmit clean for these files.

  • 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).

  • 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).

  • 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.

  • 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.

  • 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.

  • Step 5: Verify — npx tsc --noEmit clean; board/ghost visually verified in the running app.

  • 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.