From c2f2cfe3eaa7e68cbf2da7c90287331535f5d809 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 22:43:10 -0400 Subject: [PATCH] feat: add Tower Log narrative event feed (Fixes #7) Adds the tower_log DB table, a narrateEvent method on AgentService (Haiku-powered, stub-safe), a tower-log service that persists and broadcasts entries, a GET /api/tower-log REST endpoint, WebSocket bootstrap and real-time push, and a bottom-sheet Tower Log panel in the-matrix UI with fade-in animations and auto-scroll. Co-Authored-By: Claude Sonnet 4.6 --- artifacts/api-server/src/lib/agent.ts | 83 +++++++++++++++++ artifacts/api-server/src/lib/event-bus.ts | 5 +- artifacts/api-server/src/lib/tower-log.ts | 74 +++++++++++++++ artifacts/api-server/src/routes/events.ts | 34 +++++++ artifacts/api-server/src/routes/index.ts | 2 + artifacts/api-server/src/routes/tower-log.ts | 21 +++++ lib/db/migrations/0010_tower_log.sql | 15 ++++ lib/db/src/schema/index.ts | 1 + lib/db/src/schema/tower-log.ts | 10 +++ the-matrix/index.html | 91 +++++++++++++++++++ the-matrix/js/main.js | 3 +- the-matrix/js/websocket.js | 94 ++++++++++++++++++++ 12 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 artifacts/api-server/src/lib/tower-log.ts create mode 100644 artifacts/api-server/src/routes/tower-log.ts create mode 100644 lib/db/migrations/0010_tower_log.sql create mode 100644 lib/db/src/schema/tower-log.ts diff --git a/artifacts/api-server/src/lib/agent.ts b/artifacts/api-server/src/lib/agent.ts index b92a95f..f2d8450 100644 --- a/artifacts/api-server/src/lib/agent.ts +++ b/artifacts/api-server/src/lib/agent.ts @@ -442,6 +442,89 @@ Respond ONLY with valid JSON: {"accepted": true/false, "reason": "..."}`, return ""; } } + /** + * Generate a short narrative entry for the Tower Log. + * Uses Haiku (evalModel) with Timmy's wizardly persona. + * Returns a single sentence under 100 characters. + * + * In STUB_MODE returns a canned narrative so the full flow + * can be exercised without an Anthropic API key. + */ + async narrateEvent(eventType: string, context?: string): Promise { + const STUB_NARRATIVES: Record = { + "job:complete": [ + "Timmy conjures a brilliant solution, weaving lightning and wisdom.", + "Another quest fulfilled — the Workshop hums with quiet satisfaction.", + "The crystal ball glows as Timmy delivers yet another worthy result.", + ], + "job:rejected": [ + "With gentle wisdom, Timmy declines — not all quests suit the Workshop.", + "The Beta oracle speaks: this path shall not be walked today.", + ], + "job:failed": [ + "The arcane machinery sputters — a job falters in the ether.", + "Even wizards face setbacks; Timmy regroups and stands ready.", + ], + "visitor:enter": [ + "A new traveler arrives, drawn by the Workshop's lightning glow.", + "The Workshop doors swing open to welcome another seeker.", + "Another soul finds the Workshop, guided by satoshi starlight.", + ], + "visitor:leave": [ + "A visitor departs, carrying a spark of the Workshop's magic.", + "The door closes softly — one more seeker returns to the world.", + ], + "payment:eval": [ + "⚡ Lightning strikes — eval fee confirmed, wisdom unlocked.", + "Sats flow in; the Workshop's scales tip toward action.", + ], + "payment:work": [ + "⚡ Work payment confirmed — Gamma stirs to weave the answer.", + "The Lightning Network delivers; Timmy's full power is unleashed.", + ], + }; + + const candidates = STUB_NARRATIVES[eventType] + ?? ["The Workshop stirs with quiet, purposeful magic."]; + + if (STUB_MODE) { + return candidates[Math.floor(Math.random() * candidates.length)]!; + } + + const EVENT_CONTEXT: Record = { + "job:complete": "A visitor's paid job completed successfully in Timmy's Workshop.", + "job:rejected": "A visitor's job request was rejected after AI evaluation.", + "job:failed": "A job failed unexpectedly in the Workshop.", + "visitor:enter": "A new visitor just entered Timmy's Workshop.", + "visitor:leave": "A visitor just left Timmy's Workshop.", + "payment:eval": "A visitor paid the evaluation fee via Lightning.", + "payment:work": "A visitor paid the work fee via Lightning, unlocking execution.", + }; + + const baseContext = EVENT_CONTEXT[eventType] ?? "Something noteworthy happened in the Workshop."; + const fullContext = context ? `${baseContext} ${context}` : baseContext; + + try { + const client = await getClient(); + const message = await client.messages.create({ + model: this.evalModel, // Haiku — cheap and fast + max_tokens: 80, + system: `You are the chronicler of Timmy's Workshop — a mystical tower powered by Bitcoin Lightning where an AI wizard named Timmy fulfills paid quests for visitors. +Write a single vivid sentence (strictly under 100 characters) narrating what just happened. +Style: wizardly, warm, slightly epic. Present tense. No quotes. No hashtags.`, + messages: [{ role: "user", content: `Narrate this event: ${fullContext}` }], + }); + const block = message.content[0]; + if (block?.type === "text") { + const text = block.text!.trim().replace(/^["']|["']$/g, ""); + return text.slice(0, 120); // hard cap + } + return candidates[0]!; + } catch (err) { + logger.warn("narrateEvent failed", { eventType, err: String(err) }); + return candidates[Math.floor(Math.random() * candidates.length)]!; + } + } } export const agentService = new AgentService(); diff --git a/artifacts/api-server/src/lib/event-bus.ts b/artifacts/api-server/src/lib/event-bus.ts index f6af56f..5c4060a 100644 --- a/artifacts/api-server/src/lib/event-bus.ts +++ b/artifacts/api-server/src/lib/event-bus.ts @@ -21,7 +21,10 @@ export type CostEvent = export type CommentaryEvent = | { type: "agent_commentary"; agentId: string; jobId: string; text: string }; -export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent | CommentaryEvent; +export type TowerLogEvent = + | { type: "tower_log:entry"; id: string; narrative: string; eventType: string; agentId: string | null; jobId: string | null; createdAt: string }; + +export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent | CommentaryEvent | TowerLogEvent; class EventBus extends EventEmitter { emit(event: "bus", data: BusEvent): boolean; diff --git a/artifacts/api-server/src/lib/tower-log.ts b/artifacts/api-server/src/lib/tower-log.ts new file mode 100644 index 0000000..0e2e120 --- /dev/null +++ b/artifacts/api-server/src/lib/tower-log.ts @@ -0,0 +1,74 @@ +/** + * Tower Log — narrative event feed (#7). + * + * Generates a prose narrative entry via Haiku whenever a key Workshop event + * occurs, persists it to the tower_log DB table, and emits it on the eventBus + * so connected WebSocket clients receive it in real time. + */ + +import { randomUUID } from "crypto"; +import { db, towerLog } from "@workspace/db"; +import { desc } from "drizzle-orm"; +import { eventBus } from "./event-bus.js"; +import { agentService } from "./agent.js"; +import { makeLogger } from "./logger.js"; + +const logger = makeLogger("tower-log"); + +export interface TowerLogRow { + id: string; + narrative: string; + eventType: string; + agentId: string | null; + jobId: string | null; + createdAt: Date; +} + +/** + * Generate a narrative entry, persist it, and broadcast it via eventBus. + * Non-fatal — errors are logged but never thrown. + */ +export async function addTowerLogEntry( + eventType: string, + context?: string, + agentId?: string, + jobId?: string, +): Promise { + try { + const narrative = await agentService.narrateEvent(eventType, context); + const id = randomUUID(); + + await db.insert(towerLog).values({ + id, + narrative, + eventType, + agentId: agentId ?? null, + jobId: jobId ?? null, + }); + + // Broadcast to connected WS clients + eventBus.publish({ + type: "tower_log:entry", + id, + narrative, + eventType, + agentId: agentId ?? null, + jobId: jobId ?? null, + createdAt: new Date().toISOString(), + }); + } catch (err) { + logger.warn("addTowerLogEntry failed", { eventType, err: String(err) }); + } +} + +/** + * Fetch the most recent N entries from the DB, oldest-first. + */ +export async function getRecentTowerLog(limit = 20): Promise { + const rows = await db + .select() + .from(towerLog) + .orderBy(desc(towerLog.createdAt)) + .limit(limit); + return rows.reverse(); +} diff --git a/artifacts/api-server/src/routes/events.ts b/artifacts/api-server/src/routes/events.ts index f644ded..08342ff 100644 --- a/artifacts/api-server/src/routes/events.ts +++ b/artifacts/api-server/src/routes/events.ts @@ -32,6 +32,7 @@ import { eventBus, type BusEvent } from "../lib/event-bus.js"; import { makeLogger } from "../lib/logger.js"; import { getWorldState, setAgentStateInWorld } from "../lib/world-state.js"; import { agentService } from "../lib/agent.js"; +import { addTowerLogEntry, getRecentTowerLog } from "../lib/tower-log.js"; import { db, worldEvents } from "@workspace/db"; const logger = makeLogger("ws-events"); @@ -269,6 +270,18 @@ function translateEvent(ev: BusEvent): object | null { text: ev.text, }; + // ── Tower Log (#7) ──────────────────────────────────────────────────────── + case "tower_log:entry": + return { + type: "tower_log_entry", + id: ev.id, + narrative: ev.narrative, + eventType: ev.eventType, + agentId: ev.agentId, + jobId: ev.jobId, + createdAt: ev.createdAt, + }; + default: return null; } @@ -306,6 +319,17 @@ async function sendWorldStateBootstrap(socket: WebSocket): Promise { } catch { send(socket, { type: "world_state", ...getWorldState(), recentEvents: [] }); } + + // Send recent tower log entries + try { + const logEntries = await getRecentTowerLog(20); + send(socket, { + type: "tower_log_history", + entries: logEntries, + }); + } catch { + /* non-fatal */ + } } export function attachWebSocketServer(server: Server): void { @@ -338,6 +362,7 @@ export function attachWebSocketServer(server: Server): void { const formattedNpub = `${npub.slice(0, 8)}…${npub.slice(-4)}`; broadcastToAll(wss, { type: "chat", agentId: "timmy", text: `Welcome, Nostr user ${formattedNpub}! What can I help you with?` }); } + void addTowerLogEntry("visitor:enter", undefined, "timmy"); wss.clients.forEach(c => { if (c !== socket && c.readyState === 1) { @@ -437,13 +462,22 @@ export function attachWebSocketServer(server: Server): void { agentId = "gamma"; phase = "starting"; } else if (ev.state === "complete") { agentId = "alpha"; phase = "complete"; + void addTowerLogEntry("job:complete", undefined, "alpha", ev.jobId); } else if (ev.state === "rejected") { agentId = "alpha"; phase = "rejected"; + void addTowerLogEntry("job:rejected", undefined, "beta", ev.jobId); + } else if (ev.state === "failed") { + void addTowerLogEntry("job:failed", undefined, "alpha", ev.jobId); } } else if (ev.type === "job:paid") { jobId = ev.jobId; agentId = "delta"; phase = ev.invoiceType === "eval" ? "eval_paid" : "work_paid"; + if (ev.invoiceType === "eval") { + void addTowerLogEntry("payment:eval", undefined, "delta", ev.jobId); + } else if (ev.invoiceType === "work") { + void addTowerLogEntry("payment:work", undefined, "delta", ev.jobId); + } } if (agentId && phase && jobId) { diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 1d404e4..b6ca581 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -18,6 +18,7 @@ import adminRelayRouter from "./admin-relay.js"; import adminRelayQueueRouter from "./admin-relay-queue.js"; import geminiRouter from "./gemini.js"; import statsRouter from "./stats.js"; +import towerLogRouter from "./tower-log.js"; const router: IRouter = Router(); @@ -33,6 +34,7 @@ router.use(relayRouter); router.use(adminRelayRouter); router.use(adminRelayQueueRouter); router.use(demoRouter); +router.use(towerLogRouter); router.use("/gemini", geminiRouter); router.use(testkitRouter); router.use(uiRouter); diff --git a/artifacts/api-server/src/routes/tower-log.ts b/artifacts/api-server/src/routes/tower-log.ts new file mode 100644 index 0000000..b976362 --- /dev/null +++ b/artifacts/api-server/src/routes/tower-log.ts @@ -0,0 +1,21 @@ +import { Router, type Request, type Response } from "express"; +import { getRecentTowerLog } from "../lib/tower-log.js"; +import { makeLogger } from "../lib/logger.js"; + +const logger = makeLogger("tower-log-route"); +const router = Router(); + +/** + * GET /api/tower-log — return the 20 most recent narrative entries, oldest first. + */ +router.get("/tower-log", async (_req: Request, res: Response) => { + try { + const entries = await getRecentTowerLog(20); + res.json({ entries }); + } catch (err) { + logger.error("GET /api/tower-log failed", { error: String(err) }); + res.status(500).json({ error: "tower_log_error" }); + } +}); + +export default router; diff --git a/lib/db/migrations/0010_tower_log.sql b/lib/db/migrations/0010_tower_log.sql new file mode 100644 index 0000000..364b094 --- /dev/null +++ b/lib/db/migrations/0010_tower_log.sql @@ -0,0 +1,15 @@ +-- Migration: Tower Log narrative event feed (#7) +-- Adds the tower_log table that stores prose narrative entries about +-- Workshop activity, generated by Haiku on key events. + +CREATE TABLE IF NOT EXISTS tower_log ( + id TEXT PRIMARY KEY, + narrative TEXT NOT NULL, + event_type TEXT NOT NULL, + agent_id TEXT, + job_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_tower_log_created_at + ON tower_log(created_at DESC); diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index e62c9ee..aa0d958 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -14,3 +14,4 @@ export * from "./relay-accounts"; export * from "./relay-event-queue"; export * from "./job-debates"; export * from "./session-messages"; +export * from "./tower-log"; diff --git a/lib/db/src/schema/tower-log.ts b/lib/db/src/schema/tower-log.ts new file mode 100644 index 0000000..c1ca034 --- /dev/null +++ b/lib/db/src/schema/tower-log.ts @@ -0,0 +1,10 @@ +import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; + +export const towerLog = pgTable("tower_log", { + id: text("id").primaryKey(), + narrative: text("narrative").notNull(), + eventType: text("event_type").notNull(), + agentId: text("agent_id"), + jobId: text("job_id"), + createdAt: timestamp("created_at").notNull().defaultNow(), +}); diff --git a/the-matrix/index.html b/the-matrix/index.html index 5679e6a..037f523 100644 --- a/the-matrix/index.html +++ b/the-matrix/index.html @@ -702,6 +702,85 @@ padding: 12px; margin: 0; max-height: 400px; overflow-y: auto; } + + /* ── Tower Log button ────────────────────────────────────────────── */ + #open-tower-log-btn { + font-family: 'Courier New', monospace; font-size: 11px; font-weight: bold; + color: #ccaaff; background: rgba(25, 10, 45, 0.85); border: 1px solid #663399; + padding: 7px 18px; cursor: pointer; letter-spacing: 2px; + box-shadow: 0 0 14px #44116622; + transition: background 0.15s, box-shadow 0.15s, color 0.15s; + border-radius: 2px; + min-height: 36px; + } + #open-tower-log-btn:hover, #open-tower-log-btn:active { + background: rgba(45, 18, 80, 0.95); + box-shadow: 0 0 20px #55228844; + color: #eeddff; + } + + /* ── Tower Log panel (bottom sheet) ─────────────────────────────── */ + #tower-log-panel { + position: fixed; bottom: -100%; left: 0; right: 0; + height: 65vh; + background: rgba(6, 3, 14, 0.97); + border-top: 1px solid #2a1040; + z-index: 100; + font-family: 'Courier New', monospace; + transition: bottom 0.35s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 -8px 32px rgba(80, 30, 130, 0.18); + display: flex; flex-direction: column; + } + #tower-log-panel.open { bottom: 60px; } + + .tlog-header { + display: flex; align-items: center; gap: 8px; + padding: 14px 20px 10px; + border-bottom: 1px solid #2a1040; + font-size: 12px; letter-spacing: 3px; color: #9966cc; + flex-shrink: 0; + text-shadow: 0 0 8px #66228866; + } + .tlog-header span { flex: 1; } + #tower-log-close { + background: transparent; border: 1px solid #2a1040; + color: #664488; font-family: 'Courier New', monospace; + font-size: 14px; padding: 3px 8px; cursor: pointer; + transition: color 0.2s, border-color 0.2s; border-radius: 2px; + } + #tower-log-close:hover { color: #bb88ff; border-color: #8844bb; } + + #tower-log-list { + flex: 1; overflow-y: auto; padding: 12px 20px; + overscroll-behavior: contain; + } + + .tlog-empty { + color: #44224466; font-size: 11px; letter-spacing: 1px; + line-height: 1.8; text-align: center; + margin-top: 40px; padding: 0 20px; + } + + .tlog-entry { + padding: 8px 0; + border-bottom: 1px solid #1a0a2a; + display: flex; gap: 10px; align-items: baseline; + animation: tlog-fade-in 0.4s ease-out; + } + .tlog-entry:last-child { border-bottom: none; } + @keyframes tlog-fade-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } + } + .tlog-time { + font-size: 9px; color: #443355; letter-spacing: 0.5px; + white-space: nowrap; flex-shrink: 0; min-width: 48px; + } + .tlog-text { + font-size: 11px; color: #bb99dd; line-height: 1.5; + letter-spacing: 0.3px; + } + .tlog-new { color: #ddbbff; text-shadow: 0 0 6px #9944cc44; } @@ -744,6 +823,7 @@ + ⚙ RELAY ADMIN @@ -923,6 +1003,17 @@ GPU context lost — recovering... + +
+
+ 📜 TOWER LOG + +
+
+
The chronicle awaits… events will appear here as Timmy works his magic.
+
+
+