diff --git a/artifacts/api-server/src/lib/agent.ts b/artifacts/api-server/src/lib/agent.ts index 13939bb..3725cff 100644 --- a/artifacts/api-server/src/lib/agent.ts +++ b/artifacts/api-server/src/lib/agent.ts @@ -376,6 +376,62 @@ Respond ONLY with valid JSON: {"accepted": true/false, "reason": "..."}`, outputTokens: totalOutput, }; } + + /** + * Generate a privacy-safe 1-3 sentence public summary of a completed job, + * suitable for broadcast as a Nostr kind:1 note. + * Also returns a short category tag (slug, lowercase, no spaces). + * + * In stub mode returns a canned response so the Nostr publish path is + * exercised end-to-end without a real Anthropic key. + */ + async generateJobSummary( + request: string, + result: string, + ): Promise<{ summary: string; category: string }> { + if (STUB_MODE) { + return { + summary: "Timmy completed a task in the Workshop. The work is done — open, verifiable, on Nostr.", + category: "general", + }; + } + + const client = await getClient(); + const message = await client.messages.create({ + model: this.evalModel, // Haiku — cheap and fast + max_tokens: 256, + system: `You are Timmy, a whimsical AI wizard. Write a brief, public-safe summary of the work you just completed. +Rules: +- 1-3 sentences maximum. +- Do NOT reveal private details, personal information, passwords, keys, or sensitive data from the request or result. +- Write in first person as Timmy. +- Also provide a single short category slug (lowercase, hyphens only, 1-3 words) describing the type of work. +- Respond ONLY with valid JSON: {"summary": "...", "category": "..."}`, + messages: [ + { + role: "user", + content: `Request: ${request.slice(0, 400)}\n\nResult preview: ${result.slice(0, 400)}`, + }, + ], + }); + + const block = message.content[0]; + if (block?.type !== "text") { + return { summary: "Timmy completed a task in the Workshop.", category: "general" }; + } + + try { + const raw = block.text!.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim(); + const parsed = JSON.parse(raw) as { summary?: string; category?: string }; + return { + summary: parsed.summary?.trim() || "Timmy completed a task in the Workshop.", + category: (parsed.category?.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-") || "general"), + }; + } catch { + logger.warn("generateJobSummary: failed to parse JSON response", { text: block.text }); + return { summary: "Timmy completed a task in the Workshop.", category: "general" }; + } + } } export const agentService = new AgentService(); diff --git a/artifacts/api-server/src/lib/nostr-publish.ts b/artifacts/api-server/src/lib/nostr-publish.ts new file mode 100644 index 0000000..5db94fa --- /dev/null +++ b/artifacts/api-server/src/lib/nostr-publish.ts @@ -0,0 +1,174 @@ +/** + * nostr-publish.ts — Publish Timmy's job-completion notes as Nostr kind:1 events. + * + * On job completion Timmy signs a kind:1 note summarising what he did and + * broadcasts it to: + * 1. The Hermes strfry relay (HTTP import — always attempted). + * 2. wss://relay.damus.io via NIP-01 WebSocket (optional, fire-and-forget). + * + * Config env vars: + * NOSTR_EXTERNAL_RELAY_URL — external relay WebSocket URL + * (default "wss://relay.damus.io", set to "" to disable) + * + * Failed relay publishes are non-blocking and only produce a warning log. + * The event is always logged to timmy_nostr_events for auditing. + */ + +import { randomUUID } from "crypto"; +import WebSocket from "ws"; +import { db, timmyNostrEvents } from "@workspace/db"; +import { timmyIdentityService } from "./timmy-identity.js"; +import { injectEvent } from "./strfry.js"; +import { makeLogger } from "./logger.js"; + +const logger = makeLogger("nostr-publish"); + +const EXTERNAL_RELAY_URL = process.env["NOSTR_EXTERNAL_RELAY_URL"] ?? "wss://relay.damus.io"; +const RELAY_TIMEOUT_MS = 8_000; + +/** + * Publish a signed Nostr event to a WebSocket relay (NIP-01). + * Resolves true on success (OK received), false on any failure. + * Never throws. + */ +async function publishViaWebSocket(relayUrl: string, eventJson: string): Promise { + return new Promise((resolve) => { + let settled = false; + + const settle = (ok: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(ok); + }; + + const timer = setTimeout(() => { + ws.terminate(); + logger.warn("nostr-publish: relay timeout", { relay: relayUrl }); + settle(false); + }, RELAY_TIMEOUT_MS); + + let ws: WebSocket; + try { + ws = new WebSocket(relayUrl); + } catch (err) { + clearTimeout(timer); + logger.warn("nostr-publish: WebSocket construction failed", { + relay: relayUrl, + error: err instanceof Error ? err.message : String(err), + }); + resolve(false); + return; + } + + ws.on("open", () => { + const event = JSON.parse(eventJson) as Record; + ws.send(JSON.stringify(["EVENT", event])); + }); + + ws.on("message", (data: Buffer | string) => { + try { + const msg = JSON.parse(data.toString()) as unknown; + if (Array.isArray(msg) && msg[0] === "OK") { + const accepted = msg[2] !== false; + if (!accepted) { + logger.warn("nostr-publish: relay rejected event", { relay: relayUrl, reason: msg[3] }); + } + ws.close(); + settle(accepted); + } + } catch { + // non-JSON or irrelevant message — keep waiting + } + }); + + ws.on("error", (err: Error) => { + logger.warn("nostr-publish: WebSocket error", { relay: relayUrl, error: err.message }); + settle(false); + }); + + ws.on("close", () => settle(false)); + }); +} + +export interface PublishJobNoteResult { + /** Full 64-char hex event ID. */ + eventId: string; + /** Whether strfry HTTP inject succeeded. */ + strfryOk: boolean; + /** Whether the external WebSocket relay accepted the event. */ + externalRelayOk: boolean; + /** Convenience link: https://njump.me/ */ + njumpUrl: string; +} + +/** + * Sign and publish a kind:1 Nostr note summarising a completed job. + * + * @param opts.jobId Job ID (used as back-link in the DB log row). + * @param opts.summary 1-3 sentence public summary of the completed work. + * @param opts.category Optional job category string appended as a `t` tag. + */ +export async function publishJobCompletionNote(opts: { + jobId: string; + summary: string; + category?: string; +}): Promise { + const { jobId, summary, category } = opts; + + const tags: string[][] = [ + ["t", "timmy-tower"], + ["r", "https://alexanderwhitestone.com"], + ]; + + if (category) { + tags.push(["t", category]); + } + + const signedEvent = timmyIdentityService.sign({ + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags, + content: summary, + }); + + const eventJson = JSON.stringify(signedEvent); + const njumpUrl = `https://njump.me/${signedEvent.id}`; + + // ── Publish to strfry (HTTP inject) ───────────────────────────────────── + const strfryResult = await injectEvent(eventJson).catch((err: unknown) => { + logger.warn("nostr-publish: strfry inject error", { + error: err instanceof Error ? err.message : String(err), + }); + return { ok: false }; + }); + + // ── Publish to external relay (WebSocket, fire-and-forget) ─────────────── + let externalRelayOk = false; + if (EXTERNAL_RELAY_URL) { + externalRelayOk = await publishViaWebSocket(EXTERNAL_RELAY_URL, eventJson); + } + + // ── Audit log ───────────────────────────────────────────────────────────── + await db.insert(timmyNostrEvents).values({ + id: randomUUID(), + kind: 1, + eventJson, + relayUrl: EXTERNAL_RELAY_URL || null, + jobId, + }).catch((err: unknown) => { + logger.warn("nostr-publish: failed to write audit log", { + error: err instanceof Error ? err.message : String(err), + }); + }); + + logger.info("nostr-publish: kind:1 published", { + eventId: signedEvent.id.slice(0, 8), + jobId, + strfryOk: strfryResult.ok, + externalRelayOk, + njumpUrl, + }); + + return { eventId: signedEvent.id, strfryOk: strfryResult.ok, externalRelayOk, njumpUrl }; +} diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index 341ba68..5626b51 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -14,6 +14,7 @@ import { latencyHistogram } from "../lib/histogram.js"; import { trustService } from "../lib/trust.js"; import { freeTierService } from "../lib/free-tier.js"; import { zapService } from "../lib/zap.js"; +import { publishJobCompletionNote } from "../lib/nostr-publish.js"; const logger = makeLogger("jobs"); @@ -299,6 +300,38 @@ async function runWorkInBackground( }) .where(eq(jobs.id, jobId)); + // ── Nostr job-completion broadcast (Issue #13) ────────────────────────── + // Fire-and-forget: generate a privacy-safe summary and publish as kind:1. + // Failure is non-blocking — we do not let this affect job state. + void (async () => { + try { + const { summary, category } = await agentService.generateJobSummary( + request, + workResult.result, + ); + const nostrResult = await publishJobCompletionNote({ jobId, summary, category }); + + // Persist event ID on the job row and log the Tower Log entry. + await db + .update(jobs) + .set({ nostrEventId: nostrResult.eventId, updatedAt: new Date() }) + .where(eq(jobs.id, jobId)); + + logger.info("nostr broadcast complete", { + jobId, + nostrEventId: nostrResult.eventId.slice(0, 8), + njumpUrl: nostrResult.njumpUrl, + strfryOk: nostrResult.strfryOk, + externalRelayOk: nostrResult.externalRelayOk, + }); + } catch (err) { + logger.warn("nostr broadcast failed (non-blocking)", { + jobId, + error: err instanceof Error ? err.message : String(err), + }); + } + })(); + logger.info("work completed", { jobId, isFree, diff --git a/lib/db/migrations/0009_nostr_job_completions.sql b/lib/db/migrations/0009_nostr_job_completions.sql new file mode 100644 index 0000000..eb705c0 --- /dev/null +++ b/lib/db/migrations/0009_nostr_job_completions.sql @@ -0,0 +1,9 @@ +-- Migration: Nostr job-completion publishing (Issue #13) +-- Adds nostr_event_id to jobs (the published kind:1 event ID) and +-- job_id to timmy_nostr_events (back-link to the originating job). + +ALTER TABLE jobs + ADD COLUMN IF NOT EXISTS nostr_event_id TEXT; + +ALTER TABLE timmy_nostr_events + ADD COLUMN IF NOT EXISTS job_id TEXT; diff --git a/lib/db/src/schema/jobs.ts b/lib/db/src/schema/jobs.ts index b5538c0..9b7d35b 100644 --- a/lib/db/src/schema/jobs.ts +++ b/lib/db/src/schema/jobs.ts @@ -52,6 +52,10 @@ export const jobs = pgTable("jobs", { refundState: text("refund_state").$type<"not_applicable" | "pending" | "paid">(), refundPaymentHash: text("refund_payment_hash"), + // ── Nostr job-completion broadcast (Issue #13) ─────────────────────────── + // Populated after Timmy publishes a kind:1 note summarising the completed job. + nostrEventId: text("nostr_event_id"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }); diff --git a/lib/db/src/schema/timmy-nostr-events.ts b/lib/db/src/schema/timmy-nostr-events.ts index 59bd9d5..534f614 100644 --- a/lib/db/src/schema/timmy-nostr-events.ts +++ b/lib/db/src/schema/timmy-nostr-events.ts @@ -13,6 +13,9 @@ export const timmyNostrEvents = pgTable("timmy_nostr_events", { relayUrl: text("relay_url"), + // Link back to the job that triggered this event (kind:1 job-completion notes) + jobId: text("job_id"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), });