nostr-bray 1.21.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/format.d.ts CHANGED
@@ -10,8 +10,16 @@ export declare function formatContacts(contacts: Array<{
10
10
  relay?: string;
11
11
  petname?: string;
12
12
  }>): string;
13
+ export declare function formatContactSearch(contacts: Array<{
14
+ pubkey: string;
15
+ name?: string;
16
+ displayName?: string;
17
+ nip05?: string;
18
+ petname?: string;
19
+ }>): string;
13
20
  export declare function formatNotifications(notifications: any[]): string;
14
21
  export declare function formatFeed(events: any[]): string;
22
+ export declare function formatConversation(dms: any[]): string;
15
23
  export declare function formatDms(dms: any[]): string;
16
24
  export declare function formatRelays(relays: {
17
25
  read: string[];
package/dist/format.js CHANGED
@@ -58,6 +58,23 @@ export function formatContacts(contacts) {
58
58
  });
59
59
  return `${contacts.length} contacts:\n${lines.join('\n')}`;
60
60
  }
61
+ export function formatContactSearch(contacts) {
62
+ if (contacts.length === 0)
63
+ return 'No matching contacts.';
64
+ const lines = contacts.map(c => {
65
+ const label = c.displayName || c.name || c.petname || c.pubkey.slice(0, 12) + '...';
66
+ const parts = [label];
67
+ if (c.name && c.displayName && c.name !== c.displayName)
68
+ parts.push(`(@${c.name})`);
69
+ else if (c.name && !c.displayName)
70
+ parts.push(`(@${c.name})`);
71
+ if (c.nip05)
72
+ parts.push(`[${c.nip05}]`);
73
+ parts.push(c.pubkey.slice(0, 12) + '...');
74
+ return parts.join(' ');
75
+ });
76
+ return `${contacts.length} match${contacts.length !== 1 ? 'es' : ''}:\n${lines.join('\n')}`;
77
+ }
61
78
  export function formatNotifications(notifications) {
62
79
  if (notifications.length === 0)
63
80
  return 'No notifications.';
@@ -88,6 +105,17 @@ export function formatFeed(events) {
88
105
  return `${author} ${time}\n ${e.content?.slice(0, 200)}\n`;
89
106
  }).join('\n');
90
107
  }
108
+ export function formatConversation(dms) {
109
+ if (dms.length === 0)
110
+ return 'No messages in this conversation.';
111
+ return dms.map(dm => {
112
+ const time = new Date(dm.createdAt * 1000).toLocaleString();
113
+ const from = dm.from?.slice(0, 12) + '...';
114
+ if (!dm.decrypted)
115
+ return `✗ ${from} Could not decrypt (${time})`;
116
+ return `${from} ${time}\n ${dm.content}`;
117
+ }).join('\n\n');
118
+ }
91
119
  export function formatDms(dms) {
92
120
  if (dms.length === 0)
93
121
  return 'No messages.';
@@ -26,6 +26,8 @@ export declare class RelayPool {
26
26
  getRelays(npub: string): RelaySet;
27
27
  /** Publish event to write relays for the given identity */
28
28
  publish(npub: string, event: NostrEvent): Promise<PublishResult>;
29
+ /** Publish event to explicit relay URLs (not identity-bound) */
30
+ publishDirect(relays: string[], event: NostrEvent): Promise<PublishResult>;
29
31
  /** One-shot query from read relays for the given identity */
30
32
  query(npub: string, filter: Filter): Promise<NostrEvent[]>;
31
33
  /** One-shot query against explicit relay URLs (not identity-bound) */
@@ -97,6 +97,35 @@ export class RelayPool {
97
97
  errors,
98
98
  };
99
99
  }
100
+ /** Publish event to explicit relay URLs (not identity-bound) */
101
+ async publishDirect(relays, event) {
102
+ const pool = await this.poolReady;
103
+ if (relays.length === 0) {
104
+ return { success: false, accepted: [], rejected: [], errors: ['no relays specified'] };
105
+ }
106
+ const promises = pool.publish(relays, event);
107
+ const accepted = [];
108
+ const rejected = [];
109
+ const errors = [];
110
+ const results = await Promise.allSettled(promises);
111
+ for (let i = 0; i < results.length; i++) {
112
+ const result = results[i];
113
+ const url = relays[i] ?? `relay-${i}`;
114
+ if (result.status === 'fulfilled') {
115
+ accepted.push(url);
116
+ }
117
+ else {
118
+ rejected.push(url);
119
+ errors.push(`${url}: ${result.reason}`);
120
+ }
121
+ }
122
+ return {
123
+ success: accepted.length === relays.length,
124
+ accepted,
125
+ rejected,
126
+ errors,
127
+ };
128
+ }
100
129
  /** One-shot query from read relays for the given identity */
101
130
  async query(npub, filter) {
102
131
  const pool = await this.poolReady;
@@ -1,11 +1,14 @@
1
1
  import type { Event as NostrEvent } from 'nostr-tools';
2
2
  import type { IdentityContext } from '../context.js';
3
3
  import type { RelayPool } from '../relay-pool.js';
4
+ import type { Nip65Manager } from '../nip65.js';
4
5
  import type { PublishResult } from '../types.js';
5
6
  export interface DmSendResult {
6
7
  event: NostrEvent;
8
+ senderCopy?: NostrEvent;
7
9
  protocol: 'nip17' | 'nip04-deprecated';
8
10
  publish: PublishResult;
11
+ senderCopyPublish?: PublishResult;
9
12
  }
10
13
  export interface DmReadEntry {
11
14
  id: string;
@@ -23,6 +26,14 @@ export declare function handleDmSend(ctx: IdentityContext, pool: RelayPool, args
23
26
  nip04?: boolean;
24
27
  nip04Enabled?: boolean;
25
28
  recipientRelay?: string;
29
+ nip65?: Nip65Manager;
26
30
  }): Promise<DmSendResult>;
27
31
  /** Read DMs addressed to the active identity */
28
- export declare function handleDmRead(ctx: IdentityContext, pool: RelayPool): Promise<DmReadEntry[]>;
32
+ export declare function handleDmRead(ctx: IdentityContext, pool: RelayPool, args?: {
33
+ limit?: number;
34
+ }): Promise<DmReadEntry[]>;
35
+ /** Read DM conversation with a specific pubkey — filters to messages from that person only */
36
+ export declare function handleDmConversation(ctx: IdentityContext, pool: RelayPool, args: {
37
+ withPubkeyHex: string;
38
+ limit?: number;
39
+ }): Promise<DmReadEntry[]>;
package/dist/social/dm.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { wrapEvent } from 'nostr-tools/nip17';
2
2
  import * as nip04 from 'nostr-tools/nip04';
3
- import { decode } from 'nostr-tools/nip19';
3
+ import { decode, npubEncode } from 'nostr-tools/nip19';
4
4
  /** Send a DM. Uses NIP-17 gift wrap by default. NIP-04 only if explicitly requested and enabled. */
5
5
  export async function handleDmSend(ctx, pool, args) {
6
6
  if (args.nip04) {
@@ -21,19 +21,58 @@ export async function handleDmSend(ctx, pool, args) {
21
21
  return { event, protocol: 'nip04-deprecated', publish };
22
22
  }
23
23
  // NIP-17 gift-wrapped DM (default)
24
- const event = wrapEvent(ctx.activePrivateKey, { publicKey: args.recipientPubkeyHex, relayUrl: args.recipientRelay }, args.message);
25
- const publish = await pool.publish(ctx.activeNpub, event);
26
- return { event, protocol: 'nip17', publish };
24
+ // Look up recipient's inbox relays via NIP-65 for proper delivery
25
+ let recipientRelays = [];
26
+ if (args.nip65) {
27
+ try {
28
+ const recipientNpub = npubEncode(args.recipientPubkeyHex);
29
+ const relaySet = await args.nip65.loadForIdentity(recipientNpub, args.recipientPubkeyHex);
30
+ recipientRelays = relaySet.read; // recipient reads from their read relays
31
+ }
32
+ catch { /* fall back to sender's relays */ }
33
+ }
34
+ const recipientRelayHint = args.recipientRelay ?? recipientRelays[0];
35
+ // Wrap for recipient
36
+ const event = wrapEvent(ctx.activePrivateKey, { publicKey: args.recipientPubkeyHex, relayUrl: recipientRelayHint }, args.message);
37
+ // Publish to recipient's relays (primary) and sender's relays (fallback)
38
+ let publish;
39
+ if (recipientRelays.length > 0) {
40
+ // Merge recipient read relays with sender write relays for best coverage
41
+ const senderRelays = pool.getRelays(ctx.activeNpub).write;
42
+ const allRelays = [...new Set([...recipientRelays, ...senderRelays])];
43
+ publish = await pool.publishDirect(allRelays, event);
44
+ }
45
+ else {
46
+ publish = await pool.publish(ctx.activeNpub, event);
47
+ }
48
+ // Sender copy — wrap the same message addressed to ourselves so our client shows sent DMs.
49
+ // The inner rumor's p-tag (set by wrapEvent) points to us, but clients use the
50
+ // outer gift-wrap's p-tag to route, and the inner content to display.
51
+ const senderHex = decode(ctx.activeNpub).data;
52
+ const senderCopy = wrapEvent(ctx.activePrivateKey, { publicKey: senderHex, relayUrl: recipientRelayHint }, args.message);
53
+ const senderCopyPublish = await pool.publish(ctx.activeNpub, senderCopy);
54
+ return { event, senderCopy, protocol: 'nip17', publish, senderCopyPublish };
27
55
  }
28
56
  /** Read DMs addressed to the active identity */
29
- export async function handleDmRead(ctx, pool) {
57
+ export async function handleDmRead(ctx, pool, args) {
30
58
  // Fetch gift wraps (kind 1059) and legacy DMs (kind 4) addressed to us
31
59
  const activeHex = decode(ctx.activeNpub).data;
32
60
  const events = await pool.query(ctx.activeNpub, {
33
61
  kinds: [1059, 4],
34
62
  '#p': [activeHex],
35
- limit: 50,
63
+ limit: args?.limit ?? 50,
36
64
  });
65
+ return decryptDmEvents(ctx, events);
66
+ }
67
+ /** Read DM conversation with a specific pubkey — filters to messages from that person only */
68
+ export async function handleDmConversation(ctx, pool, args) {
69
+ const allMessages = await handleDmRead(ctx, pool, { limit: args.limit ?? 100 });
70
+ return allMessages
71
+ .filter(m => m.from === args.withPubkeyHex)
72
+ .sort((a, b) => a.createdAt - b.createdAt); // chronological for conversation view
73
+ }
74
+ /** Decrypt a list of DM events (kind 1059 gift wrap + kind 4 legacy) */
75
+ async function decryptDmEvents(ctx, events) {
37
76
  const results = [];
38
77
  for (const event of events) {
39
78
  if (event.kind === 1059) {
@@ -56,8 +56,15 @@ export interface Contact {
56
56
  relay?: string;
57
57
  petname?: string;
58
58
  }
59
+ export interface EnrichedContact extends Contact {
60
+ name?: string;
61
+ displayName?: string;
62
+ nip05?: string;
63
+ }
59
64
  /** Fetch the kind 3 contact list for a pubkey */
60
65
  export declare function handleContactsGet(pool: RelayPool, npub: string, pubkeyHex: string): Promise<Contact[]>;
66
+ /** Search contacts by name/display_name/nip05 — resolves profiles in a single batch query */
67
+ export declare function handleContactsSearch(pool: RelayPool, npub: string, pubkeyHex: string, query: string): Promise<EnrichedContact[]>;
61
68
  /** Follow a pubkey — fetches current contacts, adds, publishes new kind 3 */
62
69
  export declare function handleContactsFollow(ctx: IdentityContext, pool: RelayPool, args: {
63
70
  pubkeyHex: string;
@@ -149,6 +149,59 @@ export async function handleContactsGet(pool, npub, pubkeyHex) {
149
149
  petname: t[3] || undefined,
150
150
  }));
151
151
  }
152
+ /** Batch-fetch kind 0 profiles for a list of pubkeys, returning the newest per author */
153
+ async function batchFetchProfiles(pool, npub, pubkeys) {
154
+ if (pubkeys.length === 0)
155
+ return new Map();
156
+ const events = await pool.query(npub, {
157
+ kinds: [0],
158
+ authors: pubkeys,
159
+ });
160
+ // Keep only the newest kind 0 per pubkey
161
+ const best = new Map();
162
+ for (const ev of events) {
163
+ const prev = best.get(ev.pubkey);
164
+ if (!prev || ev.created_at > prev.created_at) {
165
+ best.set(ev.pubkey, ev);
166
+ }
167
+ }
168
+ const profiles = new Map();
169
+ for (const [pk, ev] of best) {
170
+ try {
171
+ profiles.set(pk, JSON.parse(ev.content));
172
+ }
173
+ catch { /* skip unparseable */ }
174
+ }
175
+ return profiles;
176
+ }
177
+ /** Search contacts by name/display_name/nip05 — resolves profiles in a single batch query */
178
+ export async function handleContactsSearch(pool, npub, pubkeyHex, query) {
179
+ const contacts = await handleContactsGet(pool, npub, pubkeyHex);
180
+ if (contacts.length === 0)
181
+ return [];
182
+ const profiles = await batchFetchProfiles(pool, npub, contacts.map(c => c.pubkey));
183
+ const q = query.toLowerCase();
184
+ const results = [];
185
+ for (const contact of contacts) {
186
+ const profile = profiles.get(contact.pubkey);
187
+ const name = profile?.name ?? '';
188
+ const displayName = profile?.display_name ?? '';
189
+ const nip05 = profile?.nip05 ?? '';
190
+ const petname = contact.petname ?? '';
191
+ if (name.toLowerCase().includes(q) ||
192
+ displayName.toLowerCase().includes(q) ||
193
+ nip05.toLowerCase().includes(q) ||
194
+ petname.toLowerCase().includes(q)) {
195
+ results.push({
196
+ ...contact,
197
+ name: name || undefined,
198
+ displayName: displayName || undefined,
199
+ nip05: nip05 || undefined,
200
+ });
201
+ }
202
+ }
203
+ return results;
204
+ }
152
205
  /** Follow a pubkey — fetches current contacts, adds, publishes new kind 3 */
153
206
  export async function handleContactsFollow(ctx, pool, args) {
154
207
  // Fetch existing contacts
@@ -2,8 +2,8 @@ import { z } from 'zod';
2
2
  import { hexId } from '../validation.js';
3
3
  import { toolResponse } from '../tool-response.js';
4
4
  import * as fmt from '../format.js';
5
- import { handleSocialPost, handleSocialReply, handleSocialReact, handleSocialProfileGet, handleSocialProfileSet, handleContactsGet, handleContactsFollow, handleContactsUnfollow, handleSocialDelete, handleSocialRepost, } from './handlers.js';
6
- import { handleDmSend, handleDmRead } from './dm.js';
5
+ import { handleSocialPost, handleSocialReply, handleSocialReact, handleSocialProfileGet, handleSocialProfileSet, handleContactsGet, handleContactsSearch, handleContactsFollow, handleContactsUnfollow, handleSocialDelete, handleSocialRepost, } from './handlers.js';
6
+ import { handleDmSend, handleDmRead, handleDmConversation } from './dm.js';
7
7
  import { handleNotifications, handleFeed } from './notifications.js';
8
8
  import { handleNipPublish, handleNipRead } from './nips.js';
9
9
  import { handleBlossomUpload, handleBlossomList, handleBlossomDelete } from './blossom.js';
@@ -151,12 +151,14 @@ export function registerSocialTools(server, deps) {
151
151
  nip04,
152
152
  nip04Enabled: false, // TODO: wire from config
153
153
  recipientRelay,
154
+ nip65: deps.nip65,
154
155
  });
155
156
  return {
156
157
  content: [{ type: 'text', text: JSON.stringify({
157
158
  protocol: result.protocol,
158
159
  id: result.event.id,
159
160
  publish: result.publish,
161
+ senderCopyPublish: result.senderCopyPublish,
160
162
  }, null, 2) }],
161
163
  };
162
164
  });
@@ -170,6 +172,63 @@ export function registerSocialTools(server, deps) {
170
172
  const messages = await handleDmRead(deps.ctx, deps.pool);
171
173
  return toolResponse(messages, output, fmt.formatDms);
172
174
  });
175
+ server.registerTool('dm-by-name', {
176
+ description: 'Send an encrypted DM to a contact by name — no pubkey needed. Searches your contacts, finds the match, and sends the message. Returns an error if zero or multiple matches are found (use contacts-search to disambiguate).',
177
+ inputSchema: {
178
+ name: z.string().describe('Contact name to search for (matches name, display_name, nip05 — case insensitive)'),
179
+ message: z.string().describe('Message text'),
180
+ nip04: z.boolean().default(false).describe('Use legacy NIP-04 instead of NIP-17'),
181
+ },
182
+ annotations: { readOnlyHint: false, destructiveHint: true },
183
+ }, async ({ name, message, nip04 }) => {
184
+ // Search contacts for the name
185
+ const matches = await handleContactsSearch(deps.pool, deps.ctx.activeNpub, deps.ctx.activePublicKeyHex, name);
186
+ if (matches.length === 0) {
187
+ return {
188
+ content: [{ type: 'text', text: JSON.stringify({
189
+ error: `No contact found matching "${name}". Use contacts-search to check your contacts.`,
190
+ }, null, 2) }],
191
+ };
192
+ }
193
+ if (matches.length > 1) {
194
+ const names = matches.map(m => m.displayName || m.name || m.pubkey.slice(0, 12) + '...');
195
+ return {
196
+ content: [{ type: 'text', text: JSON.stringify({
197
+ error: `Multiple contacts match "${name}": ${names.join(', ')}. Be more specific or use dm-send with the exact pubkey.`,
198
+ matches: matches.map(m => ({ pubkey: m.pubkey, name: m.name, displayName: m.displayName })),
199
+ }, null, 2) }],
200
+ };
201
+ }
202
+ const recipient = matches[0];
203
+ const result = await handleDmSend(deps.ctx, deps.pool, {
204
+ recipientPubkeyHex: recipient.pubkey,
205
+ message,
206
+ nip04,
207
+ nip04Enabled: false,
208
+ nip65: deps.nip65,
209
+ });
210
+ return {
211
+ content: [{ type: 'text', text: JSON.stringify({
212
+ sentTo: recipient.displayName || recipient.name || recipient.pubkey.slice(0, 12) + '...',
213
+ recipientPubkey: recipient.pubkey,
214
+ protocol: result.protocol,
215
+ id: result.event.id,
216
+ publish: result.publish,
217
+ }, null, 2) }],
218
+ };
219
+ });
220
+ server.registerTool('dm-conversation', {
221
+ description: 'Read DM conversation with a specific person. Filters to messages from one pubkey and shows them in chronological order. Use contacts-search first if you only know the name.',
222
+ inputSchema: {
223
+ pubkeyHex: hexId.describe('Hex pubkey to show conversation with'),
224
+ limit: z.number().int().min(1).max(200).default(50).describe('Max messages to fetch before filtering'),
225
+ output: z.enum(['json', 'human']).default('human').describe('Response format'),
226
+ },
227
+ annotations: { readOnlyHint: true },
228
+ }, async ({ pubkeyHex, limit, output }) => {
229
+ const messages = await handleDmConversation(deps.ctx, deps.pool, { withPubkeyHex: pubkeyHex, limit });
230
+ return toolResponse(messages, output, fmt.formatConversation);
231
+ });
173
232
  server.registerTool('social-notifications', {
174
233
  description: 'Fetch notifications for the active identity — mentions, replies, reactions, and zap receipts.',
175
234
  inputSchema: {
@@ -195,6 +254,74 @@ export function registerSocialTools(server, deps) {
195
254
  const feed = await handleFeed(deps.ctx, deps.pool, { authors, since, limit });
196
255
  return toolResponse(feed, output, fmt.formatFeed);
197
256
  });
257
+ server.registerTool('feed-by-name', {
258
+ description: 'Fetch recent posts by a contact, searching by name instead of pubkey. Returns an error if zero or multiple contacts match.',
259
+ inputSchema: {
260
+ name: z.string().describe('Contact name to search for (case insensitive)'),
261
+ since: z.number().optional().describe('Unix timestamp — only fetch posts after this time'),
262
+ limit: z.number().int().min(1).max(100).default(20).describe('Max posts to return'),
263
+ output: z.enum(['json', 'human']).default('human').describe('Response format'),
264
+ },
265
+ annotations: { readOnlyHint: true },
266
+ }, async ({ name, since, limit, output }) => {
267
+ const matches = await handleContactsSearch(deps.pool, deps.ctx.activeNpub, deps.ctx.activePublicKeyHex, name);
268
+ if (matches.length === 0) {
269
+ return {
270
+ content: [{ type: 'text', text: JSON.stringify({
271
+ error: `No contact found matching "${name}".`,
272
+ }, null, 2) }],
273
+ };
274
+ }
275
+ if (matches.length > 1) {
276
+ const names = matches.map(m => m.displayName || m.name || m.pubkey.slice(0, 12) + '...');
277
+ return {
278
+ content: [{ type: 'text', text: JSON.stringify({
279
+ error: `Multiple contacts match "${name}": ${names.join(', ')}. Be more specific.`,
280
+ matches: matches.map(m => ({ pubkey: m.pubkey, name: m.name, displayName: m.displayName })),
281
+ }, null, 2) }],
282
+ };
283
+ }
284
+ const contact = matches[0];
285
+ const feed = await handleFeed(deps.ctx, deps.pool, { authors: [contact.pubkey], since, limit });
286
+ return toolResponse({ contact: { name: contact.displayName || contact.name, pubkey: contact.pubkey }, posts: feed }, output, (data) => {
287
+ const header = `Posts by ${data.contact.name ?? data.contact.pubkey.slice(0, 12) + '...'}:`;
288
+ if (data.posts.length === 0)
289
+ return `${header}\n No posts found.`;
290
+ return `${header}\n${fmt.formatFeed(data.posts)}`;
291
+ });
292
+ });
293
+ server.registerTool('profile-by-name', {
294
+ description: 'Look up a contact\'s full profile by name — no pubkey needed. Searches your contacts and returns the kind 0 profile for the match.',
295
+ inputSchema: {
296
+ name: z.string().describe('Contact name to search for (case insensitive)'),
297
+ output: z.enum(['json', 'human']).default('human').describe('Response format'),
298
+ },
299
+ annotations: { readOnlyHint: true },
300
+ }, async ({ name, output }) => {
301
+ const matches = await handleContactsSearch(deps.pool, deps.ctx.activeNpub, deps.ctx.activePublicKeyHex, name);
302
+ if (matches.length === 0) {
303
+ return {
304
+ content: [{ type: 'text', text: JSON.stringify({
305
+ error: `No contact found matching "${name}".`,
306
+ }, null, 2) }],
307
+ };
308
+ }
309
+ if (matches.length > 1) {
310
+ const names = matches.map(m => m.displayName || m.name || m.pubkey.slice(0, 12) + '...');
311
+ return {
312
+ content: [{ type: 'text', text: JSON.stringify({
313
+ error: `Multiple contacts match "${name}": ${names.join(', ')}. Be more specific.`,
314
+ matches: matches.map(m => ({ pubkey: m.pubkey, name: m.name, displayName: m.displayName })),
315
+ }, null, 2) }],
316
+ };
317
+ }
318
+ const contact = matches[0];
319
+ const profile = await handleSocialProfileGet(deps.pool, deps.ctx.activeNpub, contact.pubkey);
320
+ return toolResponse({ ...profile, _pubkey: contact.pubkey }, output, (data) => {
321
+ const { _pubkey, ...fields } = data;
322
+ return `Pubkey: ${_pubkey}\n${fmt.formatProfile(fields)}`;
323
+ });
324
+ });
198
325
  server.registerTool('contacts-get', {
199
326
  description: 'Fetch the contact list (kind 3 follows) for a pubkey. Returns pubkeys, relay hints, and petnames.',
200
327
  inputSchema: {
@@ -207,6 +334,21 @@ export function registerSocialTools(server, deps) {
207
334
  const contacts = await handleContactsGet(deps.pool, npub ?? deps.ctx.activeNpub, pubkeyHex);
208
335
  return toolResponse(contacts, output, fmt.formatContacts);
209
336
  });
337
+ server.registerTool('contacts-search', {
338
+ description: 'Search your contacts by name, display name, or NIP-05. Resolves profiles in one batch — much faster than fetching each individually. Use this to find a contact when you know their name but not their pubkey.',
339
+ inputSchema: {
340
+ query: z.string().describe('Search string (matches name, display_name, nip05, petname — case insensitive)'),
341
+ pubkeyHex: hexId.optional().describe('Hex pubkey whose contacts to search (defaults to active identity)'),
342
+ npub: z.string().optional().describe('Bech32 npub for relay routing (defaults to active identity)'),
343
+ output: z.enum(['json', 'human']).default('human').describe('Response format'),
344
+ },
345
+ annotations: { readOnlyHint: true },
346
+ }, async ({ query, pubkeyHex, npub, output }) => {
347
+ const resolvedNpub = npub ?? deps.ctx.activeNpub;
348
+ const resolvedPubkey = pubkeyHex ?? deps.ctx.activePublicKeyHex;
349
+ const results = await handleContactsSearch(deps.pool, resolvedNpub, resolvedPubkey, query);
350
+ return toolResponse(results, output, fmt.formatContactSearch);
351
+ });
210
352
  server.registerTool('contacts-follow', {
211
353
  description: 'Follow a Nostr pubkey. Fetches current contact list, adds the pubkey, publishes updated kind 3.',
212
354
  inputSchema: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-bray",
3
- "version": "1.21.0",
3
+ "version": "1.22.0",
4
4
  "description": "MCP server giving AI agents sovereign Nostr identities — key derivation, personas, attestations, duress detection, zaps",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",