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.
- package/LICENSE +21 -0
- package/README.md +498 -0
- package/build/__tests__/basic.test.js +87 -0
- package/build/__tests__/error-handling.test.js +145 -0
- package/build/__tests__/format-conversion.test.js +137 -0
- package/build/__tests__/integration.test.js +163 -0
- package/build/__tests__/mocks.js +109 -0
- package/build/__tests__/nip19-conversion.test.js +268 -0
- package/build/__tests__/nips-search.test.js +109 -0
- package/build/__tests__/note-creation.test.js +148 -0
- package/build/__tests__/note-tools-functions.test.js +173 -0
- package/build/__tests__/note-tools-unit.test.js +97 -0
- package/build/__tests__/profile-notes-simple.test.js +78 -0
- package/build/__tests__/profile-postnote.test.js +120 -0
- package/build/__tests__/profile-tools.test.js +90 -0
- package/build/__tests__/relay-specification.test.js +136 -0
- package/build/__tests__/search-nips-simple.test.js +96 -0
- package/build/__tests__/websocket-integration.test.js +257 -0
- package/build/__tests__/zap-tools-simple.test.js +72 -0
- package/build/__tests__/zap-tools-tests.test.js +197 -0
- package/build/index.js +1285 -0
- package/build/nips/nips-tools.js +567 -0
- package/build/nips-tools.js +421 -0
- package/build/note/note-tools.js +296 -0
- package/build/note-tools.js +53 -0
- package/build/profile/profile-tools.js +260 -0
- package/build/utils/constants.js +27 -0
- package/build/utils/conversion.js +332 -0
- package/build/utils/ephemeral-relay.js +438 -0
- package/build/utils/formatting.js +34 -0
- package/build/utils/index.js +6 -0
- package/build/utils/nip19-tools.js +117 -0
- package/build/utils/pool.js +55 -0
- package/build/zap/zap-tools.js +980 -0
- package/build/zap-tools.js +989 -0
- 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
|
+
})();
|