nostr-mcp-server 2.0.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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +498 -0
  3. package/build/__tests__/basic.test.js +87 -0
  4. package/build/__tests__/error-handling.test.js +145 -0
  5. package/build/__tests__/format-conversion.test.js +137 -0
  6. package/build/__tests__/integration.test.js +163 -0
  7. package/build/__tests__/mocks.js +109 -0
  8. package/build/__tests__/nip19-conversion.test.js +268 -0
  9. package/build/__tests__/nips-search.test.js +109 -0
  10. package/build/__tests__/note-creation.test.js +148 -0
  11. package/build/__tests__/note-tools-functions.test.js +173 -0
  12. package/build/__tests__/note-tools-unit.test.js +97 -0
  13. package/build/__tests__/profile-notes-simple.test.js +78 -0
  14. package/build/__tests__/profile-postnote.test.js +120 -0
  15. package/build/__tests__/profile-tools.test.js +90 -0
  16. package/build/__tests__/relay-specification.test.js +136 -0
  17. package/build/__tests__/search-nips-simple.test.js +96 -0
  18. package/build/__tests__/websocket-integration.test.js +257 -0
  19. package/build/__tests__/zap-tools-simple.test.js +72 -0
  20. package/build/__tests__/zap-tools-tests.test.js +197 -0
  21. package/build/index.js +1285 -0
  22. package/build/nips/nips-tools.js +567 -0
  23. package/build/nips-tools.js +421 -0
  24. package/build/note/note-tools.js +296 -0
  25. package/build/note-tools.js +53 -0
  26. package/build/profile/profile-tools.js +260 -0
  27. package/build/utils/constants.js +27 -0
  28. package/build/utils/conversion.js +332 -0
  29. package/build/utils/ephemeral-relay.js +438 -0
  30. package/build/utils/formatting.js +34 -0
  31. package/build/utils/index.js +6 -0
  32. package/build/utils/nip19-tools.js +117 -0
  33. package/build/utils/pool.js +55 -0
  34. package/build/zap/zap-tools.js +980 -0
  35. package/build/zap-tools.js +989 -0
  36. package/package.json +59 -0
@@ -0,0 +1,567 @@
1
+ import fetch from "node-fetch";
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ // Cache configuration - 24 hours in milliseconds
6
+ const CACHE_TTL = 1000 * 60 * 60 * 24;
7
+ // Store cache in OS temp directory to ensure it's writable
8
+ const CACHE_DIR = path.join(os.tmpdir(), 'nostr-mcp-server');
9
+ const CACHE_FILE = path.join(CACHE_DIR, 'nips-cache.json');
10
+ // Loading state management
11
+ let isLoading = false;
12
+ let lastError = null;
13
+ let nipsCache = [];
14
+ let lastFetchTime = 0;
15
+ let searchIndex = {
16
+ titleIndex: new Map(),
17
+ descriptionIndex: new Map(),
18
+ contentIndex: new Map(),
19
+ numberIndex: new Map(),
20
+ kindIndex: new Map(),
21
+ tagIndex: new Map()
22
+ };
23
+ // Ensure cache directory exists
24
+ function ensureCacheDirectory() {
25
+ try {
26
+ if (!fs.existsSync(CACHE_DIR)) {
27
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
28
+ console.error(`Created cache directory: ${CACHE_DIR}`);
29
+ }
30
+ }
31
+ catch (error) {
32
+ console.error('Failed to create cache directory:', error);
33
+ }
34
+ }
35
+ // Load cache from disk with improved error handling
36
+ function loadCacheFromDisk() {
37
+ try {
38
+ ensureCacheDirectory();
39
+ if (fs.existsSync(CACHE_FILE)) {
40
+ const cacheData = fs.readFileSync(CACHE_FILE, 'utf8');
41
+ try {
42
+ const cacheObj = JSON.parse(cacheData);
43
+ if (cacheObj && Array.isArray(cacheObj.nips) && typeof cacheObj.timestamp === 'number') {
44
+ nipsCache = cacheObj.nips;
45
+ lastFetchTime = cacheObj.timestamp;
46
+ // Check if cache is fresh enough
47
+ if (Date.now() - lastFetchTime < CACHE_TTL) {
48
+ buildSearchIndex();
49
+ return true;
50
+ }
51
+ else {
52
+ console.error('Cache file exists but is expired');
53
+ // We'll still use it temporarily but will refresh
54
+ buildSearchIndex();
55
+ return false;
56
+ }
57
+ }
58
+ }
59
+ catch (parseError) {
60
+ console.error('Error parsing cache file:', parseError);
61
+ // If file exists but is corrupted, delete it
62
+ try {
63
+ fs.unlinkSync(CACHE_FILE);
64
+ console.error('Deleted corrupted cache file');
65
+ }
66
+ catch (unlinkError) {
67
+ console.error('Failed to delete corrupted cache file:', unlinkError);
68
+ }
69
+ }
70
+ }
71
+ return false;
72
+ }
73
+ catch (error) {
74
+ console.error('Error loading cache from disk:', error);
75
+ return false;
76
+ }
77
+ }
78
+ // Save cache to disk with improved error handling
79
+ function saveCacheToDisk() {
80
+ try {
81
+ ensureCacheDirectory();
82
+ const cacheObj = {
83
+ nips: nipsCache,
84
+ timestamp: lastFetchTime
85
+ };
86
+ // Write to a temporary file first, then rename to avoid corruption
87
+ const tempFile = `${CACHE_FILE}.tmp`;
88
+ fs.writeFileSync(tempFile, JSON.stringify(cacheObj, null, 2), 'utf8');
89
+ fs.renameSync(tempFile, CACHE_FILE);
90
+ console.error(`Saved ${nipsCache.length} NIPs to cache file`);
91
+ }
92
+ catch (error) {
93
+ console.error('Error saving cache to disk:', error);
94
+ }
95
+ }
96
+ // Build search index from nips cache - optimized for speed
97
+ function buildSearchIndex() {
98
+ // Reset indexes
99
+ searchIndex = {
100
+ titleIndex: new Map(),
101
+ descriptionIndex: new Map(),
102
+ contentIndex: new Map(),
103
+ numberIndex: new Map(),
104
+ kindIndex: new Map(),
105
+ tagIndex: new Map()
106
+ };
107
+ // Pre-allocate sets to reduce memory allocations
108
+ const uniqueWords = new Set();
109
+ // First pass: collect all unique words
110
+ for (const nip of nipsCache) {
111
+ // Index title words
112
+ const titleWords = nip.title.toLowerCase().split(/\W+/).filter(word => word.length > 0);
113
+ titleWords.forEach(word => uniqueWords.add(word));
114
+ // Index description words
115
+ const descWords = nip.description.toLowerCase().split(/\W+/).filter(word => word.length > 0);
116
+ descWords.forEach(word => uniqueWords.add(word));
117
+ // Index content selectively
118
+ const contentWords = new Set(nip.content.toLowerCase()
119
+ .split(/\W+/)
120
+ .filter(word => word.length > 3));
121
+ contentWords.forEach(word => uniqueWords.add(word));
122
+ // Add tags
123
+ if (nip.tags) {
124
+ nip.tags.forEach(tag => uniqueWords.add(tag.toLowerCase().trim()));
125
+ }
126
+ }
127
+ // Pre-allocate maps for each unique word
128
+ uniqueWords.forEach(word => {
129
+ searchIndex.titleIndex.set(word, new Set());
130
+ searchIndex.descriptionIndex.set(word, new Set());
131
+ searchIndex.contentIndex.set(word, new Set());
132
+ });
133
+ // Second pass: fill the indexes
134
+ for (const nip of nipsCache) {
135
+ // Index NIP number
136
+ searchIndex.numberIndex.set(nip.number.toString(), nip.number);
137
+ // Index title words
138
+ const titleWords = nip.title.toLowerCase().split(/\W+/).filter(word => word.length > 0);
139
+ for (const word of titleWords) {
140
+ searchIndex.titleIndex.get(word)?.add(nip.number);
141
+ }
142
+ // Index description words
143
+ const descWords = nip.description.toLowerCase().split(/\W+/).filter(word => word.length > 0);
144
+ for (const word of descWords) {
145
+ searchIndex.descriptionIndex.get(word)?.add(nip.number);
146
+ }
147
+ // Index content (more selective to save memory)
148
+ const contentWords = new Set(nip.content.toLowerCase()
149
+ .split(/\W+/)
150
+ .filter(word => word.length > 3));
151
+ for (const word of contentWords) {
152
+ searchIndex.contentIndex.get(word)?.add(nip.number);
153
+ }
154
+ // Index kind
155
+ if (nip.kind !== undefined) {
156
+ if (!searchIndex.kindIndex.has(nip.kind)) {
157
+ searchIndex.kindIndex.set(nip.kind, new Set());
158
+ }
159
+ searchIndex.kindIndex.get(nip.kind)?.add(nip.number);
160
+ }
161
+ // Index tags
162
+ if (nip.tags) {
163
+ for (const tag of nip.tags) {
164
+ const normalizedTag = tag.toLowerCase().trim();
165
+ if (!searchIndex.tagIndex.has(normalizedTag)) {
166
+ searchIndex.tagIndex.set(normalizedTag, new Set());
167
+ }
168
+ searchIndex.tagIndex.get(normalizedTag)?.add(nip.number);
169
+ }
170
+ }
171
+ }
172
+ }
173
+ // Calculate exponential backoff time for retries
174
+ function calculateBackoff(attempt, baseMs = 1000, maxMs = 30000) {
175
+ const backoff = Math.min(maxMs, baseMs * Math.pow(2, attempt - 1));
176
+ // Add jitter to avoid thundering herd problem
177
+ return backoff * (0.75 + Math.random() * 0.5);
178
+ }
179
+ // Function to fetch NIPs from GitHub with improved retries and error handling
180
+ async function fetchNipsFromGitHub(retries = 5) {
181
+ isLoading = true;
182
+ lastError = null;
183
+ for (let attempt = 1; attempt <= retries; attempt++) {
184
+ try {
185
+ console.error(`Fetching NIPs from GitHub (attempt ${attempt}/${retries})`);
186
+ // Fetch the NIPs directory listing with improved options
187
+ const headers = {
188
+ 'Accept': 'application/vnd.github.v3+json',
189
+ 'User-Agent': 'nostr-mcp-server'
190
+ };
191
+ // Use conditional request if we already have data
192
+ if (nipsCache.length > 0) {
193
+ headers['If-Modified-Since'] = new Date(lastFetchTime).toUTCString();
194
+ }
195
+ // Set timeout to avoid long-hanging requests
196
+ const controller = new AbortController();
197
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
198
+ const response = await fetch('https://api.github.com/repos/nostr-protocol/nips/contents', {
199
+ headers,
200
+ signal: controller.signal
201
+ });
202
+ clearTimeout(timeoutId);
203
+ // If not modified, use cache
204
+ if (response.status === 304) {
205
+ console.error('NIPs not modified since last fetch, using cache');
206
+ isLoading = false;
207
+ return nipsCache;
208
+ }
209
+ if (!response.ok) {
210
+ throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
211
+ }
212
+ const files = await response.json();
213
+ // Filter for NIP markdown files more efficiently with a single regex
214
+ const nipFileRegex = /^(\d+|[0-9A-Fa-f]+)\.md$/;
215
+ const nipFiles = files.filter((file) => nipFileRegex.test(file.name));
216
+ console.error(`Found ${nipFiles.length} NIP files to process`);
217
+ // Process files with improved concurrency controls
218
+ // Increased batch size but with connection limits
219
+ const batchSize = 10; // Process more files at once
220
+ const nips = [];
221
+ // Load all NIPs concurrently in controlled batches
222
+ for (let i = 0; i < nipFiles.length; i += batchSize) {
223
+ const batch = nipFiles.slice(i, i + batchSize);
224
+ const batchPromises = batch.map(file => fetchNipFile(file, attempt));
225
+ try {
226
+ // Process batch with proper timeout
227
+ const batchResults = await Promise.allSettled(batchPromises);
228
+ // Handle fulfilled promises
229
+ batchResults.forEach(result => {
230
+ if (result.status === 'fulfilled' && result.value !== null) {
231
+ nips.push(result.value);
232
+ }
233
+ });
234
+ // Add a small delay between batches to avoid rate limiting, shorter delay
235
+ if (i + batchSize < nipFiles.length) {
236
+ await new Promise(resolve => setTimeout(resolve, 200));
237
+ }
238
+ }
239
+ catch (batchError) {
240
+ console.error(`Error processing batch starting at index ${i}:`, batchError);
241
+ // Continue to next batch even if current fails
242
+ }
243
+ }
244
+ console.error(`Successfully processed ${nips.length} NIPs`);
245
+ isLoading = false;
246
+ return nips;
247
+ }
248
+ catch (error) {
249
+ const typedError = error;
250
+ console.error(`Error fetching NIPs from GitHub (attempt ${attempt}/${retries}):`, typedError.message);
251
+ lastError = typedError;
252
+ if (attempt === retries) {
253
+ // On final retry failure, return cache if available or empty array
254
+ console.error('All GitHub fetch attempts failed, using cached data if available');
255
+ isLoading = false;
256
+ return nipsCache.length > 0 ? nipsCache : [];
257
+ }
258
+ // Exponential backoff with jitter before retrying
259
+ const backoffTime = calculateBackoff(attempt);
260
+ console.error(`Retrying in ${Math.round(backoffTime / 1000)} seconds...`);
261
+ await new Promise(resolve => setTimeout(resolve, backoffTime));
262
+ }
263
+ }
264
+ isLoading = false;
265
+ return [];
266
+ }
267
+ // Helper to fetch a single NIP file with improved error handling and timeouts
268
+ async function fetchNipFile(file, attemptNumber) {
269
+ try {
270
+ // Set timeout to avoid hanging requests - higher for content
271
+ const controller = new AbortController();
272
+ const timeoutId = setTimeout(() => controller.abort(), 15000);
273
+ const contentResponse = await fetch(file.download_url, {
274
+ signal: controller.signal,
275
+ headers: {
276
+ 'User-Agent': 'nostr-mcp-server',
277
+ 'Accept': 'text/plain'
278
+ }
279
+ });
280
+ clearTimeout(timeoutId);
281
+ if (!contentResponse.ok) {
282
+ console.error(`Failed to fetch ${file.name}: ${contentResponse.status}`);
283
+ return null;
284
+ }
285
+ const content = await contentResponse.text();
286
+ const numberMatch = file.name.match(/^(\d+|[0-9A-Fa-f]+)\.md$/);
287
+ if (!numberMatch)
288
+ return null;
289
+ const numberStr = numberMatch[1];
290
+ const number = numberStr.match(/^[0-9A-Fa-f]+$/) ?
291
+ parseInt(numberStr, 16) :
292
+ parseInt(numberStr, 10);
293
+ // More efficient parsing - fix title extraction
294
+ const lines = content.split('\n');
295
+ // Find the actual title after the NIP header
296
+ let title = `NIP-${number}`; // fallback
297
+ let description = `NIP-${number} description`; // fallback
298
+ // Look for the title after the initial header and any decorative lines
299
+ for (let i = 1; i < Math.min(lines.length, 10); i++) {
300
+ const line = lines[i].trim();
301
+ // Skip empty lines and decorative lines (like ======)
302
+ if (!line || line.match(/^[=\-_]+$/)) {
303
+ continue;
304
+ }
305
+ // Skip lines that look like metadata tags (like `draft` `mandatory`)
306
+ if (line.match(/^`[^`]+`(\s+`[^`]+`)*$/)) {
307
+ continue;
308
+ }
309
+ // This should be the actual title
310
+ title = line;
311
+ description = line; // Use the same line for both title and description
312
+ break;
313
+ }
314
+ // Optimize regex searches
315
+ const statusRegex = /Status:\s*(draft|final|deprecated)/i;
316
+ const kindRegex = /Kind:\s*(\d+)/i;
317
+ const tagRegex = /Tags:\s*([^\n]+)/gi;
318
+ const statusMatch = content.match(statusRegex);
319
+ const status = statusMatch ? statusMatch[1].toLowerCase() : "draft";
320
+ const kindMatch = content.match(kindRegex);
321
+ const kind = kindMatch ? parseInt(kindMatch[1], 10) : undefined;
322
+ const tags = [];
323
+ const tagMatches = content.matchAll(tagRegex);
324
+ for (const match of tagMatches) {
325
+ tags.push(...match[1].split(',').map((tag) => tag.trim()));
326
+ }
327
+ return {
328
+ number,
329
+ title,
330
+ description,
331
+ status,
332
+ kind,
333
+ tags: tags.length > 0 ? tags : undefined,
334
+ content
335
+ };
336
+ }
337
+ catch (error) {
338
+ console.error(`Error processing NIP ${file.name}`);
339
+ return null;
340
+ }
341
+ }
342
+ // Function to get NIPs with improved caching and parallel loading
343
+ async function getNips(forceRefresh = false) {
344
+ const now = Date.now();
345
+ // First attempt to load from memory cache if it's fresh enough
346
+ if (!forceRefresh && nipsCache.length > 0 && now - lastFetchTime < CACHE_TTL) {
347
+ return nipsCache;
348
+ }
349
+ // If no memory cache, try loading from disk
350
+ if (!forceRefresh && nipsCache.length === 0) {
351
+ const loaded = loadCacheFromDisk();
352
+ if (loaded && now - lastFetchTime < CACHE_TTL) {
353
+ return nipsCache;
354
+ }
355
+ }
356
+ // Avoid multiple parallel fetches
357
+ if (isLoading) {
358
+ console.error('NIPs already being fetched, using existing cache');
359
+ // Return current cache while waiting
360
+ return nipsCache.length > 0 ? nipsCache : [];
361
+ }
362
+ // Fetch fresh data
363
+ try {
364
+ const nips = await fetchNipsFromGitHub();
365
+ // Only update cache if we got new data
366
+ if (nips.length > 0) {
367
+ nipsCache = nips;
368
+ lastFetchTime = now;
369
+ // Save to disk and build search index
370
+ saveCacheToDisk();
371
+ buildSearchIndex();
372
+ }
373
+ return nipsCache;
374
+ }
375
+ catch (error) {
376
+ console.error("Error refreshing NIPs:", error);
377
+ lastError = error instanceof Error ? error : new Error(String(error));
378
+ // If we already have cached data, use it even if expired
379
+ if (nipsCache.length > 0) {
380
+ console.error("Using expired cache due to fetch error");
381
+ return nipsCache;
382
+ }
383
+ // Last resort - try to load from disk regardless of timestamp
384
+ if (loadCacheFromDisk()) {
385
+ return nipsCache;
386
+ }
387
+ // No options left
388
+ return [];
389
+ }
390
+ }
391
+ // Helper function to calculate relevance score using the search index - optimized for performance
392
+ function calculateRelevance(nip, searchTerms) {
393
+ const matchedTerms = [];
394
+ let score = 0;
395
+ // Convert search terms to lowercase for case-insensitive matching
396
+ const lowerSearchTerms = searchTerms.map(term => term.toLowerCase());
397
+ // Use a map to avoid duplicate scoring and O(n²) searches
398
+ const termScores = new Map();
399
+ for (const term of lowerSearchTerms) {
400
+ // Check for exact NIP number match (highest priority)
401
+ if (nip.number.toString() === term) {
402
+ score += 10;
403
+ matchedTerms.push(term);
404
+ continue;
405
+ }
406
+ let termMatched = false;
407
+ // Check title matches (high weight)
408
+ if (searchIndex.titleIndex.has(term) &&
409
+ searchIndex.titleIndex.get(term)?.has(nip.number)) {
410
+ score += 3;
411
+ termMatched = true;
412
+ }
413
+ // Check description matches (medium weight)
414
+ if (searchIndex.descriptionIndex.has(term) &&
415
+ searchIndex.descriptionIndex.get(term)?.has(nip.number)) {
416
+ score += 2;
417
+ termMatched = true;
418
+ }
419
+ // Check content matches (lower weight)
420
+ if (searchIndex.contentIndex.has(term) &&
421
+ searchIndex.contentIndex.get(term)?.has(nip.number)) {
422
+ score += 1;
423
+ termMatched = true;
424
+ }
425
+ // Check kind match
426
+ if (nip.kind !== undefined && nip.kind.toString() === term) {
427
+ score += 4;
428
+ termMatched = true;
429
+ }
430
+ // Check tag matches
431
+ if (nip.tags && nip.tags.some(tag => tag.toLowerCase() === term)) {
432
+ score += 3;
433
+ termMatched = true;
434
+ }
435
+ // Partial matches in title (very important)
436
+ if (nip.title.toLowerCase().includes(term)) {
437
+ score += 2;
438
+ termMatched = true;
439
+ }
440
+ if (termMatched && !matchedTerms.includes(term)) {
441
+ matchedTerms.push(term);
442
+ }
443
+ }
444
+ return { score, matchedTerms };
445
+ }
446
+ // Improved search function with performance optimizations
447
+ export async function searchNips(query, limit = 10) {
448
+ // Ensure we have NIPs data and the search index is built
449
+ const nips = await getNips();
450
+ if (nips.length === 0) {
451
+ console.error("No NIPs available for search");
452
+ console.error('Completed searchNips with no results');
453
+ return [];
454
+ }
455
+ // Handle direct NIP number search as a special case (fastest path)
456
+ const nipNumberMatch = query.match(/^(?:NIP-?)?(\d+)$/i);
457
+ if (nipNumberMatch) {
458
+ const nipNumber = parseInt(nipNumberMatch[1], 10);
459
+ const directNip = nips.find(nip => nip.number === nipNumber);
460
+ if (directNip) {
461
+ console.error('Completed searchNips with direct match');
462
+ return [{
463
+ nip: directNip,
464
+ relevance: 100,
465
+ matchedTerms: [nipNumber.toString()]
466
+ }];
467
+ }
468
+ }
469
+ // Split query into terms and filter out empty strings
470
+ const searchTerms = query.split(/\s+/).filter(term => term.length > 0);
471
+ // If the search terms are too short or common, warn about potential slow search
472
+ if (searchTerms.some(term => term.length < 3)) {
473
+ console.error('Search includes very short terms which may slow down the search');
474
+ }
475
+ // Search through all NIPs efficiently
476
+ const results = [];
477
+ // Pre-filter NIPs that might be relevant based on fast checks
478
+ // This avoids scoring every NIP for performance
479
+ const potentialMatches = new Set();
480
+ // First do a quick scan to find potential matches
481
+ for (const term of searchTerms) {
482
+ const lowerTerm = term.toLowerCase();
483
+ // Number match
484
+ if (searchIndex.numberIndex.has(lowerTerm)) {
485
+ potentialMatches.add(searchIndex.numberIndex.get(lowerTerm));
486
+ }
487
+ // Title matches
488
+ const titleMatches = searchIndex.titleIndex.get(lowerTerm);
489
+ if (titleMatches) {
490
+ titleMatches.forEach(num => potentialMatches.add(num));
491
+ }
492
+ // Description matches
493
+ const descMatches = searchIndex.descriptionIndex.get(lowerTerm);
494
+ if (descMatches) {
495
+ descMatches.forEach(num => potentialMatches.add(num));
496
+ }
497
+ // Content matches only if we have few potential matches so far
498
+ if (potentialMatches.size < 50) {
499
+ const contentMatches = searchIndex.contentIndex.get(lowerTerm);
500
+ if (contentMatches) {
501
+ contentMatches.forEach(num => potentialMatches.add(num));
502
+ }
503
+ }
504
+ // If we have too many potential matches, don't add more from content
505
+ if (potentialMatches.size > 100) {
506
+ break;
507
+ }
508
+ }
509
+ // If no potential matches through indexing, do a linear scan
510
+ if (potentialMatches.size === 0) {
511
+ // Fallback: check titles directly
512
+ for (const nip of nips) {
513
+ for (const term of searchTerms) {
514
+ if (nip.title.toLowerCase().includes(term.toLowerCase())) {
515
+ potentialMatches.add(nip.number);
516
+ break;
517
+ }
518
+ }
519
+ }
520
+ }
521
+ // Score only the potential matches
522
+ for (const nipNumber of potentialMatches) {
523
+ const nip = nips.find(n => n.number === nipNumber);
524
+ if (!nip)
525
+ continue;
526
+ const { score, matchedTerms } = calculateRelevance(nip, searchTerms);
527
+ if (score > 0) {
528
+ results.push({
529
+ nip,
530
+ relevance: score,
531
+ matchedTerms
532
+ });
533
+ }
534
+ }
535
+ // Sort by relevance and limit results
536
+ results.sort((a, b) => b.relevance - a.relevance);
537
+ const limitedResults = results.slice(0, limit);
538
+ return limitedResults;
539
+ }
540
+ // Format a NIP search result with cleaner output
541
+ export function formatNipResult(result, includeContent = false) {
542
+ const { nip, relevance, matchedTerms } = result;
543
+ const lines = [
544
+ `NIP-${nip.number}: ${nip.title}`,
545
+ `Status: ${nip.status}`,
546
+ nip.kind ? `Kind: ${nip.kind}` : null,
547
+ `Description: ${nip.description}`,
548
+ `Relevance Score: ${relevance}`,
549
+ matchedTerms.length > 0 ? `Matched Terms: ${matchedTerms.join(", ")}` : null,
550
+ ].filter(Boolean);
551
+ if (includeContent) {
552
+ lines.push("", "Content:", nip.content);
553
+ }
554
+ lines.push("---");
555
+ return lines.join("\n");
556
+ }
557
+ // Initialize by loading cache on module import, with background fetch
558
+ (async () => {
559
+ // Try to load from disk first
560
+ const loaded = loadCacheFromDisk();
561
+ // Always trigger a background fetch to ensure fresh data
562
+ setTimeout(() => {
563
+ getNips(false).catch(error => {
564
+ console.error('Error initializing NIPs cache');
565
+ });
566
+ }, loaded ? 5000 : 0); // If we loaded from cache, wait 5 seconds before refreshing
567
+ })();