WIP: Claude Code progress on #7

Automated salvage commit — agent session ended (exit 124).
Work in progress, may need continuation.
This commit is contained in:
Alexander Whitestone
2026-03-23 16:43:11 -04:00
parent e41d30d308
commit 3831a1c96d
11 changed files with 421 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
import { randomUUID } from "crypto";
import { db, towerLog } from "@workspace/db";
import { desc } from "drizzle-orm";
import { makeLogger } from "./logger.js";
const logger = makeLogger("tower-log");
const STUB_MODE =
!process.env["AI_INTEGRATIONS_ANTHROPIC_API_KEY"] ||
!process.env["AI_INTEGRATIONS_ANTHROPIC_BASE_URL"];
// Stub narratives for each event type
const STUB_NARRATIVES: Record<string, string[]> = {
"job:complete": [
"Timmy completed a visitor's quest with wizardly precision, sending forth the fruits of his craft.",
"The Workshop hums with satisfaction — another task fulfilled by Timmy's capable hands.",
"A spell is cast and resolved; Timmy's work is done, Lightning sats exchanged for wisdom.",
],
"job:evaluating": [
"Timmy peers into the crystal ball, studying a new request with keen arcane eyes.",
"The gatekeeper stirs — Timmy weighs a visitor's petition before the Workshop gates.",
"Beta's scales tip as Timmy evaluates the merit of a new task.",
],
"job:executing": [
"Timmy's workshop blazes with activity as he tackles a visitor's request.",
"Gears spin and lightning crackles — Timmy is hard at work on a quest.",
"The wizard focuses, channeling deep knowledge into a visitor's commission.",
],
"visitor:enter": [
"A new visitor steps through the Workshop door, drawn by the glow of Timmy's lantern.",
"The crystal ball shimmers — someone new has arrived in the Workshop.",
"Footsteps echo across the Workshop floor as a curious soul enters.",
],
"visitor:leave": [
"A visitor departs the Workshop, their lantern lit by Timmy's wisdom.",
"The door closes softly — another seeker leaves the Workshop enriched.",
"A traveler takes their leave, carrying new knowledge into the wider world.",
],
"visitor:reply": [
"Timmy offers a word of wizardly counsel to a curious visitor.",
"The crystal ball speaks — Timmy shares his ancient wisdom.",
],
"default": [
"The Workshop stirs with quiet activity.",
"Timmy tends to the Workshop's many enchantments.",
],
};
function pickStub(eventType: string): string {
const pool = STUB_NARRATIVES[eventType] ?? STUB_NARRATIVES["default"]!;
return pool[Math.floor(Math.random() * pool.length)]!;
}
interface AnthropicLike {
messages: {
create(params: Record<string, unknown>): Promise<{
content: Array<{ type: string; text?: string }>;
}>;
};
}
let _anthropic: AnthropicLike | null = null;
async function getClient(): Promise<AnthropicLike> {
if (_anthropic) return _anthropic;
// @ts-expect-error -- dynamic import of integrations package
const mod = (await import("@workspace/integrations-anthropic-ai")) as { anthropic: AnthropicLike };
_anthropic = mod.anthropic;
return _anthropic;
}
const NARRATIVE_SYSTEM = `You are the Tower Chronicler — a mystical narrator who records the story of Timmy's Workshop in Timmy's voice: wizardly, warm, slightly epic. When given an event, write exactly 1-2 sentences of prose narrative about what happened. Keep it under 160 characters total. Use third person. No quotation marks.`;
const NARRATIVE_MODEL = "claude-haiku-4-5";
export async function generateNarrative(
eventType: string,
context: Record<string, string>,
): Promise<string> {
if (STUB_MODE) {
return pickStub(eventType);
}
const contextStr = Object.entries(context)
.map(([k, v]) => `${k}: ${v}`)
.join(", ");
try {
const client = await getClient();
const msg = await client.messages.create({
model: NARRATIVE_MODEL,
max_tokens: 80,
system: NARRATIVE_SYSTEM,
messages: [
{
role: "user",
content: `Event: ${eventType}. Context: ${contextStr}. Write the narrative entry.`,
},
],
});
const block = msg.content[0];
if (block?.type === "text" && block.text) {
return block.text.trim().slice(0, 200);
}
} catch (err) {
logger.warn("narrative generation failed, using stub", { err: String(err) });
}
return pickStub(eventType);
}
export interface StoredEntry {
id: string;
eventType: string;
narrative: string;
agentId: string | null;
jobId: string | null;
createdAt: Date;
}
export async function addTowerLogEntry(
eventType: string,
narrative: string,
agentId?: string,
jobId?: string,
): Promise<StoredEntry> {
const entry: StoredEntry = {
id: randomUUID(),
eventType,
narrative,
agentId: agentId ?? null,
jobId: jobId ?? null,
createdAt: new Date(),
};
try {
await db.insert(towerLog).values(entry);
} catch (err) {
logger.warn("failed to insert tower_log entry", { err: String(err) });
}
return entry;
}
export async function getRecentEntries(limit = 20): Promise<StoredEntry[]> {
try {
const rows = await db
.select()
.from(towerLog)
.orderBy(desc(towerLog.createdAt))
.limit(limit);
return rows.reverse() as StoredEntry[];
} catch (err) {
logger.warn("failed to fetch tower_log entries", { err: String(err) });
return [];
}
}

View File

@@ -33,6 +33,7 @@ import { makeLogger } from "../lib/logger.js";
import { getWorldState, setAgentStateInWorld } from "../lib/world-state.js";
import { agentService } from "../lib/agent.js";
import { db, worldEvents } from "@workspace/db";
import { generateNarrative, addTowerLogEntry } from "../lib/tower-log.js";
const logger = makeLogger("ws-events");
@@ -95,6 +96,22 @@ async function logWorldEvent(
}
}
async function emitTowerLogEntry(
wss: WebSocketServer,
eventType: string,
context: Record<string, string>,
agentId?: string,
jobId?: string,
): Promise<void> {
try {
const narrative = await generateNarrative(eventType, context);
const entry = await addTowerLogEntry(eventType, narrative, agentId, jobId);
broadcastToAll(wss, { type: "tower_log_entry", entry });
} catch {
/* non-fatal */
}
}
function translateEvent(ev: BusEvent): object | null {
switch (ev.type) {
// ── Mode 1 job lifecycle ─────────────────────────────────────────────────
@@ -299,6 +316,17 @@ async function sendWorldStateBootstrap(socket: WebSocket): Promise<void> {
export function attachWebSocketServer(server: Server): void {
const wss = new WebSocketServer({ server, path: "/api/ws" });
// Tower Log: generate narrative on key job events (module-level, once per wss)
eventBus.on("bus", (ev: BusEvent) => {
if (ev.type === "job:state" && ev.state === "complete") {
void emitTowerLogEntry(wss, "job:complete", { jobId: ev.jobId.slice(0, 8) }, "alpha", ev.jobId);
} else if (ev.type === "job:state" && ev.state === "evaluating") {
void emitTowerLogEntry(wss, "job:evaluating", { jobId: ev.jobId.slice(0, 8) }, "beta", ev.jobId);
} else if (ev.type === "job:state" && ev.state === "executing") {
void emitTowerLogEntry(wss, "job:executing", { jobId: ev.jobId.slice(0, 8) }, "gamma", ev.jobId);
}
});
wss.on("connection", (socket: WebSocket, req: IncomingMessage) => {
const ip = req.headers["x-forwarded-for"] ?? req.socket.remoteAddress ?? "unknown";
logger.info("ws client connected", { ip, clients: wss.clients.size });
@@ -326,6 +354,7 @@ export function attachWebSocketServer(server: Server): void {
}
});
send(socket, { type: "visitor_count", count: wss.clients.size });
void emitTowerLogEntry(wss, "visitor:enter", {}, "timmy");
}
if (msg.type === "visitor_leave") {
wss.clients.forEach(c => {
@@ -333,6 +362,7 @@ export function attachWebSocketServer(server: Server): void {
c.send(JSON.stringify({ type: "visitor_count", count: Math.max(0, wss.clients.size - 1) }));
}
});
void emitTowerLogEntry(wss, "visitor:leave", {}, "timmy");
}
if (msg.type === "visitor_message" && msg.text) {
const text = String(msg.text).slice(0, 500);
@@ -366,6 +396,7 @@ export function attachWebSocketServer(server: Server): void {
broadcastToAll(wss, { type: "chat", agentId: "timmy", text: reply });
void logWorldEvent("visitor:reply", reply.slice(0, 100), "timmy");
void emitTowerLogEntry(wss, "visitor:reply", { reply: reply.slice(0, 60) }, "timmy");
} catch (err) {
broadcastToAll(wss, { type: "agent_state", agentId: "gamma", state: "idle" });
updateAgentWorld("gamma", "idle");

View File

@@ -17,6 +17,7 @@ import relayRouter from "./relay.js";
import adminRelayRouter from "./admin-relay.js";
import adminRelayQueueRouter from "./admin-relay-queue.js";
import geminiRouter from "./gemini.js";
import towerLogRouter from "./tower-log.js";
const router: IRouter = Router();
@@ -36,6 +37,7 @@ router.use(testkitRouter);
router.use(uiRouter);
router.use(nodeDiagnosticsRouter);
router.use(worldRouter);
router.use(towerLogRouter);
// Mount dev routes when NOT in production OR when LNbits is in stub mode.
// Stub mode means there is no real Lightning backend — payments are simulated

View File

@@ -0,0 +1,18 @@
import { Router, type Request, type Response } from "express";
import { getRecentEntries } from "../lib/tower-log.js";
import { makeLogger } from "../lib/logger.js";
const logger = makeLogger("tower-log-route");
const router = Router();
router.get("/tower-log", async (_req: Request, res: Response) => {
try {
const entries = await getRecentEntries(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;

View File

@@ -0,0 +1,9 @@
-- Migration: Tower Log narrative event table (#7)
CREATE TABLE IF NOT EXISTS tower_log (
id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
narrative TEXT NOT NULL,
agent_id TEXT,
job_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -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";

View File

@@ -0,0 +1,12 @@
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const towerLog = pgTable("tower_log", {
id: text("id").primaryKey(),
eventType: text("event_type").notNull(),
narrative: text("narrative").notNull(),
agentId: text("agent_id"),
jobId: text("job_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export type TowerLogEntry = typeof towerLog.$inferSelect;

View File

@@ -112,6 +112,81 @@
color: #88ffcc;
}
/* ── Tower Log toggle button ──────────────────────────────────────── */
#open-tower-log-btn {
font-family: 'Courier New', monospace; font-size: 11px; font-weight: bold;
color: #ddcc88; background: #1a1500; border: 1px solid #aa9922;
padding: 7px 18px; cursor: pointer; letter-spacing: 1px;
box-shadow: 0 0 14px #55440022;
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: #2a2200;
box-shadow: 0 0 20px #88660044;
color: #ffee88;
}
/* ── Tower Log panel (bottom slide-up) ────────────────────────────── */
#tower-log-panel {
position: fixed; bottom: -100%; left: 0; right: 0;
height: 320px;
background: rgba(10, 8, 2, 0.97);
border-top: 1px solid #2a2200;
padding: 0;
overflow: hidden; 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(100, 80, 10, 0.15);
}
#tower-log-panel.open {
bottom: 0;
}
#tower-log-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 16px 8px;
border-bottom: 1px solid #2a2200;
}
#tower-log-header h2 {
font-size: 12px; letter-spacing: 3px; color: #ccaa44;
text-shadow: 0 0 8px #88660088;
margin: 0;
}
#tower-log-close {
background: transparent; border: 1px solid #2a2200;
color: #665522; font-family: 'Courier New', monospace;
font-size: 16px; width: 28px; height: 28px;
cursor: pointer; transition: color 0.2s, border-color 0.2s;
flex-shrink: 0;
}
#tower-log-close:hover { color: #ddcc44; border-color: #aa9922; }
#tower-log-entries {
height: calc(320px - 46px);
overflow-y: auto;
padding: 10px 16px;
scroll-behavior: smooth;
}
#tower-log-entries::-webkit-scrollbar { width: 4px; }
#tower-log-entries::-webkit-scrollbar-track { background: transparent; }
#tower-log-entries::-webkit-scrollbar-thumb { background: #2a2200; border-radius: 2px; }
.tower-log-entry {
padding: 7px 0;
border-bottom: 1px solid #1a1200;
color: #998855;
font-size: 11px;
line-height: 1.6;
}
.tower-log-entry:last-child { border-bottom: none; }
.tower-log-entry .tl-time {
font-size: 9px; color: #443300; letter-spacing: 1px; margin-bottom: 2px;
}
.tower-log-entry .tl-text { color: #ccaa66; }
#tower-log-empty {
color: #443300; font-size: 11px; text-align: center; padding: 30px 0;
letter-spacing: 2px;
}
/* ── Low balance notice ───────────────────────────────────────────── */
#low-balance-notice {
display: none;
@@ -541,6 +616,7 @@
<div id="top-buttons">
<button id="open-panel-btn">⚡ SUBMIT JOB</button>
<button id="open-session-btn">⚡ FUND SESSION</button>
<button id="open-tower-log-btn">📜 TOWER LOG</button>
<a id="relay-admin-btn" href="/admin/relay">⚙ RELAY ADMIN</a>
</div>
@@ -720,5 +796,16 @@
})();
</script>
<script type="module" src="./js/main.js"></script>
<!-- ── Tower Log panel (bottom) ───────────────────────────────────── -->
<div id="tower-log-panel">
<div id="tower-log-header">
<h2>📜 TOWER LOG</h2>
<button id="tower-log-close"></button>
</div>
<div id="tower-log-entries">
<div id="tower-log-empty">NO ENTRIES YET…</div>
</div>
</div>
</body>
</html>

View File

@@ -18,6 +18,7 @@ import { initTimmyId } from './timmy-id.js';
import { AGENT_DEFS } from './agent-defs.js';
import { initNavigation, updateNavigation, disposeNavigation } from './navigation.js';
import { initHudLabels, updateHudLabels, disposeHudLabels } from './hud-labels.js';
import { initTowerLog } from './tower-log.js';
let running = false;
let canvas = null;
@@ -46,6 +47,7 @@ function buildWorld(firstInit, stateSnapshot) {
initWebSocket(scene);
initPaymentPanel();
initSessionPanel();
initTowerLog();
void initNostrIdentity('/api');
warmupEdgeWorker();
onEdgeWorkerReady(() => setEdgeWorkerReady());

View File

@@ -0,0 +1,97 @@
// Tower Log UI — slide-out narrative event feed (#7)
const MAX_ENTRIES = 20;
let _entries = [];
let _initialized = false;
function $panel() { return document.getElementById('tower-log-panel'); }
function $entriesEl() { return document.getElementById('tower-log-entries'); }
function $emptyEl() { return document.getElementById('tower-log-empty'); }
function _formatTime(isoOrDate) {
const d = new Date(isoOrDate);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function _renderEntry(entry) {
const el = document.createElement('div');
el.className = 'tower-log-entry';
el.dataset.id = entry.id;
const timeEl = document.createElement('div');
timeEl.className = 'tl-time';
timeEl.textContent = _formatTime(entry.createdAt);
const textEl = document.createElement('div');
textEl.className = 'tl-text';
textEl.textContent = entry.narrative;
el.appendChild(timeEl);
el.appendChild(textEl);
return el;
}
function _refresh() {
const container = $entriesEl();
const empty = $emptyEl();
if (!container) return;
if (_entries.length === 0) {
if (empty) empty.style.display = '';
return;
}
if (empty) empty.style.display = 'none';
// Remove entries beyond container's own DOM children (don't double-render)
const existing = new Set(
Array.from(container.querySelectorAll('.tower-log-entry')).map(el => el.dataset.id)
);
for (const entry of _entries) {
if (!existing.has(entry.id)) {
container.appendChild(_renderEntry(entry));
}
}
// Auto-scroll to bottom
container.scrollTop = container.scrollHeight;
}
export function appendTowerLogEntry(entry) {
// Deduplicate
if (_entries.some(e => e.id === entry.id)) return;
_entries.push(entry);
if (_entries.length > MAX_ENTRIES) _entries.shift();
if ($panel()?.classList.contains('open')) {
_refresh();
}
}
async function _fetchHistory() {
try {
const res = await fetch('/api/tower-log');
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data.entries)) {
_entries = data.entries.slice(-MAX_ENTRIES);
}
} catch {
// ignore
}
}
export function openTowerLog() {
const panel = $panel();
if (!panel) return;
panel.classList.add('open');
if (!_initialized) {
_initialized = true;
_fetchHistory().then(() => _refresh());
} else {
_refresh();
}
}
export function closeTowerLog() {
$panel()?.classList.remove('open');
}
export function initTowerLog() {
const openBtn = document.getElementById('open-tower-log-btn');
const closeBtn = document.getElementById('tower-log-close');
if (openBtn) openBtn.addEventListener('click', openTowerLog);
if (closeBtn) closeBtn.addEventListener('click', closeTowerLog);
}

View File

@@ -2,6 +2,7 @@ import { setAgentState, setSpeechBubble, applyAgentStates, setMood } from './age
import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker } from './ui.js';
import { sentiment } from './edge-worker-client.js';
import { setLabelState } from './hud-labels.js';
import { appendTowerLogEntry } from './tower-log.js';
function resolveWsUrl() {
const explicit = import.meta.env.VITE_WS_URL;
@@ -151,6 +152,13 @@ function handleMessage(msg) {
break;
}
case 'tower_log_entry': {
if (msg.entry) {
appendTowerLogEntry(msg.entry);
}
break;
}
case 'agent_count':
case 'visitor_count':
break;