WIP: Claude Code progress on #15

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:30:20 -04:00
parent cbeaa61083
commit 12a53092e9
11 changed files with 580 additions and 2 deletions

View File

@@ -280,6 +280,40 @@ No hedging, no steering them back to the hosted version. The magic is meant to b
if (block.type !== "text") return "The crystal ball is cloudy… try again.";
return block.text!.trim();
}
/**
* Generate a short wizard-style thank-you for a Lightning zap.
* Uses the cheap Haiku model. Capped at 150 tokens for speech-bubble display.
*/
async zapThankYou(amountSats: number, message?: string | null): Promise<string> {
const STUB_THANKS = [
`${amountSats} sats received! By the runes — you honour my workshop!`,
`${amountSats} sats?! The crystal ball glows with gratitude! Thank you, traveler!`,
`⚡ A zap of ${amountSats} sats! The lightning spirits are pleased!`,
`${amountSats} sats — you have warmed this wizard's heart! The magic thanks you!`,
];
if (STUB_MODE) {
await new Promise((r) => setTimeout(r, 200));
return STUB_THANKS[Math.floor(Math.random() * STUB_THANKS.length)]!;
}
const client = await getClient();
const zapContext = message
? `Someone just sent you a ${amountSats} sat Lightning zap with the message: "${message}"`
: `Someone just sent you a ${amountSats} sat Lightning zap!`;
const msg = await client.messages.create({
model: this.evalModel,
max_tokens: 150,
system: `You are Timmy, a whimsical wizard who runs a Lightning-powered workshop. React to receiving a Bitcoin Lightning zap (tip) in 1-2 short, punchy sentences. Be warm, grateful, and weave in wizard/Lightning metaphors. Start with ⚡`,
messages: [{ role: "user", content: zapContext }],
});
const block = msg.content[0];
if (block.type !== "text") return `${amountSats} sats received! Thank you, traveler!`;
return block.text!.trim();
}
/**
* Run a mini debate on a borderline eval request (#21).
* Two opposing Haiku calls argue accept vs reject, then a third synthesizes.

View File

@@ -18,7 +18,10 @@ export type DebateEvent =
export type CostEvent =
| { type: "cost:update"; jobId: string; sats: number; phase: "eval" | "work" | "session"; isFinal: boolean };
export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent;
export type ZapEvent =
| { type: "zap:confirmed"; zapId: string; amountSats: number; message?: string | null; thankYou?: string };
export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent | ZapEvent;
class EventBus extends EventEmitter {
emit(event: "bus", data: BusEvent): boolean;

View File

@@ -3,7 +3,7 @@
*/
import { Router, type Request, type Response } from "express";
import { eq, or } from "drizzle-orm";
import { db, invoices, sessions, bootstrapJobs } from "@workspace/db";
import { db, invoices, sessions, bootstrapJobs, zaps } from "@workspace/db";
import { lnbitsService } from "../lib/lnbits.js";
const router = Router();
@@ -54,6 +54,14 @@ router.post("/dev/stub/pay/:paymentHash", async (req: Request, res: Response) =>
}
}
// 4. Check zaps table
if (!bolt11) {
const zapRows = await db.select().from(zaps).where(eq(zaps.paymentHash, paymentHash)).limit(1);
if (zapRows.length > 0) {
bolt11 = zapRows[0]!.paymentRequest;
}
}
if (!bolt11) {
res.status(404).json({ error: "Invoice not found for paymentHash" });
return;

View File

@@ -257,6 +257,23 @@ function translateEvent(ev: BusEvent): object | null {
isFinal: ev.isFinal,
};
// ── Zap confirmed (#15) ───────────────────────────────────────────────────
case "zap:confirmed": {
void logWorldEvent("zap:confirmed", `Zap confirmed: ${ev.amountSats} sats`);
const msgs: object[] = [
{
type: "zap_confirmed",
zapId: ev.zapId,
amountSats: ev.amountSats,
message: ev.message ?? null,
},
];
if (ev.thankYou) {
msgs.push({ type: "chat", agentId: "timmy", text: ev.thankYou });
}
return msgs;
}
default:
return null;
}

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 zapRouter from "./zap.js";
const router: IRouter = Router();
@@ -32,6 +33,7 @@ router.use(adminRelayRouter);
router.use(adminRelayQueueRouter);
router.use(demoRouter);
router.use("/gemini", geminiRouter);
router.use(zapRouter);
router.use(testkitRouter);
router.use(uiRouter);
router.use(nodeDiagnosticsRouter);

View File

@@ -0,0 +1,148 @@
/**
* /api/zap — Lightning zap to Timmy from the Workshop (#15)
*
* POST /api/zap — Create a LNbits invoice for a zap; starts background payment poll
* GET /api/zap/:id — Poll zap status (paid / pending)
*/
import { Router, type Request, type Response } from "express";
import { randomUUID } from "crypto";
import { eq } from "drizzle-orm";
import { db, zaps } from "@workspace/db";
import { lnbitsService } from "../lib/lnbits.js";
import { agentService } from "../lib/agent.js";
import { eventBus } from "../lib/event-bus.js";
import { makeLogger } from "../lib/logger.js";
const router = Router();
const logger = makeLogger("zap");
const PRESET_AMOUNTS = [21, 210, 2100];
const MIN_SATS = 1;
const MAX_SATS = 100_000;
// ── Background payment poller ─────────────────────────────────────────────────
const ZAP_POLL_INTERVAL_MS = 2_000;
const ZAP_POLL_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
async function pollUntilPaid(zapId: string, paymentHash: string, startedAt: number): Promise<void> {
if (Date.now() - startedAt > ZAP_POLL_TIMEOUT_MS) {
logger.info("zap poll timed out", { zapId });
return;
}
const paid = await lnbitsService.checkInvoicePaid(paymentHash).catch(() => false);
if (paid) {
const paidAt = new Date();
await db.update(zaps)
.set({ paid: true, paidAt })
.where(eq(zaps.id, zapId));
const rows = await db.select().from(zaps).where(eq(zaps.id, zapId)).limit(1);
const zap = rows[0];
if (!zap) return;
logger.info("zap paid", { zapId, amountSats: zap.amountSats });
// Generate Timmy's thank-you before publishing the event so the
// WebSocket bridge can emit it as a chat message in one shot.
let thankYou: string | undefined;
try {
thankYou = await agentService.zapThankYou(zap.amountSats, zap.message);
} catch (err) {
logger.warn("zapThankYou failed", { err: String(err) });
}
eventBus.publish({
type: "zap:confirmed",
zapId,
amountSats: zap.amountSats,
message: zap.message,
thankYou,
});
return;
}
setTimeout(
() => void pollUntilPaid(zapId, paymentHash, startedAt),
ZAP_POLL_INTERVAL_MS,
);
}
// ── POST /api/zap ─────────────────────────────────────────────────────────────
router.post("/api/zap", async (req: Request, res: Response) => {
const body = req.body as { amount_sats?: unknown; message?: unknown };
const amountSats = Number(body.amount_sats);
if (!Number.isInteger(amountSats) || amountSats < MIN_SATS || amountSats > MAX_SATS) {
res.status(400).json({
error: `amount_sats must be an integer between ${MIN_SATS} and ${MAX_SATS}`,
presets: PRESET_AMOUNTS,
});
return;
}
const message =
typeof body.message === "string" ? body.message.slice(0, 200).trim() || null : null;
try {
const memo = message
? `Zap Timmy: ${message.slice(0, 100)}`
: `Zap Timmy ${amountSats} sats`;
const invoice = await lnbitsService.createInvoice(amountSats, memo);
const zapId = randomUUID();
await db.insert(zaps).values({
id: zapId,
paymentHash: invoice.paymentHash,
paymentRequest: invoice.paymentRequest,
amountSats,
message,
});
logger.info("zap created", { zapId, amountSats });
// Start background poll
setTimeout(() => void pollUntilPaid(zapId, invoice.paymentHash, Date.now()), ZAP_POLL_INTERVAL_MS);
res.status(201).json({
zapId,
amountSats,
message,
paymentHash: invoice.paymentHash,
paymentRequest: invoice.paymentRequest,
presets: PRESET_AMOUNTS,
});
} catch (err) {
logger.error("zap create failed", { err: String(err) });
res.status(500).json({ error: "Failed to create zap invoice" });
}
});
// ── GET /api/zap/:zapId ───────────────────────────────────────────────────────
router.get("/api/zap/:zapId", async (req: Request, res: Response) => {
const { zapId } = req.params as { zapId: string };
const rows = await db.select().from(zaps).where(eq(zaps.id, zapId)).limit(1);
const zap = rows[0];
if (!zap) {
res.status(404).json({ error: "Zap not found" });
return;
}
res.json({
zapId: zap.id,
amountSats: zap.amountSats,
message: zap.message,
paid: zap.paid,
paidAt: zap.paidAt,
paymentHash: zap.paymentHash,
});
});
export default router;