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;