task-29: Timmy as economic peer — Nostr identity, zap-out, vouching, engagement

1. TimmyIdentityService (artifacts/api-server/src/lib/timmy-identity.ts)
   - Loads nsec from TIMMY_NOSTR_NSEC env var at boot (bech32 decode)
   - Generates and warns about ephemeral key if env var absent
   - sign(EventTemplate) → finalizeEvent() with Timmy's key
   - encryptDm(recipientPubkeyHex, plaintext) → NIP-04 nip04.encrypt()
   - Logs npub at server startup

2. ZapService (artifacts/api-server/src/lib/zap.ts)
   - Constructs NIP-57 zap request event (kind 9734), signs with Timmy's key
   - Pays via lnbitsService.payInvoice() if bolt11 provided (stub-mode aware)
   - Logs every outbound event to timmy_nostr_events audit table
   - maybeZapOnJobComplete() wired in jobs.ts after trustService.recordSuccess()
   - Config: ZAP_PCT_DEFAULT (default 0 = disabled), ZAP_MIN_SATS (default 10)
   - Only fires for trusted/elite tier partners when ZAP_PCT_DEFAULT > 0

3. Engagement engine (artifacts/api-server/src/lib/engagement.ts)
   - Configurable cadence: ENGAGEMENT_INTERVAL_DAYS (default 0 = disabled)
   - Queries nostrIdentities for trustScore >= 50 AND lastSeen < threshold
   - Generates personalised DM via agentService.chatReply()
   - Encrypts as NIP-04 DM (kind 4), signs with Timmy's key
   - Logs to timmy_nostr_events; publishes to NOSTR_RELAY_URL if set
   - First run delayed 60s after startup to avoid cold-start noise

4. Vouching endpoint (artifacts/api-server/src/routes/identity.ts)
   - POST /api/identity/vouch: requires X-Nostr-Token with elite tier
   - Verifies optional Nostr event signature from voucher
   - Records relationship in nostr_trust_vouches table
   - Applies VOUCH_TRUST_BOOST (20 pts) to vouchee's trust score
   - GET /api/identity/timmy: public endpoint returning npub + zap count

5. DB schema additions (lib/db/src/schema/)
   - timmy_nostr_events: audit log for all outbound Nostr events
   - nostr_trust_vouches: voucher/vouchee social graph with boost amount
   - Tables created in production DB via drizzle-kit push

6. Frontend identity card (the-matrix/)
   - #timmy-id-card: fixed bottom-right widget with Timmy's npub + zap count
   - timmy-id.js: initTimmyId() fetches /api/identity/timmy on load
   - Npub shortened (npub1xxxx...yyyyyy), click-to-copy with feedback
   - Refreshes every 60s to show live zap count
   - Wired into main.js on firstInit
This commit is contained in:
Replit Agent
2026-03-19 19:27:13 +00:00
parent dabadb4298
commit eb5dcfd48a
12 changed files with 649 additions and 1 deletions

View File

@@ -0,0 +1,140 @@
/**
* engagement.ts — Proactive engagement engine for Timmy.
*
* When enabled (ENGAGEMENT_INTERVAL_DAYS > 0), runs on a configurable
* schedule and sends a Nostr DM (NIP-04 encrypted, kind 4) to trusted
* partners who have been absent for ENGAGEMENT_ABSENT_DAYS days.
*
* Events are signed with Timmy's key and logged to timmy_nostr_events.
* Broadcasting to a Nostr relay requires NOSTR_RELAY_URL to be set.
*
* Configuration (env vars):
* ENGAGEMENT_INTERVAL_DAYS — how often to run the check (default 0 = disabled)
* ENGAGEMENT_ABSENT_DAYS — absence threshold to trigger a DM (default 7)
* NOSTR_RELAY_URL — relay URL; if set, events are POSTed there
*/
import { randomUUID } from "crypto";
import { db, nostrIdentities, timmyNostrEvents } from "@workspace/db";
import { and, gte, lt } from "drizzle-orm";
import { timmyIdentityService } from "./timmy-identity.js";
import { agentService } from "./agent.js";
import { makeLogger } from "./logger.js";
const logger = makeLogger("engagement");
function envInt(name: string, fallback: number): number {
const v = parseInt(process.env[name] ?? "", 10);
return Number.isFinite(v) && v >= 0 ? v : fallback;
}
const ENGAGEMENT_INTERVAL_DAYS = envInt("ENGAGEMENT_INTERVAL_DAYS", 0);
const ENGAGEMENT_ABSENT_DAYS = envInt("ENGAGEMENT_ABSENT_DAYS", 7);
const RELAY_URL = process.env["NOSTR_RELAY_URL"] ?? "";
const MIN_TIER_SCORES = { trusted: 50, elite: 200 } as const;
async function sendDm(recipientPubkey: string, plaintext: string): Promise<string> {
const ciphertext = await timmyIdentityService.encryptDm(recipientPubkey, plaintext);
const event = timmyIdentityService.sign({
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", recipientPubkey]],
content: ciphertext,
});
await db.insert(timmyNostrEvents).values({
id: randomUUID(),
kind: 4,
recipientPubkey,
eventJson: JSON.stringify(event),
amountSats: null,
relayUrl: RELAY_URL || null,
});
if (RELAY_URL) {
try {
const r = await fetch(RELAY_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(["EVENT", event]),
signal: AbortSignal.timeout(10_000),
});
if (!r.ok) {
logger.warn("relay rejected DM event", { status: r.status });
} else {
logger.info("DM event published to relay", { relay: RELAY_URL });
}
} catch (err) {
logger.warn("relay publish failed", { error: err instanceof Error ? err.message : String(err) });
}
}
return event.id;
}
async function runEngagementCycle(): Promise<void> {
logger.info("engagement cycle started", { absentDays: ENGAGEMENT_ABSENT_DAYS });
const threshold = new Date(Date.now() - ENGAGEMENT_ABSENT_DAYS * 24 * 60 * 60 * 1000);
const absentPartners = await db
.select({ pubkey: nostrIdentities.pubkey, trustScore: nostrIdentities.trustScore })
.from(nostrIdentities)
.where(
and(
gte(nostrIdentities.trustScore, MIN_TIER_SCORES.trusted),
lt(nostrIdentities.lastSeen, threshold),
),
)
.limit(20);
logger.info("absent trusted partners found", { count: absentPartners.length });
for (const partner of absentPartners) {
try {
const prompt =
`Write a short, friendly Nostr DM from Timmy (a wizard AI agent) to a trusted partner ` +
`who hasn't visited the workshop in ${ENGAGEMENT_ABSENT_DAYS}+ days. ` +
`Keep it under 120 characters, warm but not pushy. No emoji overload.`;
const message = await agentService.chatReply(prompt);
const eventId = await sendDm(partner.pubkey, message);
logger.info("proactive DM sent", {
to: partner.pubkey.slice(0, 8),
eventId,
});
} catch (err) {
logger.warn("proactive DM failed for partner", {
pubkey: partner.pubkey.slice(0, 8),
error: err instanceof Error ? err.message : String(err),
});
}
}
logger.info("engagement cycle complete", { processed: absentPartners.length });
}
export function startEngagementEngine(): void {
if (ENGAGEMENT_INTERVAL_DAYS <= 0) {
logger.info("proactive engagement disabled (set ENGAGEMENT_INTERVAL_DAYS > 0 to enable)");
return;
}
const intervalMs = ENGAGEMENT_INTERVAL_DAYS * 24 * 60 * 60 * 1000;
logger.info("proactive engagement engine started", {
intervalDays: ENGAGEMENT_INTERVAL_DAYS,
absentDays: ENGAGEMENT_ABSENT_DAYS,
relay: RELAY_URL || "none",
});
const run = () => {
runEngagementCycle().catch(err => {
logger.error("engagement cycle error", { error: err instanceof Error ? err.message : String(err) });
});
};
setTimeout(run, 60_000);
setInterval(run, intervalMs);
}

View File

@@ -0,0 +1,77 @@
/**
* timmy-identity.ts — Timmy's persistent Nostr identity.
*
* Loads a secp256k1 keypair from TIMMY_NOSTR_NSEC env var at boot.
* If the env var is absent, generates an ephemeral keypair and logs
* a warning instructing the operator to persist it.
*
* Exports `timmyIdentityService` singleton with:
* .npub — bech32 public key (npub1...)
* .nsec — bech32 private key (nsec1...) — never log this
* .pubkeyHex — raw 64-char hex pubkey
* .sign(template) — finalises and signs a Nostr event template
* .encryptDm(recipientPubkeyHex, plaintext) — NIP-04 encrypt
*/
import { generateSecretKey, getPublicKey, finalizeEvent, nip19, nip04 } from "nostr-tools";
import type { EventTemplate, NostrEvent } from "nostr-tools";
import { makeLogger } from "./logger.js";
const logger = makeLogger("timmy-identity");
function loadOrGenerate(): { sk: Uint8Array; nsec: string; npub: string; pubkeyHex: string } {
const raw = process.env["TIMMY_NOSTR_NSEC"];
if (raw && raw.startsWith("nsec1")) {
try {
const decoded = nip19.decode(raw);
if (decoded.type !== "nsec") throw new Error("not nsec type");
const sk = decoded.data as Uint8Array;
const pubkeyHex = getPublicKey(sk);
const npub = nip19.npubEncode(pubkeyHex);
logger.info("timmy identity loaded from env", { npub });
return { sk, nsec: raw, npub, pubkeyHex };
} catch (err) {
logger.warn("TIMMY_NOSTR_NSEC is set but could not be decoded — generating ephemeral key", {
error: err instanceof Error ? err.message : String(err),
});
}
}
const sk = generateSecretKey();
const pubkeyHex = getPublicKey(sk);
const nsec = nip19.nsecEncode(sk);
const npub = nip19.npubEncode(pubkeyHex);
logger.warn(
"TIMMY_NOSTR_NSEC not set — generated ephemeral Nostr identity. " +
"Set this env var permanently to preserve Timmy's identity across restarts.",
{ npub },
);
return { sk, nsec, npub, pubkeyHex };
}
class TimmyIdentityService {
readonly npub: string;
readonly nsec: string;
readonly pubkeyHex: string;
private readonly _sk: Uint8Array;
constructor() {
const identity = loadOrGenerate();
this.npub = identity.npub;
this.nsec = identity.nsec;
this.pubkeyHex = identity.pubkeyHex;
this._sk = identity.sk;
}
sign(template: EventTemplate): NostrEvent {
return finalizeEvent(template, this._sk);
}
async encryptDm(recipientPubkeyHex: string, plaintext: string): Promise<string> {
return nip04.encrypt(this._sk, recipientPubkeyHex, plaintext);
}
}
export const timmyIdentityService = new TimmyIdentityService();

View File

@@ -0,0 +1,149 @@
/**
* zap.ts — NIP-57 zap-out capability for Timmy.
*
* ZapService constructs and signs a NIP-57 zap request event with Timmy's key,
* optionally pays a BOLT11 invoice via LNbits, and logs the outbound event to
* the `timmy_nostr_events` audit table.
*
* Configuration (env vars):
* ZAP_PCT_DEFAULT — percentage of work fee to zap back (default 0 = disabled)
* ZAP_MIN_SATS — minimum sats to trigger a zap (default 10)
* NOSTR_RELAY_URL — relay URL embedded in the zap request tags (optional)
*/
import { randomUUID } from "crypto";
import { db, timmyNostrEvents } from "@workspace/db";
import { timmyIdentityService } from "./timmy-identity.js";
import { lnbitsService } from "./lnbits.js";
import { makeLogger } from "./logger.js";
const logger = makeLogger("zap");
function envInt(name: string, fallback: number): number {
const v = parseInt(process.env[name] ?? "", 10);
return Number.isFinite(v) && v >= 0 ? v : fallback;
}
const ZAP_PCT_DEFAULT = envInt("ZAP_PCT_DEFAULT", 0);
const ZAP_MIN_SATS = envInt("ZAP_MIN_SATS", 10);
const RELAY_URL = process.env["NOSTR_RELAY_URL"] ?? "";
export interface ZapRequest {
recipientPubkey: string;
amountSats: number;
bolt11?: string;
message?: string;
}
export interface ZapResult {
skipped: boolean;
reason?: string;
eventId?: string;
paymentHash?: string;
}
class ZapService {
/** Calculate the zap amount for a given work fee, returns 0 if disabled. */
zapAmountSats(workFeeSats: number, pct: number = ZAP_PCT_DEFAULT): number {
if (pct <= 0) return 0;
return Math.floor(workFeeSats * pct / 100);
}
/**
* Send a zap from Timmy to a trusted partner.
*
* If bolt11 is provided, pays the invoice via LNbits.
* Event is always logged to timmy_nostr_events for auditing.
*/
async sendZap(req: ZapRequest): Promise<ZapResult> {
const { recipientPubkey, amountSats, bolt11, message } = req;
if (amountSats < ZAP_MIN_SATS) {
return { skipped: true, reason: `amount ${amountSats} < ZAP_MIN_SATS ${ZAP_MIN_SATS}` };
}
const amountMsats = amountSats * 1000;
const tags: string[][] = [
["p", recipientPubkey],
["amount", amountMsats.toString()],
];
if (RELAY_URL) {
tags.push(["relays", RELAY_URL]);
}
const zapRequestEvent = timmyIdentityService.sign({
kind: 9734,
created_at: Math.floor(Date.now() / 1000),
tags,
content: message ?? "⚡ zap from Timmy",
});
let paymentHash: string | undefined;
if (bolt11 && !lnbitsService.stubMode) {
try {
paymentHash = await lnbitsService.payInvoice(bolt11);
logger.info("zap payment sent", { amountSats, paymentHash, recipientPubkey: recipientPubkey.slice(0, 8) });
} catch (err) {
logger.warn("zap payment failed — event logged anyway", {
error: err instanceof Error ? err.message : String(err),
});
}
} else if (lnbitsService.stubMode) {
paymentHash = "stub_" + zapRequestEvent.id.slice(0, 16);
logger.info("zap stub payment simulated", { amountSats, paymentHash });
}
await db.insert(timmyNostrEvents).values({
id: randomUUID(),
kind: 9734,
recipientPubkey,
eventJson: JSON.stringify(zapRequestEvent),
amountSats,
relayUrl: RELAY_URL || null,
});
logger.info("zap event logged", {
eventId: zapRequestEvent.id,
recipientPubkey: recipientPubkey.slice(0, 8),
amountSats,
});
return { skipped: false, eventId: zapRequestEvent.id, paymentHash };
}
/**
* Called from the job completion path.
* Only fires if trust tier warrants it and ZAP_PCT_DEFAULT > 0.
*/
async maybeZapOnJobComplete(opts: {
pubkey: string | null;
workFeeSats: number;
tier: string;
}): Promise<void> {
const { pubkey, workFeeSats, tier } = opts;
if (!pubkey) return;
if (ZAP_PCT_DEFAULT <= 0) return;
if (tier !== "trusted" && tier !== "elite") return;
const amount = this.zapAmountSats(workFeeSats);
if (amount < ZAP_MIN_SATS) return;
const result = await this.sendZap({
recipientPubkey: pubkey,
amountSats: amount,
message: "Thanks for being a trusted partner! ⚡",
}).catch(err => {
logger.warn("zapOnJobComplete failed silently", { error: err instanceof Error ? err.message : String(err) });
return { skipped: true, reason: "error" } as ZapResult;
});
if (!result.skipped) {
logger.info("zap sent on job complete", { pubkey: pubkey.slice(0, 8), amount, tier });
}
}
}
export const zapService = new ZapService();