feat: publish job completions as Nostr kind:1 events (#13)

On job completion, Timmy generates a privacy-safe 1-3 sentence summary
(via Haiku) and broadcasts it as a signed Nostr kind:1 note.

- New `nostr-publish.ts` utility: signs event with Timmy's nsec,
  publishes to Hermes strfry (HTTP inject) and optionally to an
  external relay (wss://relay.damus.io) via NIP-01 WebSocket.
  Failed publishes are non-blocking (warn + continue).
- `AgentService.generateJobSummary()`: Haiku call producing a public-safe
  summary and category slug; falls back to canned text in stub mode.
- `runWorkInBackground` fires the broadcast fire-and-forget after the
  job DB row is committed; persists the event ID on `jobs.nostr_event_id`.
- Tags: `["t","timmy-tower"]`, job category, link to alexanderwhitestone.com.
- Tower Log includes nostrEventId + njump.me link in the completion log entry.
- DB schema: `nostr_event_id` on `jobs`, `job_id` on `timmy_nostr_events`.
- Migration: `0009_nostr_job_completions.sql`.

Fixes #13

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-23 16:26:39 -04:00
parent 3843e749a3
commit 65693b1569
6 changed files with 279 additions and 0 deletions

View File

@@ -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();

View File

@@ -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<boolean> {
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<string, unknown>;
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/<eventId> */
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<PublishJobNoteResult> {
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 };
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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(),
});

View File

@@ -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(),
});