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:
154
artifacts/api-server/src/lib/tower-log.ts
Normal file
154
artifacts/api-server/src/lib/tower-log.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
18
artifacts/api-server/src/routes/tower-log.ts
Normal file
18
artifacts/api-server/src/routes/tower-log.ts
Normal 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;
|
||||
Reference in New Issue
Block a user