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:
@@ -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();
|
||||
|
||||
174
artifacts/api-server/src/lib/nostr-publish.ts
Normal file
174
artifacts/api-server/src/lib/nostr-publish.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
9
lib/db/migrations/0009_nostr_job_completions.sql
Normal file
9
lib/db/migrations/0009_nostr_job_completions.sql
Normal 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;
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user