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;

View File

@@ -0,0 +1,13 @@
-- Migration: Zap Timmy from the Workshop (#15)
-- Stores Lightning zap invoices and records when they are paid.
CREATE TABLE IF NOT EXISTS zaps (
id TEXT PRIMARY KEY,
payment_hash TEXT NOT NULL UNIQUE,
payment_request TEXT NOT NULL,
amount_sats INTEGER NOT NULL,
message TEXT,
paid BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
paid_at TIMESTAMPTZ
);

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 "./zaps";

14
lib/db/src/schema/zaps.ts Normal file
View File

@@ -0,0 +1,14 @@
import { pgTable, text, integer, boolean, timestamp } from "drizzle-orm/pg-core";
export const zaps = pgTable("zaps", {
id: text("id").primaryKey(),
paymentHash: text("payment_hash").notNull().unique(),
paymentRequest: text("payment_request").notNull(),
amountSats: integer("amount_sats").notNull(),
message: text("message"),
paid: boolean("paid").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
paidAt: timestamp("paid_at", { withTimezone: true }),
});
export type Zap = typeof zaps.$inferSelect;

View File

@@ -514,6 +514,109 @@
}
#timmy-id-card .id-npub:hover { color: #88aadd; }
#timmy-id-card .id-zaps { color: #556688; font-size: 9px; }
/* ── Zap button ───────────────────────────────────────────────────── */
#open-zap-btn {
font-family: 'Courier New', monospace; font-size: 11px; font-weight: bold;
color: #ffee44; background: rgba(40, 35, 5, 0.85); border: 1px solid #aaaa22;
padding: 7px 18px; cursor: pointer; letter-spacing: 2px;
box-shadow: 0 0 14px #aaaa2222;
transition: background 0.15s, box-shadow 0.15s, color 0.15s;
border-radius: 2px;
min-height: 36px;
}
#open-zap-btn:hover, #open-zap-btn:active {
background: rgba(60, 55, 8, 0.95);
box-shadow: 0 0 20px #dddd3344;
color: #ffff88;
}
/* ── Zap panel (bottom sliding) ───────────────────────────────────── */
#zap-panel {
position: fixed; bottom: -480px; left: 50%; transform: translateX(-50%);
width: min(420px, 96vw);
background: rgba(12, 10, 3, 0.97);
border: 1px solid #2a2808;
border-bottom: none;
padding: 20px 18px 24px;
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(160, 140, 20, 0.12);
}
#zap-panel.open { bottom: 56px; }
#zap-panel h2 {
font-size: 12px; letter-spacing: 3px; color: #aaaa44;
text-shadow: 0 0 10px #666611;
margin-bottom: 14px; border-bottom: 1px solid #2a2808; padding-bottom: 8px;
}
#zap-close {
position: absolute; top: 14px; right: 14px;
background: transparent; border: 1px solid #2a2808;
color: #555522; font-family: 'Courier New', monospace;
font-size: 15px; width: 26px; height: 26px;
cursor: pointer; transition: color 0.2s, border-color 0.2s;
}
#zap-close:hover { color: #aaaa44; border-color: #888822; }
.zap-amount-presets {
display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0;
}
.zap-amount-btn {
background: transparent; border: 1px solid #2a2808;
color: #777722; font-family: 'Courier New', monospace;
font-size: 11px; letter-spacing: 1px; padding: 5px 11px;
cursor: pointer; transition: all 0.15s; border-radius: 2px;
}
.zap-amount-btn:hover { background: #2a2808; border-color: #aaaa22; color: #dddd44; }
.zap-amount-btn.active { background: #2a2808; border-color: #cccc33; color: #ffff66; }
.zap-amount-row {
display: flex; align-items: center; gap: 6px; margin: 6px 0 10px;
}
.zap-amount-row span { color: #777722; font-size: 11px; letter-spacing: 1px; }
.zap-amount-input {
background: #0a0800; border: 1px solid #2a2808;
color: #dddd44; font-family: 'Courier New', monospace;
font-size: 15px; font-weight: bold; letter-spacing: 1px;
padding: 5px 9px; width: 100px;
outline: none; border-radius: 2px;
transition: border-color 0.2s;
-moz-appearance: textfield;
}
.zap-amount-input:focus { border-color: #aaaa22; }
.zap-amount-input::-webkit-outer-spin-button,
.zap-amount-input::-webkit-inner-spin-button { -webkit-appearance: none; }
#zap-message-input {
width: 100%; background: #0a0800; border: 1px solid #2a2808;
color: #bbbb44; font-family: 'Courier New', monospace; font-size: 12px;
padding: 8px; outline: none; resize: none; height: 50px;
transition: border-color 0.2s; border-radius: 2px;
}
#zap-message-input:focus { border-color: #aaaa22; }
#zap-message-input::placeholder { color: #333311; }
#zap-qr-img {
display: none; width: 200px; height: 200px;
margin: 8px auto; border: 1px solid #2a2808;
}
.zap-qr-placeholder {
display: flex; align-items: center; justify-content: center;
margin: 8px 0;
background: #0a0800; border: 1px solid #2a2808;
color: #333311; font-size: 11px; letter-spacing: 3px;
height: 50px;
}
#zap-status { font-size: 11px; margin-top: 6px; color: #aaaa44; min-height: 14px; }
#zap-error { font-size: 11px; margin-top: 4px; min-height: 14px; color: #aa4422; }
.zap-done-msg {
font-size: 13px; color: #ffff66; letter-spacing: 2px;
text-align: center; margin: 12px 0;
text-shadow: 0 0 14px #aaaa22;
}
</style>
</head>
<body>
@@ -541,6 +644,7 @@
<div id="top-buttons">
<button id="open-panel-btn">⚡ SUBMIT JOB</button>
<button id="open-session-btn">⚡ FUND SESSION</button>
<button id="open-zap-btn">⚡ ZAP TIMMY</button>
<a id="relay-admin-btn" href="/admin/relay">⚙ RELAY ADMIN</a>
</div>

234
the-matrix/js/zap.js Normal file
View File

@@ -0,0 +1,234 @@
/**
* zap.js — Zap Timmy panel for the Workshop (#15)
*
* Flow:
* 1. Visitor clicks "⚡ ZAP" button → panel slides in
* 2. Picks preset amount (21 / 210 / 2100 sats) or enters custom
* 3. Optionally adds a message
* 4. POST /api/zap → invoice returned, QR + copy displayed
* 5. Frontend polls GET /api/zap/:id; on paid → celebration animation
* 6. Backend emits zap:confirmed → WS broadcasts zap_confirmed + Timmy chat
*/
const API_BASE = '/api';
const POLL_INTERVAL_MS = 2_000;
const POLL_TIMEOUT_MS = 10 * 60 * 1000; // 10 min
let panel = null;
let currentZapId = null;
let currentPaymentHash = null;
let pollTimer = null;
let onCelebrate = null; // callback(amountSats, thankYou?)
export function initZapPanel(celebrateCallback) {
panel = document.getElementById('zap-panel');
if (!panel) return;
onCelebrate = celebrateCallback ?? null;
document.getElementById('zap-close')?.addEventListener('click', closePanel);
document.getElementById('open-zap-btn')?.addEventListener('click', openPanel);
document.getElementById('zap-back-btn')?.addEventListener('click', resetPanel);
// Amount preset buttons
panel.querySelectorAll('.zap-amount-btn').forEach(btn => {
btn.addEventListener('click', () => {
panel.querySelectorAll('.zap-amount-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const amountInput = document.getElementById('zap-amount-input');
if (amountInput) amountInput.value = btn.dataset.sats;
});
});
document.getElementById('zap-submit-btn')?.addEventListener('click', createZap);
document.getElementById('zap-simulate-btn')?.addEventListener('click', simulatePayment);
}
export function openPanel() {
if (!panel) return;
panel.classList.add('open');
resetPanel();
}
function closePanel() {
if (!panel) return;
panel.classList.remove('open');
stopPolling();
}
function resetPanel() {
stopPolling();
currentZapId = null;
currentPaymentHash = null;
setZapStep('pick');
setZapError('');
setZapStatus('');
// Reset to default preset
panel.querySelectorAll('.zap-amount-btn').forEach(b => b.classList.remove('active'));
panel.querySelector('.zap-amount-btn[data-sats="21"]')?.classList.add('active');
const amountInput = document.getElementById('zap-amount-input');
if (amountInput) amountInput.value = '21';
const msgInput = document.getElementById('zap-message-input');
if (msgInput) msgInput.value = '';
}
function setZapStep(step) {
panel.querySelectorAll('[data-zap-step]').forEach(el => {
el.style.display = el.dataset.zapStep === step ? '' : 'none';
});
}
function setZapStatus(msg, color) {
const el = document.getElementById('zap-status');
if (el) { el.textContent = msg; el.style.color = color || '#00ff41'; }
}
function setZapError(msg) {
const el = document.getElementById('zap-error');
if (el) { el.textContent = msg; el.style.color = '#ff4444'; }
}
async function createZap() {
const amountInput = document.getElementById('zap-amount-input');
const msgInput = document.getElementById('zap-message-input');
const amountSats = parseInt(amountInput?.value ?? '21', 10);
if (!Number.isFinite(amountSats) || amountSats < 1) {
setZapError('Enter a valid amount (min 1 sat).');
return;
}
const message = msgInput?.value?.trim() || null;
setZapError('');
setZapStatus('Creating invoice…', '#ffaa00');
const btn = document.getElementById('zap-submit-btn');
if (btn) btn.disabled = true;
try {
const res = await fetch(`${API_BASE}/zap`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount_sats: amountSats, message }),
});
const data = await res.json();
if (!res.ok) {
setZapError(data.error || 'Failed to create invoice.');
return;
}
currentZapId = data.zapId;
currentPaymentHash = data.paymentHash;
showInvoice(data);
} catch (err) {
setZapError('Network error: ' + err.message);
} finally {
if (btn) btn.disabled = false;
}
}
function showInvoice(data) {
setZapStep('invoice');
document.getElementById('zap-invoice-amount').textContent = data.amountSats + ' sats';
const prEl = document.getElementById('zap-payment-request');
if (prEl) prEl.textContent = data.paymentRequest || '';
const hashSpan = document.getElementById('zap-hash');
if (hashSpan) hashSpan.dataset.hash = data.paymentHash || '';
// QR code via free public API (invoice is not sensitive — it is just a payment request)
const qrImg = document.getElementById('zap-qr-img');
if (qrImg && data.paymentRequest) {
const encoded = encodeURIComponent(data.paymentRequest.toUpperCase());
qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encoded}`;
qrImg.style.display = 'block';
document.getElementById('zap-qr-placeholder').style.display = 'none';
}
setZapStatus('⚡ Awaiting payment…', '#ffaa00');
startPolling();
}
async function simulatePayment() {
const hashSpan = document.getElementById('zap-hash');
const hash = hashSpan?.dataset.hash;
if (!hash) { setZapError('No payment hash found.'); return; }
const btn = document.getElementById('zap-simulate-btn');
if (btn) btn.disabled = true;
setZapStatus('Simulating payment…', '#ffaa00');
try {
const res = await fetch(`${API_BASE}/dev/stub/pay/${hash}`, { method: 'POST' });
const data = await res.json();
if (!data.ok) { setZapError('Simulation failed.'); return; }
// Polling will pick up the confirmed state
} catch (err) {
setZapError('Error: ' + err.message);
} finally {
if (btn) btn.disabled = false;
}
}
function startPolling() {
stopPolling();
const deadline = Date.now() + POLL_TIMEOUT_MS;
async function poll() {
if (!currentZapId) return;
try {
const res = await fetch(`${API_BASE}/zap/${currentZapId}`);
const data = await res.json();
if (data.paid) {
showCelebration(data.amountSats);
return;
}
} catch { /* keep polling */ }
if (Date.now() > deadline) {
setZapError('Invoice expired. Please try again.');
stopPolling();
return;
}
pollTimer = setTimeout(poll, POLL_INTERVAL_MS);
}
pollTimer = setTimeout(poll, POLL_INTERVAL_MS);
}
function stopPolling() {
clearTimeout(pollTimer);
pollTimer = null;
}
function showCelebration(amountSats) {
stopPolling();
setZapStep('done');
document.getElementById('zap-done-amount').textContent = amountSats + ' sats';
setZapStatus('⚡ Zap confirmed! Thank you!', '#00ff88');
if (onCelebrate) onCelebrate(amountSats);
}
/** Called by websocket.js when a zap_confirmed message arrives for the current zap. */
export function handleZapConfirmed(zapId) {
if (zapId && currentZapId && zapId !== currentZapId) return;
// Let the poll loop detect it naturally if panel is open,
// or trigger celebration immediately if we're still waiting.
if (currentZapId && panel?.classList.contains('open')) {
fetch(`${API_BASE}/zap/${currentZapId}`)
.then(r => r.json())
.then(d => { if (d.paid) showCelebration(d.amountSats); })
.catch(() => {});
}
}
function copyToClipboard(elId) {
const el = document.getElementById(elId);
if (!el) return;
navigator.clipboard.writeText(el.textContent).catch(() => {});
const btn = el.nextElementSibling;
if (btn) { btn.textContent = 'COPIED'; setTimeout(() => { btn.textContent = 'COPY'; }, 1500); }
}
window._zapCopy = copyToClipboard;