dfns-mcp 1.2.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/src/indexer.ts ADDED
@@ -0,0 +1,915 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+
4
+ export interface DocEntry {
5
+ path: string;
6
+ relativePath: string;
7
+ title: string;
8
+ content: string;
9
+ category: string;
10
+ keywords: string[];
11
+ }
12
+
13
+ // ============================================================================
14
+ // TypeScript Type Indexing
15
+ // ============================================================================
16
+
17
+ export interface TypeEntry {
18
+ /** The type/interface/class name */
19
+ name: string;
20
+ /** Kind of definition: 'type', 'interface', or 'class' */
21
+ kind: "type" | "interface" | "class";
22
+ /** Full definition including JSDoc comments */
23
+ definition: string;
24
+ /** The npm package to import from (e.g., '@dfns/sdk', '@dfns/sdk-browser') */
25
+ importPackage: string;
26
+ /** The subpath import if needed (e.g., '@dfns/sdk/generated/wallets') */
27
+ importPath: string;
28
+ /** Category (wallets, auth, keys, etc.) */
29
+ category: string;
30
+ /** JSDoc description if available */
31
+ description: string;
32
+ /** Source file path */
33
+ sourceFile: string;
34
+ }
35
+
36
+ export interface SearchResult {
37
+ path: string;
38
+ title: string;
39
+ category: string;
40
+ snippet: string;
41
+ score: number;
42
+ }
43
+
44
+ export interface ApiEndpoint {
45
+ method: string;
46
+ path: string;
47
+ docPath: string;
48
+ }
49
+
50
+ /**
51
+ * Parse TypeScript file and extract type/interface/class definitions
52
+ */
53
+ function parseTypeScriptTypes(content: string, filePath: string): Array<{
54
+ name: string;
55
+ kind: "type" | "interface" | "class";
56
+ definition: string;
57
+ description: string;
58
+ }> {
59
+ const results: Array<{
60
+ name: string;
61
+ kind: "type" | "interface" | "class";
62
+ definition: string;
63
+ description: string;
64
+ }> = [];
65
+
66
+ // Match exported type aliases: export type Name = ...
67
+ // This regex handles nested braces and complex union types
68
+ const typeRegex = /(?:\/\*\*[\s\S]*?\*\/\s*)?(export\s+type\s+(\w+)(?:<[^>]+>)?\s*=\s*)/g;
69
+ let match;
70
+
71
+ while ((match = typeRegex.exec(content)) !== null) {
72
+ const startIndex = match.index;
73
+ const name = match[2];
74
+ const afterEquals = content.slice(match.index + match[0].length);
75
+
76
+ // Find the end of the type definition (handle nested braces and semicolons)
77
+ const endIndex = findTypeDefinitionEnd(afterEquals);
78
+ const typeBody = afterEquals.slice(0, endIndex);
79
+
80
+ // Extract JSDoc if present
81
+ const jsDocMatch = content.slice(Math.max(0, startIndex - 500), startIndex).match(/\/\*\*[\s\S]*?\*\/\s*$/);
82
+ const jsDoc = jsDocMatch ? jsDocMatch[0] : "";
83
+ const description = extractJSDocDescription(jsDoc);
84
+
85
+ const fullDefinition = jsDoc + match[1] + typeBody;
86
+
87
+ results.push({
88
+ name,
89
+ kind: "type",
90
+ definition: fullDefinition.trim(),
91
+ description,
92
+ });
93
+ }
94
+
95
+ // Match exported interfaces: export interface Name { ... }
96
+ const interfaceRegex = /(?:\/\*\*[\s\S]*?\*\/\s*)?(export\s+interface\s+(\w+)(?:<[^>]+>)?(?:\s+extends\s+[^{]+)?\s*\{)/g;
97
+
98
+ while ((match = interfaceRegex.exec(content)) !== null) {
99
+ const startIndex = match.index;
100
+ const name = match[2];
101
+ const afterBrace = content.slice(match.index + match[0].length);
102
+
103
+ // Find matching closing brace
104
+ const endIndex = findMatchingBrace(afterBrace);
105
+ const interfaceBody = afterBrace.slice(0, endIndex);
106
+
107
+ // Extract JSDoc if present
108
+ const jsDocMatch = content.slice(Math.max(0, startIndex - 500), startIndex).match(/\/\*\*[\s\S]*?\*\/\s*$/);
109
+ const jsDoc = jsDocMatch ? jsDocMatch[0] : "";
110
+ const description = extractJSDocDescription(jsDoc);
111
+
112
+ const fullDefinition = jsDoc + match[1] + interfaceBody + "}";
113
+
114
+ results.push({
115
+ name,
116
+ kind: "interface",
117
+ definition: fullDefinition.trim(),
118
+ description,
119
+ });
120
+ }
121
+
122
+ // Match exported classes: export class Name { ... }
123
+ const classRegex = /(?:\/\*\*[\s\S]*?\*\/\s*)?(export\s+class\s+(\w+)(?:<[^>]+>)?(?:\s+(?:extends|implements)\s+[^{]+)?\s*\{)/g;
124
+
125
+ while ((match = classRegex.exec(content)) !== null) {
126
+ const startIndex = match.index;
127
+ const name = match[2];
128
+ const afterBrace = content.slice(match.index + match[0].length);
129
+
130
+ // Find matching closing brace
131
+ const endIndex = findMatchingBrace(afterBrace);
132
+ const classBody = afterBrace.slice(0, endIndex);
133
+
134
+ // Extract JSDoc if present
135
+ const jsDocMatch = content.slice(Math.max(0, startIndex - 500), startIndex).match(/\/\*\*[\s\S]*?\*\/\s*$/);
136
+ const jsDoc = jsDocMatch ? jsDocMatch[0] : "";
137
+ const description = extractJSDocDescription(jsDoc);
138
+
139
+ const fullDefinition = jsDoc + match[1] + classBody + "}";
140
+
141
+ results.push({
142
+ name,
143
+ kind: "class",
144
+ definition: fullDefinition.trim(),
145
+ description,
146
+ });
147
+ }
148
+
149
+ return results;
150
+ }
151
+
152
+ /**
153
+ * Find the end of a type definition (handles nested braces, arrays, unions)
154
+ */
155
+ function findTypeDefinitionEnd(content: string): number {
156
+ let braceCount = 0;
157
+ let parenCount = 0;
158
+ let inString = false;
159
+ let stringChar = "";
160
+
161
+ for (let i = 0; i < content.length; i++) {
162
+ const char = content[i];
163
+ const prevChar = i > 0 ? content[i - 1] : "";
164
+
165
+ // Handle string literals
166
+ if ((char === '"' || char === "'" || char === "`") && prevChar !== "\\") {
167
+ if (!inString) {
168
+ inString = true;
169
+ stringChar = char;
170
+ } else if (char === stringChar) {
171
+ inString = false;
172
+ }
173
+ continue;
174
+ }
175
+
176
+ if (inString) continue;
177
+
178
+ if (char === "{") braceCount++;
179
+ if (char === "}") braceCount--;
180
+ if (char === "(") parenCount++;
181
+ if (char === ")") parenCount--;
182
+
183
+ // Type definition ends at semicolon or newline when not inside braces/parens
184
+ if (braceCount === 0 && parenCount === 0) {
185
+ if (char === ";") return i + 1;
186
+ // Also end at double newline (next export statement)
187
+ if (char === "\n" && content[i + 1] === "\n" && content.slice(i + 2, i + 8) === "export") {
188
+ return i;
189
+ }
190
+ }
191
+ }
192
+
193
+ return content.length;
194
+ }
195
+
196
+ /**
197
+ * Find matching closing brace
198
+ */
199
+ function findMatchingBrace(content: string): number {
200
+ let braceCount = 1;
201
+ let inString = false;
202
+ let stringChar = "";
203
+
204
+ for (let i = 0; i < content.length; i++) {
205
+ const char = content[i];
206
+ const prevChar = i > 0 ? content[i - 1] : "";
207
+
208
+ // Handle string literals
209
+ if ((char === '"' || char === "'" || char === "`") && prevChar !== "\\") {
210
+ if (!inString) {
211
+ inString = true;
212
+ stringChar = char;
213
+ } else if (char === stringChar) {
214
+ inString = false;
215
+ }
216
+ continue;
217
+ }
218
+
219
+ if (inString) continue;
220
+
221
+ if (char === "{") braceCount++;
222
+ if (char === "}") {
223
+ braceCount--;
224
+ if (braceCount === 0) return i;
225
+ }
226
+ }
227
+
228
+ return content.length;
229
+ }
230
+
231
+ /**
232
+ * Extract description from JSDoc comment
233
+ */
234
+ function extractJSDocDescription(jsDoc: string): string {
235
+ if (!jsDoc) return "";
236
+
237
+ // Remove /** and */ and * prefixes
238
+ const cleaned = jsDoc
239
+ .replace(/^\/\*\*\s*/, "")
240
+ .replace(/\s*\*\/$/, "")
241
+ .split("\n")
242
+ .map(line => line.replace(/^\s*\*\s?/, ""))
243
+ .filter(line => !line.startsWith("@"))
244
+ .join(" ")
245
+ .trim();
246
+
247
+ return cleaned;
248
+ }
249
+
250
+ /**
251
+ * Determine package name from file path
252
+ */
253
+ function determinePackageName(filePath: string): { importPackage: string; importPath: string } {
254
+ // Extract package info from path like:
255
+ // dfns-sdk-ts/packages/sdk/generated/wallets/types.ts -> @dfns/sdk, @dfns/sdk/generated/wallets
256
+ // dfns-sdk-ts/packages/sdk-browser/signers/webauthn.ts -> @dfns/sdk-browser
257
+ // dfns-sdk-ts/packages/lib-ethersjs6/index.ts -> @dfns/lib-ethersjs6
258
+
259
+ const packagesMatch = filePath.match(/packages\/([^/]+)/);
260
+ if (!packagesMatch) {
261
+ return { importPackage: "@dfns/sdk", importPath: "@dfns/sdk" };
262
+ }
263
+
264
+ const packageDir = packagesMatch[1];
265
+ const importPackage = `@dfns/${packageDir}`;
266
+
267
+ // For generated types, include the subpath
268
+ const afterPackage = filePath.slice(filePath.indexOf(packageDir) + packageDir.length + 1);
269
+ if (afterPackage.startsWith("generated/")) {
270
+ // e.g., generated/wallets/types.ts -> @dfns/sdk/generated/wallets
271
+ const subPath = afterPackage.replace(/\/types\.ts$/, "").replace(/\/index\.ts$/, "");
272
+ return { importPackage, importPath: `${importPackage}/${subPath}` };
273
+ }
274
+
275
+ // For types/ directory
276
+ if (afterPackage.startsWith("types/")) {
277
+ const subPath = afterPackage.replace(/\.ts$/, "");
278
+ return { importPackage, importPath: `${importPackage}/${subPath}` };
279
+ }
280
+
281
+ return { importPackage, importPath: importPackage };
282
+ }
283
+
284
+ /**
285
+ * Determine type category from file path
286
+ */
287
+ function determineTypeCategory(filePath: string): string {
288
+ const categoryPatterns: Record<string, string> = {
289
+ "generated/wallets": "Wallets",
290
+ "generated/auth": "Authentication",
291
+ "generated/keys": "Keys",
292
+ "generated/policies": "Policies",
293
+ "generated/permissions": "Permissions",
294
+ "generated/networks": "Networks",
295
+ "generated/webhooks": "Webhooks",
296
+ "generated/staking": "Staking",
297
+ "generated/exchanges": "Exchanges",
298
+ "generated/feeSponsors": "Fee Sponsors",
299
+ "generated/signers": "Signers",
300
+ "generated/swaps": "Swaps",
301
+ "generated/agreements": "Agreements",
302
+ "generated/allocations": "Allocations",
303
+ "types/wallets": "Wallets",
304
+ "types/auth": "Authentication",
305
+ "sdk-browser": "Browser SDK",
306
+ "sdk-keysigner": "Key Signer",
307
+ "sdk-react-native": "React Native SDK",
308
+ "lib-ethersjs": "Ethereum (ethers.js)",
309
+ "lib-viem": "Ethereum (viem)",
310
+ "lib-solana": "Solana",
311
+ "lib-bitcoin": "Bitcoin",
312
+ };
313
+
314
+ for (const [pattern, category] of Object.entries(categoryPatterns)) {
315
+ if (filePath.includes(pattern)) {
316
+ return category;
317
+ }
318
+ }
319
+
320
+ return "Core SDK";
321
+ }
322
+
323
+ /**
324
+ * Extracts title from markdown content
325
+ */
326
+ function extractTitle(content: string, filePath: string): string {
327
+ // Try to find first h1 heading
328
+ const h1Match = content.match(/^#\s+(.+)$/m);
329
+ if (h1Match) {
330
+ return h1Match[1].replace(/[*_`\[\]]/g, "").trim();
331
+ }
332
+
333
+ // Fall back to filename
334
+ const fileName = filePath.split("/").pop() || "";
335
+ return fileName.replace(/\.md$/, "").replace(/-/g, " ");
336
+ }
337
+
338
+ /**
339
+ * Extracts keywords from markdown content
340
+ */
341
+ function extractKeywords(content: string): string[] {
342
+ const keywords: Set<string> = new Set();
343
+
344
+ // Extract code identifiers (camelCase, PascalCase)
345
+ const codeMatches = content.match(/`([A-Za-z][A-Za-z0-9]*(?:[A-Z][a-z0-9]*)*)`/g);
346
+ if (codeMatches) {
347
+ codeMatches.forEach((m) => keywords.add(m.replace(/`/g, "").toLowerCase()));
348
+ }
349
+
350
+ // Extract API endpoints
351
+ const endpointMatches = content.match(/(GET|POST|PUT|DELETE|PATCH)\s+\/[^\s\n]+/g);
352
+ if (endpointMatches) {
353
+ endpointMatches.forEach((m) => keywords.add(m.toLowerCase()));
354
+ }
355
+
356
+ // Extract headings as keywords
357
+ const headingMatches = content.match(/^#{1,3}\s+(.+)$/gm);
358
+ if (headingMatches) {
359
+ headingMatches.forEach((h) => {
360
+ const text = h.replace(/^#+\s+/, "").toLowerCase();
361
+ keywords.add(text);
362
+ });
363
+ }
364
+
365
+ return Array.from(keywords);
366
+ }
367
+
368
+ /**
369
+ * Determines category from file path using a configuration approach
370
+ */
371
+ function determineCategory(filePath: string): string {
372
+ const categoryMap: Record<string, string> = {
373
+ "api-docs/authentication": "Authentication API",
374
+ "api-docs/wallets": "Wallets API",
375
+ "api-docs/keys": "Keys API",
376
+ "api-docs/policy-engine": "Policy Engine API",
377
+ "api-docs/permissions": "Permissions API",
378
+ "api-docs/webhooks": "Webhooks API",
379
+ "api-docs/networks": "Networks API",
380
+ "api-docs/fee-sponsors": "Fee Sponsors API",
381
+ "integrations/exchanges": "Exchange Integrations",
382
+ "integrations/staking": "Staking",
383
+ "integrations/swaps": "Swaps",
384
+ "integrations": "Integrations",
385
+ "getting-started": "Getting Started",
386
+ "advanced-topics": "Advanced Topics",
387
+ "guides": "Guides",
388
+ "use-cases": "Use Cases",
389
+ "lib-": "SDK Libraries",
390
+ "sdk-": "SDK Core",
391
+ "examples": "SDK Examples",
392
+ };
393
+
394
+ for (const [key, value] of Object.entries(categoryMap)) {
395
+ if (filePath.includes(key)) {
396
+ return value;
397
+ }
398
+ }
399
+
400
+ return "General";
401
+ }
402
+
403
+ /**
404
+ * Recursively finds all markdown files in a directory
405
+ */
406
+ async function findMarkdownFiles(dir: string): Promise<string[]> {
407
+ const files: string[] = [];
408
+
409
+ async function walk(currentDir: string) {
410
+ const entries = await readdir(currentDir, { withFileTypes: true });
411
+ for (const entry of entries) {
412
+ const fullPath = join(currentDir, entry.name);
413
+ if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
414
+ await walk(fullPath);
415
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
416
+ files.push(fullPath);
417
+ }
418
+ }
419
+ }
420
+
421
+ await walk(dir);
422
+ return files;
423
+ }
424
+
425
+ /**
426
+ * Recursively finds all TypeScript files in SDK packages
427
+ */
428
+ async function findTypeScriptFiles(dir: string): Promise<string[]> {
429
+ const files: string[] = [];
430
+
431
+ async function walk(currentDir: string) {
432
+ try {
433
+ const entries = await readdir(currentDir, { withFileTypes: true });
434
+ for (const entry of entries) {
435
+ const fullPath = join(currentDir, entry.name);
436
+ if (entry.isDirectory()) {
437
+ // Skip node_modules, dist, examples, and hidden directories
438
+ if (entry.name === "node_modules" || entry.name === "dist" ||
439
+ entry.name === "examples" || entry.name.startsWith(".")) {
440
+ continue;
441
+ }
442
+ await walk(fullPath);
443
+ } else if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
444
+ // Only include type definition files and main files
445
+ const inSignersDir = currentDir.includes("/signers/") || currentDir.endsWith("/signers");
446
+ const inTypesDir = currentDir.includes("/types/") || currentDir.endsWith("/types");
447
+
448
+ if (entry.name === "types.ts" || entry.name === "index.ts" ||
449
+ entry.name === "signer.ts" || inSignersDir ||
450
+ inTypesDir || entry.name.includes("Client")) {
451
+ files.push(fullPath);
452
+ }
453
+ }
454
+ }
455
+ } catch (err) {
456
+ // Directory might not exist
457
+ }
458
+ }
459
+
460
+ await walk(dir);
461
+ return files;
462
+ }
463
+
464
+ export class DocumentIndex {
465
+ private docs: Map<string, DocEntry> = new Map();
466
+ private endpointIndex: Map<string, ApiEndpoint> = new Map();
467
+ private typeIndex: Map<string, TypeEntry> = new Map();
468
+ private docsDir: string;
469
+ private sdkDir: string;
470
+
471
+ constructor(docsDir: string, sdkDir: string) {
472
+ this.docsDir = docsDir;
473
+ this.sdkDir = sdkDir;
474
+ }
475
+
476
+ async build(): Promise<void> {
477
+ console.error("Building document index...");
478
+
479
+ // Index documentation
480
+ const docFiles = await findMarkdownFiles(this.docsDir);
481
+ await this.indexFiles(docFiles, this.docsDir, "docs");
482
+
483
+ // Index SDK READMEs and key files
484
+ const sdkFiles = await findMarkdownFiles(this.sdkDir);
485
+ await this.indexFiles(sdkFiles, this.sdkDir, "sdk");
486
+
487
+ // Index TypeScript types from SDK packages
488
+ console.error("Building TypeScript type index...");
489
+ const packagesDir = join(this.sdkDir, "packages");
490
+ const tsFiles = await findTypeScriptFiles(packagesDir);
491
+ await this.indexTypeScriptFiles(tsFiles);
492
+
493
+ console.error(`Indexed ${this.docs.size} documents, ${this.endpointIndex.size} endpoints, and ${this.typeIndex.size} types`);
494
+ }
495
+
496
+ /**
497
+ * Index TypeScript files for type definitions
498
+ */
499
+ private async indexTypeScriptFiles(files: string[]): Promise<void> {
500
+ for (const filePath of files) {
501
+ try {
502
+ const content = await readFile(filePath, "utf-8");
503
+ const types = parseTypeScriptTypes(content, filePath);
504
+ const { importPackage, importPath } = determinePackageName(filePath);
505
+ const category = determineTypeCategory(filePath);
506
+
507
+ for (const typeInfo of types) {
508
+ const entry: TypeEntry = {
509
+ name: typeInfo.name,
510
+ kind: typeInfo.kind,
511
+ definition: typeInfo.definition,
512
+ importPackage,
513
+ importPath,
514
+ category,
515
+ description: typeInfo.description,
516
+ sourceFile: filePath,
517
+ };
518
+
519
+ // Use lowercase name as key for case-insensitive lookup
520
+ this.typeIndex.set(typeInfo.name.toLowerCase(), entry);
521
+ }
522
+ } catch (err) {
523
+ console.error(`Failed to index types from ${filePath}:`, err);
524
+ }
525
+ }
526
+ }
527
+
528
+ private async indexFiles(files: string[], baseDir: string, prefix: string) {
529
+ for (const filePath of files) {
530
+ try {
531
+ const content = await readFile(filePath, "utf-8");
532
+ const relativePath = relative(baseDir, filePath);
533
+ const fullRelativePath = `${prefix}/${relativePath}`;
534
+
535
+ const entry: DocEntry = {
536
+ path: filePath,
537
+ relativePath: fullRelativePath,
538
+ title: extractTitle(content, filePath),
539
+ content,
540
+ category: determineCategory(filePath),
541
+ keywords: extractKeywords(content),
542
+ };
543
+
544
+ this.docs.set(fullRelativePath, entry);
545
+
546
+ // Index endpoints found in this file
547
+ this.extractEndpoints(content, fullRelativePath);
548
+ } catch (err) {
549
+ console.error(`Failed to index ${filePath}:`, err);
550
+ }
551
+ }
552
+ }
553
+
554
+ private extractEndpoints(content: string, docPath: string) {
555
+ // Look for patterns like "POST /wallets" or "GET /wallets/{id}"
556
+ // Common in headers or code blocks
557
+ const regex = /(GET|POST|PUT|DELETE|PATCH)\s+(\/[a-zA-Z0-9\-\/_{}]+)/g;
558
+ let match;
559
+ while ((match = regex.exec(content)) !== null) {
560
+ const [fullMatch, method, path] = match;
561
+ const key = `${method.toUpperCase()} ${path}`;
562
+
563
+ // Only index if not already present (prioritize first occurrence which is usually definition)
564
+ if (!this.endpointIndex.has(key)) {
565
+ this.endpointIndex.set(key, {
566
+ method: method.toUpperCase(),
567
+ path,
568
+ docPath
569
+ });
570
+ }
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Search documents by query
576
+ */
577
+ search(query: string, limit: number = 10): SearchResult[] {
578
+ const queryLower = query.toLowerCase();
579
+ const queryTerms = queryLower.split(/\s+/).filter((t) => t.length > 2);
580
+
581
+ const results: SearchResult[] = [];
582
+
583
+ for (const doc of this.docs.values()) {
584
+ let score = 0;
585
+ const contentLower = doc.content.toLowerCase();
586
+ const titleLower = doc.title.toLowerCase();
587
+
588
+ // Exact phrase match in title is highest value
589
+ if (titleLower.includes(queryLower)) {
590
+ score += 100;
591
+ }
592
+
593
+ // Exact phrase match in content
594
+ if (contentLower.includes(queryLower)) {
595
+ score += 30;
596
+ }
597
+
598
+ // Title matches individual terms
599
+ for (const term of queryTerms) {
600
+ if (titleLower.includes(term)) {
601
+ score += 15;
602
+ }
603
+ }
604
+
605
+ // Keyword matches
606
+ for (const keyword of doc.keywords) {
607
+ // Exact phrase match in keyword
608
+ if (keyword.includes(queryLower)) {
609
+ score += 20;
610
+ }
611
+ for (const term of queryTerms) {
612
+ if (keyword.includes(term)) {
613
+ score += 5;
614
+ }
615
+ }
616
+ }
617
+
618
+ // Content matches - but cap it to avoid long docs dominating
619
+ for (const term of queryTerms) {
620
+ const regex = new RegExp(term, "gi");
621
+ const matches = contentLower.match(regex);
622
+ if (matches) {
623
+ // Cap at 10 matches per term to avoid SUMMARY.md type docs
624
+ score += Math.min(matches.length, 10);
625
+ }
626
+ }
627
+
628
+ // Penalize index/summary files that match everything
629
+ if (doc.relativePath.includes("SUMMARY") || doc.relativePath.includes("README.md")) {
630
+ score = Math.floor(score * 0.5);
631
+ }
632
+
633
+ if (score > 0) {
634
+ // Extract relevant snippet
635
+ const snippet = this.extractSmartSnippet(doc.content, queryTerms, queryLower);
636
+
637
+ results.push({
638
+ path: doc.relativePath,
639
+ title: doc.title,
640
+ category: doc.category,
641
+ snippet,
642
+ score,
643
+ });
644
+ }
645
+ }
646
+
647
+ // Sort by score descending
648
+ results.sort((a, b) => b.score - a.score);
649
+
650
+ return results.slice(0, limit);
651
+ }
652
+
653
+ /**
654
+ * "Smart" snippet extraction:
655
+ * - Tries to return full paragraphs or code blocks
656
+ * - Avoids cutting off sentences
657
+ */
658
+ private extractSmartSnippet(content: string, queryTerms: string[], fullQuery: string): string {
659
+ const contentLower = content.toLowerCase();
660
+
661
+ // Find the best match position
662
+ let idx = contentLower.indexOf(fullQuery);
663
+ if (idx === -1) {
664
+ for (const term of queryTerms) {
665
+ idx = contentLower.indexOf(term);
666
+ if (idx !== -1) break;
667
+ }
668
+ }
669
+
670
+ if (idx === -1) {
671
+ // Fallback to start of file
672
+ return content.slice(0, 200).replace(/\n+/g, " ").trim() + "...";
673
+ }
674
+
675
+ // Expand to paragraph boundaries (double newlines)
676
+ const startSearch = Math.max(0, idx - 500);
677
+ const endSearch = Math.min(content.length, idx + 500);
678
+
679
+ const preText = content.slice(startSearch, idx);
680
+ const postText = content.slice(idx, endSearch);
681
+
682
+ // Find start of paragraph (last \n\n before match)
683
+ const paraStart = preText.lastIndexOf("\n\n");
684
+ const start = paraStart !== -1 ? startSearch + paraStart + 2 : Math.max(0, idx - 100);
685
+
686
+ // Find end of paragraph (first \n\n after match)
687
+ const paraEnd = postText.indexOf("\n\n");
688
+ const end = paraEnd !== -1 ? idx + paraEnd : Math.min(content.length, idx + 200);
689
+
690
+ // Check if we are inside a code block
691
+ // Simple heuristic: count backticks before match
692
+ const backticksBefore = (content.slice(0, idx).match(/```/g) || []).length;
693
+ if (backticksBefore % 2 !== 0) {
694
+ // We are likely inside a code block. Try to capture the whole block.
695
+ const blockStart = content.lastIndexOf("```", idx);
696
+ const blockEnd = content.indexOf("```", idx);
697
+ if (blockStart !== -1 && blockEnd !== -1) {
698
+ return content.slice(blockStart, blockEnd + 3);
699
+ }
700
+ }
701
+
702
+ let snippet = content.slice(start, end).trim();
703
+ if (start > 0) snippet = "..." + snippet;
704
+ if (end < content.length) snippet = snippet + "...";
705
+
706
+ return snippet;
707
+ }
708
+
709
+ /**
710
+ * Get a specific document by path
711
+ */
712
+ getDocument(path: string): DocEntry | undefined {
713
+ // Try exact match first
714
+ if (this.docs.has(path)) {
715
+ return this.docs.get(path);
716
+ }
717
+ return undefined;
718
+ }
719
+
720
+ /**
721
+ * Find potential matches for a partial path
722
+ */
723
+ findDocuments(partialPath: string): DocEntry[] {
724
+ const matches: DocEntry[] = [];
725
+ for (const [key, doc] of this.docs) {
726
+ if (key.includes(partialPath) || partialPath.includes(key)) {
727
+ matches.push(doc);
728
+ }
729
+ }
730
+ return matches;
731
+ }
732
+
733
+ /**
734
+ * Get endpoint details directly
735
+ */
736
+ getEndpoint(method: string, path: string): ApiEndpoint | undefined {
737
+ const key = `${method.toUpperCase()} ${path}`;
738
+ return this.endpointIndex.get(key);
739
+ }
740
+
741
+ /**
742
+ * Get all indexed endpoints
743
+ */
744
+ getAllEndpoints(): ApiEndpoint[] {
745
+ return Array.from(this.endpointIndex.values());
746
+ }
747
+
748
+ /**
749
+ * Extract code examples related to a query
750
+ */
751
+ getCodeExamples(query: string, limit: number = 5): Array<{ title: string; language: string; code: string }> {
752
+ // First find relevant docs
753
+ const docs = this.search(query, limit);
754
+ const examples: Array<{ title: string; language: string; code: string }> = [];
755
+
756
+ for (const res of docs) {
757
+ const doc = this.docs.get(res.path);
758
+ if (!doc) continue;
759
+
760
+ // Extract code blocks
761
+ const regex = /```(\w+)?\n([\s\S]*?)```/g;
762
+ let match;
763
+ while ((match = regex.exec(doc.content)) !== null) {
764
+ const [_, language, code] = match;
765
+ if (code.length > 20) { // Filter out tiny snippets
766
+ examples.push({
767
+ title: doc.title,
768
+ language: language || "text",
769
+ code: code.trim()
770
+ });
771
+ }
772
+ if (examples.length >= limit) break;
773
+ }
774
+ if (examples.length >= limit) break;
775
+ }
776
+
777
+ return examples;
778
+ }
779
+
780
+ /**
781
+ * List all documents, optionally filtered by category
782
+ */
783
+ listDocuments(category?: string): Array<{ path: string; title: string; category: string }> {
784
+ const results: Array<{ path: string; title: string; category: string }> = [];
785
+
786
+ for (const doc of this.docs.values()) {
787
+ if (!category || doc.category.toLowerCase().includes(category.toLowerCase())) {
788
+ results.push({
789
+ path: doc.relativePath,
790
+ title: doc.title,
791
+ category: doc.category,
792
+ });
793
+ }
794
+ }
795
+
796
+ return results.sort((a, b) => a.category.localeCompare(b.category));
797
+ }
798
+
799
+ /**
800
+ * Get all unique categories
801
+ */
802
+ getCategories(): string[] {
803
+ const categories = new Set<string>();
804
+ for (const doc of this.docs.values()) {
805
+ categories.add(doc.category);
806
+ }
807
+ return Array.from(categories).sort();
808
+ }
809
+
810
+ // ============================================================================
811
+ // Type Index Methods
812
+ // ============================================================================
813
+
814
+ /**
815
+ * Get a type by exact name (case-insensitive)
816
+ */
817
+ getType(name: string): TypeEntry | undefined {
818
+ return this.typeIndex.get(name.toLowerCase());
819
+ }
820
+
821
+ /**
822
+ * Search types by name pattern
823
+ */
824
+ searchTypes(query: string, limit: number = 20): TypeEntry[] {
825
+ const queryLower = query.toLowerCase();
826
+ const results: Array<{ entry: TypeEntry; score: number }> = [];
827
+
828
+ for (const entry of this.typeIndex.values()) {
829
+ const nameLower = entry.name.toLowerCase();
830
+ let score = 0;
831
+
832
+ // Exact match is highest
833
+ if (nameLower === queryLower) {
834
+ score = 1000;
835
+ }
836
+ // Starts with query
837
+ else if (nameLower.startsWith(queryLower)) {
838
+ score = 500;
839
+ }
840
+ // Contains query
841
+ else if (nameLower.includes(queryLower)) {
842
+ score = 100;
843
+ }
844
+ // Word boundary match (e.g., "Wallet" matches "CreateWalletRequest")
845
+ else {
846
+ // Split camelCase/PascalCase into words
847
+ const words = entry.name.split(/(?=[A-Z])/).map(w => w.toLowerCase());
848
+ for (const word of words) {
849
+ if (word === queryLower) {
850
+ score = 200;
851
+ break;
852
+ }
853
+ if (word.startsWith(queryLower)) {
854
+ score = Math.max(score, 50);
855
+ }
856
+ }
857
+ }
858
+
859
+ // Also check description
860
+ if (score === 0 && entry.description.toLowerCase().includes(queryLower)) {
861
+ score = 25;
862
+ }
863
+
864
+ if (score > 0) {
865
+ results.push({ entry, score });
866
+ }
867
+ }
868
+
869
+ // Sort by score descending, then by name length (prefer shorter names)
870
+ results.sort((a, b) => {
871
+ if (b.score !== a.score) return b.score - a.score;
872
+ return a.entry.name.length - b.entry.name.length;
873
+ });
874
+
875
+ return results.slice(0, limit).map(r => r.entry);
876
+ }
877
+
878
+ /**
879
+ * List all types, optionally filtered by category
880
+ */
881
+ listTypes(category?: string): Array<{ name: string; kind: string; category: string; importPath: string }> {
882
+ const results: Array<{ name: string; kind: string; category: string; importPath: string }> = [];
883
+
884
+ for (const entry of this.typeIndex.values()) {
885
+ if (!category || entry.category.toLowerCase().includes(category.toLowerCase())) {
886
+ results.push({
887
+ name: entry.name,
888
+ kind: entry.kind,
889
+ category: entry.category,
890
+ importPath: entry.importPath,
891
+ });
892
+ }
893
+ }
894
+
895
+ return results.sort((a, b) => a.name.localeCompare(b.name));
896
+ }
897
+
898
+ /**
899
+ * Get all unique type categories
900
+ */
901
+ getTypeCategories(): string[] {
902
+ const categories = new Set<string>();
903
+ for (const entry of this.typeIndex.values()) {
904
+ categories.add(entry.category);
905
+ }
906
+ return Array.from(categories).sort();
907
+ }
908
+
909
+ /**
910
+ * Get total type count
911
+ */
912
+ getTypeCount(): number {
913
+ return this.typeIndex.size;
914
+ }
915
+ }