From c5f659eb0ee9eeb1a1328c00a67c537583da358a Mon Sep 17 00:00:00 2001 From: Lemon Date: Mon, 6 Apr 2026 16:20:31 -0700 Subject: [PATCH] Add publish_events and fetch_event tools ported from Shakespeare publish_events: Publishes Nostr events signed by the buddy's key (falls back to ephemeral when no buddy). Supports any event kind with content and tags. Returns published events for inline display. The agent only publishes when explicitly instructed by the user. fetch_event: Fetches Nostr events by NIP-19 identifier (npub, note, nevent, naddr, nprofile). Uses the existing nostr pool connection. Allows the agent to read content the user references in chat. Both tools adapted from Shakespeare's implementations to fit Ditto's hook-based tool pattern. System prompt updated with documentation for both new tools. --- src/hooks/useAIChatTools.ts | 142 +++++++++++++++++++++++++++++++++- src/lib/aiChatSystemPrompt.ts | 30 ++++++- src/lib/aiChatTools.ts | 57 +++++++++++++- 3 files changed, 226 insertions(+), 3 deletions(-) diff --git a/src/hooks/useAIChatTools.ts b/src/hooks/useAIChatTools.ts index 2a740105..c48cbb0d 100644 --- a/src/hooks/useAIChatTools.ts +++ b/src/hooks/useAIChatTools.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools'; +import { generateSecretKey, getPublicKey, finalizeEvent, nip19 } from 'nostr-tools'; import { useNostr } from '@nostrify/react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -579,6 +579,146 @@ export function useAIChatTools() { } } + case 'publish_events': { + try { + const events = Array.isArray(args.events) + ? args.events as Array<{ kind?: number; content?: string; tags?: string[][] }> + : []; + if (events.length === 0) { + return { result: JSON.stringify({ error: 'At least one event is required.' }) }; + } + + // Use buddy key if available, otherwise ephemeral + const buddySk = getBuddySecretKey(); + const sk = buddySk ?? generateSecretKey(); + const pubkey = getPublicKey(sk); + const currentTimestamp = Math.floor(Date.now() / 1000); + + const finalized: NostrEvent[] = events.map((partial) => + finalizeEvent({ + kind: partial.kind ?? 1, + content: partial.content ?? '', + tags: partial.tags ?? [], + created_at: currentTimestamp, + }, sk) as NostrEvent, + ); + + // If ephemeral (no buddy), publish a fallback profile + if (!buddySk) { + const profileEvent = finalizeEvent({ + kind: 0, + content: JSON.stringify({ name: 'Dork Publisher', about: 'Events published by Dork AI' }), + tags: [], + created_at: currentTimestamp, + }, sk) as NostrEvent; + await nostr.event(profileEvent, { signal: AbortSignal.timeout(5000) }); + } + + await Promise.all( + finalized.map((event) => nostr.event(event, { signal: AbortSignal.timeout(5000) })), + ); + + // Return the first event for inline display if it's a kind 1 + const displayEvent = finalized.find((e) => e.kind === 1) ?? finalized[0]; + + return { + result: JSON.stringify({ + success: true, + pubkey, + events_published: finalized.length, + events: finalized.map((e) => ({ + id: e.id, + kind: e.kind, + content: e.content.length > 100 ? `${e.content.slice(0, 100)}...` : e.content, + })), + }), + nostrEvent: displayEvent, + }; + } catch (err) { + return { result: JSON.stringify({ error: `Failed to publish events: ${err instanceof Error ? err.message : 'Unknown error'}` }) }; + } + } + + case 'fetch_event': { + try { + const identifier = typeof args.identifier === 'string' ? args.identifier.trim() : ''; + if (!identifier) { + return { result: JSON.stringify({ error: 'A NIP-19 identifier is required.' }) }; + } + + let decoded: nip19.DecodedResult; + try { + decoded = nip19.decode(identifier); + } catch { + return { result: JSON.stringify({ error: `Invalid NIP-19 identifier: ${identifier}` }) }; + } + + if (decoded.type === 'nsec') { + return { result: JSON.stringify({ error: 'nsec identifiers are not supported for security reasons.' }) }; + } + + let event: NostrEvent | undefined; + + switch (decoded.type) { + case 'npub': { + const events = await nostr.query( + [{ kinds: [0], authors: [decoded.data], limit: 1 }], + { signal: AbortSignal.timeout(8000) }, + ); + event = events[0]; + break; + } + case 'nprofile': { + const events = await nostr.query( + [{ kinds: [0], authors: [decoded.data.pubkey], limit: 1 }], + { signal: AbortSignal.timeout(8000) }, + ); + event = events[0]; + break; + } + case 'note': { + const events = await nostr.query( + [{ ids: [decoded.data] }], + { signal: AbortSignal.timeout(8000) }, + ); + event = events[0]; + break; + } + case 'nevent': { + const events = await nostr.query( + [{ ids: [decoded.data.id] }], + { signal: AbortSignal.timeout(8000) }, + ); + event = events[0]; + break; + } + case 'naddr': { + const events = await nostr.query( + [{ + kinds: [decoded.data.kind], + authors: [decoded.data.pubkey], + '#d': [decoded.data.identifier], + limit: 1, + }], + { signal: AbortSignal.timeout(8000) }, + ); + event = events[0]; + break; + } + default: + return { result: JSON.stringify({ error: `Unsupported identifier type: ${(decoded as { type: string }).type}` }) }; + } + + if (!event) { + return { result: JSON.stringify({ error: 'No event found for the provided identifier.' }) }; + } + + return { result: JSON.stringify(event) }; + } catch (err) { + return { result: JSON.stringify({ error: `Failed to fetch event: ${err instanceof Error ? err.message : 'Unknown error'}` }) }; + } + } + default: return { result: JSON.stringify({ error: `Unknown tool: ${name}` }) }; } diff --git a/src/lib/aiChatSystemPrompt.ts b/src/lib/aiChatSystemPrompt.ts index a883ea08..7c14bc67 100644 --- a/src/lib/aiChatSystemPrompt.ts +++ b/src/lib/aiChatSystemPrompt.ts @@ -117,7 +117,35 @@ Publishes a NIP-30 custom emoji pack (kind 30030) as the logged-in user. Takes a 2. upload_from_url(image_urls) → upload to Blossom, get URLs + shortcodes 3. create_emoji_pack(name, emojis) → publish the pack -When uploading emojis, use clean shortcodes. Strip file extensions, replace special characters with hyphens. If the user doesn't specify a pack name, derive one from the page title or context.`; +When uploading emojis, use clean shortcodes. Strip file extensions, replace special characters with hyphens. If the user doesn't specify a pack name, derive one from the page title or context. + +## publish_events +Publishes one or more Nostr events signed by your identity. Each event can specify a kind, content, and tags. Use this when the user asks you to post, publish, or broadcast something to Nostr. + +**Common kinds:** +- 1 = text note (put post text in content) +- 7 = reaction (content is "+" or an emoji, add an "e" tag referencing the target event) +- 6 = repost (content is the JSON of the reposted event, add an "e" tag) + +**Tag format:** Arrays of strings, e.g. \`[["t", "nostr"], ["p", ""]]\` + +**Examples:** +- Post a note: \`{ events: [{ content: "Hello Nostr!" }] }\` +- Post with hashtags: \`{ events: [{ content: "Building on Nostr", tags: [["t", "nostr"], ["t", "dev"]] }] }\` + +Only publish events when the user explicitly asks you to. Never publish autonomously. + +## fetch_event +Fetches a Nostr event by its NIP-19 identifier. Use this when the user shares a Nostr link or identifier and you need to read its content. + +**Supported identifiers:** +- npub1... → fetches the user's kind 0 profile +- note1... → fetches a specific event by ID +- nevent1... → fetches an event (may include relay hints) +- naddr1... → fetches an addressable event by kind+author+d-tag +- nprofile1... → fetches a user profile with relay hints + +Returns the full event JSON. For profiles (kind 0), the content field contains JSON metadata (name, about, picture, etc.).`; /** The raw default template with {{NAME}} and {{SOUL}} placeholders (for display in settings). */ export const DEFAULT_SYSTEM_PROMPT_TEMPLATE = DEFAULT_TEMPLATE; diff --git a/src/lib/aiChatTools.ts b/src/lib/aiChatTools.ts index 9c19e1ff..8ce0fd6f 100644 --- a/src/lib/aiChatTools.ts +++ b/src/lib/aiChatTools.ts @@ -304,6 +304,61 @@ After publishing, the emoji pack appears in the user's feed and can be added to }, }, }, + { + type: 'function' as const, + function: { + name: 'publish_events', + description: `Publish one or more Nostr events signed by your identity. Each event can specify a kind, content, and tags. Defaults: kind 1 (text note), empty content, empty tags, current timestamp. + +Common kinds: 1 = text note, 6 = repost, 7 = reaction (content is "+" or emoji), 30023 = long-form article. + +For text notes (kind 1), put the post text in content. For reactions (kind 7), set content to "+" or an emoji and add an "e" tag referencing the target event. + +Tags are arrays of strings, e.g. [["t", "nostr"], ["p", ""]] for a hashtag and a mention.`, + parameters: { + type: 'object' as const, + properties: { + events: { + type: 'array', + items: { + type: 'object', + properties: { + kind: { type: 'number', description: 'Event kind number (default: 1).' }, + content: { type: 'string', description: 'Event content (default: empty string).' }, + tags: { + type: 'array', + items: { type: 'array', items: { type: 'string' } }, + description: 'Event tags (default: empty array).', + }, + }, + }, + description: 'Array of events to publish.', + }, + }, + required: ['events'], + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'fetch_event', + description: `Fetch a Nostr event by its NIP-19 identifier. Supports npub (fetches kind 0 profile), nprofile, note (fetches event by ID), nevent, and naddr (fetches addressable event by kind+author+d-tag). + +Use this when the user shares a Nostr identifier and you need to read its content — for example, to see what a note says, look up a user's profile, or read an article. + +Returns the full event JSON including kind, content, tags, pubkey, and timestamp.`, + parameters: { + type: 'object' as const, + properties: { + identifier: { + type: 'string', + description: 'NIP-19 identifier (npub1..., note1..., nevent1..., naddr1..., nprofile1...).', + }, + }, + required: ['identifier'], + }, + }, + }, ]; -