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
18 KiB
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/devbyte 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
_sendertask fed by anasyncio.Queue— no task other than_sendermay callwebsocket.send_json. - A summoning must never visibly fail: if the Ollama box is dark,
spirit_service.mint_profiledegrades toentities.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 code4401. - 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.
- Foundation & Auth (complete)
- Frontend, LLM & Realtime Pipeline (complete)
- Wire Ghost mode + Ouija/Planchette UI (this plan)
- EVP Listening mode
- Spirit Radio mode
- Codex & entity persistence
- Internationalization (EN/ES)
Task 1: Wire Ghost Telemetry Sampler
Files:
- Create:
backend/app/telemetry.py - Test:
backend/tests/test_telemetry.py
Interfaces:
-
Produces:
TelemetrySampledataclass (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 constantsREFERENCE_HOSTS = [("1.1.1.1", 53), ("10.30.20.107", 11434)]andREFERENCE_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(addentity_id,language) - Modify:
backend/app/models/__init__.py,backend/app/main.py(/audiomount) - Test:
backend/tests/test_ws_session.py
Interfaces:
- Produces: the séance protocol (see table below);
Eventmodel (id,session_idFK,kind∈ {anomaly, utterance, question, reply, system},text,payloadJSONB,audio_path,created_at) — the per-session transcript;SeanceStatedataclass holding per-connection mode/language/entity/anomaly ring buffer/history/ambient task;AUDIO_DIR = Path(settings.data_dir) / "audio", mounted at/audioinmain.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, drainingstate.send_queue. Handlers and background tasks only eversend_queue.put(...). -
_record_eventpersists every anomaly/question/utterance/reply as anEventrow; utterance ids double as audio filenames (<event_id>.wav). -
_speakpushes the utterance text immediately, then synthesizes audio in a detached task and pushesaudio {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(setsended_at) runs as a detachedasyncio.create_taskthat survives handler teardown. The lifecycle test polls up to 2 s forended_atto 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_signaturefromapp.entities(Plan 6);spirit_service.fragment/chat_stream/mint_profile/wire_whisper/ambient_readyfromapp.llm.service(Plan 2/6);Entity,EntitySightingmodels (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/ServerFramediscriminated unions mirroring Task 2's protocol exactly (types.ts— "do not invent changes");VeilSocketwithconnect/close/send/onFrame/onState, outbox queueing while connecting, exponential backoff reconnect (800 ms → 15 s cap) surfacingconnecting|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(), andgetAmplitude()RMS via anAnalyserNodeso 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 --noEmitclean 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 helpersnormalizeWord/tokenize(A–Z/0–9/space only, uppercase); phasesidle|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 --noEmitclean; 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.