@dreki-gg/pi-doc-search 0.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.
@@ -0,0 +1,615 @@
1
+ import type { ExtensionAPI, ToolDefinition } from '@earendil-works/pi-coding-agent';
2
+ import { Type, type TSchema } from 'typebox';
3
+ import { fetchLibraryDocs, searchLibraries } from './api';
4
+ import {
5
+ buildDocRef,
6
+ extractVersionInfo,
7
+ findDocCacheCandidates,
8
+ getDocCacheByRef,
9
+ getResolveCache,
10
+ loadDocCacheObject,
11
+ putDocCache,
12
+ putResolveCache,
13
+ } from './cache';
14
+ import { loadSettings } from './config';
15
+ import { curateDocText } from './curation';
16
+ import type {
17
+ CacheSearchSelector,
18
+ DocCacheIndexEntry,
19
+ GetCachedDocRawParams,
20
+ GetLibraryDocsParams,
21
+ ResolveLibraryParams,
22
+ SearchResponse,
23
+ SearchResult,
24
+ } from './types';
25
+
26
+ function normalizeText(value?: string): string {
27
+ return (value ?? '').trim().toLowerCase();
28
+ }
29
+
30
+ function displayTitle(result?: SearchResult): string {
31
+ if (!result) return '';
32
+ return result.title || result.id;
33
+ }
34
+
35
+ function snippetCount(result: SearchResult): number {
36
+ return typeof result.totalSnippets === 'number' && result.totalSnippets > 0
37
+ ? result.totalSnippets
38
+ : 0;
39
+ }
40
+
41
+ function benchmarkScore(result: SearchResult): number {
42
+ return typeof result.benchmarkScore === 'number' ? result.benchmarkScore : 0;
43
+ }
44
+
45
+ function trustScore(result: SearchResult): number {
46
+ return typeof result.trustScore === 'number' ? result.trustScore : 0;
47
+ }
48
+
49
+ function selectBestLibrary(
50
+ results: SearchResult[],
51
+ libraryName: string,
52
+ ): { selected?: SearchResult; ambiguous: boolean } {
53
+ if (results.length === 0) return { ambiguous: false };
54
+ if (results.length === 1) return { selected: results[0], ambiguous: false };
55
+
56
+ const target = normalizeText(libraryName).replace(/^@/, '');
57
+ const scored = results
58
+ .map((result) => {
59
+ const title = normalizeText(result.title);
60
+ const id = normalizeText(result.id);
61
+ let score =
62
+ benchmarkScore(result) / 10 + trustScore(result) + Math.min(snippetCount(result), 100) / 20;
63
+ if (title === target) score += 25;
64
+ if (title.includes(target)) score += 12;
65
+ if (id.endsWith(`/${target}`) || id.includes(`/${target}/`)) score += 12;
66
+ return { result, score };
67
+ })
68
+ .sort((a, b) => b.score - a.score);
69
+
70
+ const [top, second] = scored;
71
+ if (!second) return { selected: top.result, ambiguous: false };
72
+
73
+ const clearByExactMatch =
74
+ normalizeText(top.result.title) === target ||
75
+ normalizeText(top.result.id).endsWith(`/${target}`) ||
76
+ top.score >= second.score + 8;
77
+
78
+ if (clearByExactMatch) return { selected: top.result, ambiguous: false };
79
+ return { ambiguous: true };
80
+ }
81
+
82
+ function formatResolveCandidate(result: SearchResult): string {
83
+ const parts = [
84
+ `- Title: ${displayTitle(result)}`,
85
+ ` Library ID: ${result.id}`,
86
+ ` Description: ${result.description}`,
87
+ ];
88
+
89
+ if (snippetCount(result) > 0) parts.push(` Code Snippets: ${snippetCount(result)}`);
90
+ if (trustScore(result) > 0) parts.push(` Source Reputation Score: ${trustScore(result)}`);
91
+ if (benchmarkScore(result) > 0) parts.push(` Benchmark Score: ${benchmarkScore(result)}`);
92
+ if (result.versions?.length) parts.push(` Versions: ${result.versions.join(', ')}`);
93
+ if (result.source) parts.push(` Source: ${result.source}`);
94
+
95
+ return parts.join('\n');
96
+ }
97
+
98
+ function formatResolveResults(
99
+ response: SearchResponse,
100
+ libraryName: string,
101
+ ): { text: string; recommended?: SearchResult; ambiguous: boolean } {
102
+ if (!response.results.length) {
103
+ return {
104
+ text: `No Context7 libraries matched "${libraryName}".`,
105
+ ambiguous: false,
106
+ };
107
+ }
108
+
109
+ const recommendation = selectBestLibrary(response.results, libraryName);
110
+ const header = [
111
+ `Context7 matches for "${libraryName}":`,
112
+ recommendation.selected ? `Recommended library ID: ${recommendation.selected.id}` : undefined,
113
+ recommendation.ambiguous
114
+ ? 'Auto-resolution is ambiguous; choose one of the candidates below.'
115
+ : undefined,
116
+ '',
117
+ ].filter(Boolean) as string[];
118
+
119
+ const body = response.results.slice(0, 8).map(formatResolveCandidate).join('\n----------\n');
120
+ return {
121
+ text: `${header.join('\n')}${body}`,
122
+ recommended: recommendation.selected,
123
+ ambiguous: recommendation.ambiguous,
124
+ };
125
+ }
126
+
127
+ function buildEffectiveQuery(query?: string, topic?: string, page?: number): string {
128
+ const parts = [query?.trim(), topic?.trim() ? `Focus: ${topic.trim()}` : undefined];
129
+ if (typeof page === 'number' && page > 1) parts.push(`Requested page: ${page}`);
130
+ return parts.filter(Boolean).join('\n\n') || 'overview';
131
+ }
132
+
133
+ function summarizeError(
134
+ error: { message: string; upstreamMessage?: string },
135
+ extra?: string,
136
+ ): string {
137
+ return [error.message, extra].filter(Boolean).join(' ');
138
+ }
139
+
140
+ async function resolveLibraries(params: ResolveLibraryParams) {
141
+ const settings = await loadSettings();
142
+ const query = params.query?.trim() || params.libraryName.trim();
143
+ const cached = await getResolveCache(settings, params.libraryName, query);
144
+
145
+ if (cached.entry && cached.fresh) {
146
+ return {
147
+ source: 'fresh-cache' as const,
148
+ response: { results: cached.entry.results },
149
+ configError: settings.configError,
150
+ };
151
+ }
152
+
153
+ const network = await searchLibraries(settings, { libraryName: params.libraryName, query });
154
+ if (network.ok) {
155
+ const entry = await putResolveCache(settings, {
156
+ libraryName: params.libraryName,
157
+ query,
158
+ results: network.data.results,
159
+ });
160
+ return {
161
+ source: 'network' as const,
162
+ response: { results: entry.results, searchFilterApplied: network.data.searchFilterApplied },
163
+ configError: settings.configError,
164
+ };
165
+ }
166
+
167
+ if (cached.entry) {
168
+ return {
169
+ source: 'stale-cache' as const,
170
+ response: { results: cached.entry.results },
171
+ configError: settings.configError,
172
+ staleBecause: network.error,
173
+ };
174
+ }
175
+
176
+ throw Object.assign(
177
+ new Error(
178
+ summarizeError(
179
+ network.error,
180
+ settings.configError ? `Config parse issue: ${settings.configError}` : undefined,
181
+ ),
182
+ ),
183
+ {
184
+ details: { upstreamError: network.error, configError: settings.configError },
185
+ },
186
+ );
187
+ }
188
+
189
+ function formatCacheCandidates(candidates: DocCacheIndexEntry[]): string {
190
+ return candidates
191
+ .slice(0, 8)
192
+ .map((candidate) => {
193
+ const parts = [
194
+ `- docRef: ${candidate.docRef}`,
195
+ ` Library: ${candidate.libraryName}`,
196
+ ` Library ID: ${candidate.libraryId}`,
197
+ candidate.libraryVersion ? ` Version: ${candidate.libraryVersion}` : undefined,
198
+ candidate.topic ? ` Topic: ${candidate.topic}` : undefined,
199
+ ` Query: ${candidate.query}`,
200
+ ` Page: ${candidate.page}`,
201
+ ` Cached At: ${candidate.createdAt}`,
202
+ ].filter(Boolean);
203
+ return parts.join('\n');
204
+ })
205
+ .join('\n----------\n');
206
+ }
207
+
208
+ function normalizedLibraryNameFromResult(result?: SearchResult, fallback?: string): string {
209
+ return normalizeText(result?.title || fallback || 'unknown-library');
210
+ }
211
+
212
+ async function getDocsEntry(params: GetLibraryDocsParams) {
213
+ const settings = await loadSettings();
214
+ const page =
215
+ typeof params.page === 'number' && Number.isFinite(params.page)
216
+ ? Math.max(1, Math.floor(params.page))
217
+ : 1;
218
+ const effectiveQuery = buildEffectiveQuery(params.query, params.topic, page);
219
+
220
+ let libraryId = params.libraryId?.trim();
221
+ let libraryName = params.libraryName?.trim();
222
+ let resolvedResult: SearchResult | undefined;
223
+ let resolveMetadata: Record<string, unknown> | undefined;
224
+
225
+ if (!libraryId) {
226
+ if (!libraryName) {
227
+ throw new Error('doc_search_get_library_docs requires either libraryId or libraryName.');
228
+ }
229
+
230
+ const resolveResult = await resolveLibraries({ libraryName, query: params.query });
231
+ const formatted = formatResolveResults(resolveResult.response, libraryName);
232
+ resolveMetadata = {
233
+ source: resolveResult.source,
234
+ staleBecause: resolveResult.staleBecause,
235
+ configError: resolveResult.configError,
236
+ };
237
+
238
+ if (!formatted.recommended || formatted.ambiguous) {
239
+ return {
240
+ ok: false as const,
241
+ text: `${formatted.text}\n\nUnable to safely auto-resolve before fetching docs. Call doc_search_resolve_library_id first or pass an exact libraryId.`,
242
+ details: {
243
+ needsResolution: true,
244
+ candidates: resolveResult.response.results.slice(0, 8),
245
+ resolve: resolveMetadata,
246
+ },
247
+ };
248
+ }
249
+
250
+ libraryId = formatted.recommended.id;
251
+ resolvedResult = formatted.recommended;
252
+ libraryName = displayTitle(formatted.recommended);
253
+ }
254
+
255
+ const docRef = buildDocRef(libraryId, effectiveQuery, page);
256
+ const cached = await getDocCacheByRef(settings, docRef);
257
+ if (cached.entry && cached.fresh) {
258
+ return {
259
+ ok: true as const,
260
+ entry: cached.entry,
261
+ source: 'fresh-cache' as const,
262
+ details: {
263
+ docRef,
264
+ cacheStatus: 'fresh',
265
+ resolve: resolveMetadata,
266
+ configError: settings.configError,
267
+ },
268
+ };
269
+ }
270
+
271
+ const network = await fetchLibraryDocs(settings, { libraryId, query: effectiveQuery });
272
+ if (network.ok) {
273
+ const version = extractVersionInfo(libraryId);
274
+ const curated = curateDocText({
275
+ rawText: network.data,
276
+ libraryId,
277
+ libraryName: libraryName || displayTitle(resolvedResult) || libraryId,
278
+ libraryVersion: version.normalized,
279
+ query: params.query,
280
+ topic: params.topic,
281
+ page,
282
+ docRef,
283
+ });
284
+
285
+ const entry = await putDocCache(settings, {
286
+ docRef,
287
+ libraryId,
288
+ libraryName: libraryName || displayTitle(resolvedResult) || libraryId,
289
+ normalizedLibraryName: normalizedLibraryNameFromResult(resolvedResult, libraryName),
290
+ libraryVersion: version.normalized,
291
+ libraryVersionRaw: version.raw,
292
+ query: params.query?.trim() || 'overview',
293
+ topic: params.topic?.trim(),
294
+ effectiveQuery,
295
+ page,
296
+ rawText: network.data,
297
+ curatedText: curated.text,
298
+ });
299
+
300
+ return {
301
+ ok: true as const,
302
+ entry,
303
+ source: 'network' as const,
304
+ details: {
305
+ docRef,
306
+ cacheStatus: 'refreshed',
307
+ resolve: resolveMetadata,
308
+ curation: {
309
+ truncated: curated.truncated,
310
+ selectedSectionCount: curated.selectedSectionCount,
311
+ rawLength: curated.rawLength,
312
+ },
313
+ configError: settings.configError,
314
+ },
315
+ };
316
+ }
317
+
318
+ if (cached.entry) {
319
+ return {
320
+ ok: true as const,
321
+ entry: cached.entry,
322
+ source: 'stale-cache' as const,
323
+ details: {
324
+ docRef,
325
+ cacheStatus: 'stale',
326
+ staleBecause: network.error,
327
+ resolve: resolveMetadata,
328
+ configError: settings.configError,
329
+ },
330
+ };
331
+ }
332
+
333
+ throw Object.assign(
334
+ new Error(
335
+ summarizeError(
336
+ network.error,
337
+ settings.configError ? `Config parse issue: ${settings.configError}` : undefined,
338
+ ),
339
+ ),
340
+ {
341
+ details: {
342
+ upstreamError: network.error,
343
+ resolve: resolveMetadata,
344
+ configError: settings.configError,
345
+ },
346
+ },
347
+ );
348
+ }
349
+
350
+ function defineDocSearchTool<TParams extends TSchema>(
351
+ tool: ToolDefinition<TParams, Record<string, unknown>>,
352
+ ): ToolDefinition<TParams, Record<string, unknown>> {
353
+ return tool;
354
+ }
355
+
356
+ function createResolveTool(name: string, description: string, promptVisible: boolean) {
357
+ return defineDocSearchTool({
358
+ name,
359
+ label: 'Doc Search: Resolve Library ID',
360
+ description,
361
+ ...(promptVisible
362
+ ? {
363
+ promptSnippet:
364
+ 'Pin an ambiguous library name to one exact Context7 ID before fetching its docs.',
365
+ promptGuidelines: [
366
+ 'Reach for doc_search_resolve_library_id only when doc_search_get_library_docs reports an ambiguous match or you must pick one library among several — for the common case let doc_search_get_library_docs resolve the name itself.',
367
+ ],
368
+ }
369
+ : {}),
370
+ parameters: Type.Object({
371
+ libraryName: Type.String({ description: 'Library or package name to resolve in Context7.' }),
372
+ query: Type.Optional(
373
+ Type.String({
374
+ description: 'Optional task or intent to help rank the best Context7 library match.',
375
+ }),
376
+ ),
377
+ }),
378
+ async execute(_toolCallId: string, params: ResolveLibraryParams) {
379
+ const resolved = await resolveLibraries(params);
380
+ const formatted = formatResolveResults(resolved.response, params.libraryName);
381
+ return {
382
+ content: [{ type: 'text' as const, text: formatted.text }],
383
+ details: {
384
+ source: resolved.source,
385
+ staleBecause: resolved.staleBecause,
386
+ configError: resolved.configError,
387
+ recommendedLibraryId: formatted.recommended?.id,
388
+ ambiguous: formatted.ambiguous,
389
+ results: resolved.response.results,
390
+ },
391
+ };
392
+ },
393
+ });
394
+ }
395
+
396
+ function createDocsTool(
397
+ name: string,
398
+ description: string,
399
+ promptVisible: boolean,
400
+ prepareArguments?: (args: unknown) => GetLibraryDocsParams,
401
+ ) {
402
+ return defineDocSearchTool({
403
+ name,
404
+ label: 'Doc Search: Get Library Docs',
405
+ description,
406
+ ...(promptVisible
407
+ ? {
408
+ promptSnippet:
409
+ 'Pull current, version-accurate library docs before coding against a third-party API instead of trusting trained-in memory.',
410
+ promptGuidelines: [
411
+ 'Call doc_search_get_library_docs before writing or editing code that imports, configures, or calls a third-party library — treat your trained-in knowledge of its API as stale until confirmed, especially for versioned or recently changed surfaces.',
412
+ 'Pass libraryName and let doc_search_get_library_docs auto-resolve in a single call; only fall back to doc_search_resolve_library_id when the match comes back ambiguous.',
413
+ ],
414
+ }
415
+ : {}),
416
+ parameters: Type.Object({
417
+ libraryId: Type.Optional(
418
+ Type.String({ description: 'Exact Context7 library ID, such as /vercel/next.js.' }),
419
+ ),
420
+ libraryName: Type.Optional(
421
+ Type.String({
422
+ description: 'Library or package name when an exact Context7 ID is not known.',
423
+ }),
424
+ ),
425
+ query: Type.Optional(
426
+ Type.String({ description: 'Freeform documentation request or question.' }),
427
+ ),
428
+ topic: Type.Optional(Type.String({ description: 'Optional focused topic inside the docs.' })),
429
+ page: Type.Optional(
430
+ Type.Number({
431
+ description: 'Optional logical page number for repeated docs retrieval attempts.',
432
+ }),
433
+ ),
434
+ }),
435
+ prepareArguments,
436
+ async execute(_toolCallId: string, params: GetLibraryDocsParams) {
437
+ const result = await getDocsEntry(params);
438
+ if (!result.ok) {
439
+ return {
440
+ content: [{ type: 'text' as const, text: result.text }],
441
+ details: result.details,
442
+ };
443
+ }
444
+
445
+ const entry = result.entry;
446
+ const notes: string[] = [];
447
+ if (result.source === 'stale-cache')
448
+ notes.push('Returned stale cached docs because the network refresh failed.');
449
+ if (result.source === 'fresh-cache') notes.push('Returned fresh cached docs.');
450
+ if (result.source === 'network')
451
+ notes.push('Fetched docs from Context7 and refreshed the local cache.');
452
+
453
+ return {
454
+ content: [
455
+ { type: 'text' as const, text: `${entry.curatedText}\n\n${notes.join(' ')}`.trim() },
456
+ ],
457
+ details: {
458
+ ...result.details,
459
+ docRef: entry.docRef,
460
+ libraryId: entry.libraryId,
461
+ libraryName: entry.libraryName,
462
+ libraryVersion: entry.libraryVersion,
463
+ page: entry.page,
464
+ query: entry.query,
465
+ topic: entry.topic,
466
+ effectiveQuery: entry.effectiveQuery,
467
+ },
468
+ };
469
+ },
470
+ });
471
+ }
472
+
473
+ function createRawDocsTool() {
474
+ return defineDocSearchTool({
475
+ name: 'doc_search_get_cached_doc_raw',
476
+ label: 'Doc Search: Get Cached Raw Doc',
477
+ description:
478
+ 'Read the raw cached Context7 document for a previous docs fetch. Prefer doc_search_get_library_docs first.',
479
+ promptSnippet:
480
+ 'Open the full raw cached document when a curated Context7 excerpt dropped detail you still need.',
481
+ promptGuidelines: [
482
+ 'Use doc_search_get_cached_doc_raw only after doc_search_get_library_docs, when the curated excerpt omitted detail you need — pass the docRef it returned rather than re-describing the lookup.',
483
+ 'Semantic lookup that matches several cached docs returns the candidates instead of guessing; narrow it with docRef or version.',
484
+ ],
485
+ parameters: Type.Object({
486
+ docRef: Type.Optional(
487
+ Type.String({ description: 'Exact docRef returned by doc_search_get_library_docs.' }),
488
+ ),
489
+ libraryId: Type.Optional(
490
+ Type.String({ description: 'Optional Context7 library ID for semantic cache lookup.' }),
491
+ ),
492
+ libraryName: Type.Optional(
493
+ Type.String({ description: 'Optional library name for semantic cache lookup.' }),
494
+ ),
495
+ libraryVersion: Type.Optional(
496
+ Type.String({ description: 'Optional library version for semantic cache lookup.' }),
497
+ ),
498
+ query: Type.Optional(
499
+ Type.String({ description: 'Optional original query used to fetch docs.' }),
500
+ ),
501
+ topic: Type.Optional(
502
+ Type.String({ description: 'Optional original topic used to fetch docs.' }),
503
+ ),
504
+ page: Type.Optional(Type.Number({ description: 'Optional original logical page number.' })),
505
+ }),
506
+ async execute(_toolCallId: string, params: GetCachedDocRawParams) {
507
+ const settings = await loadSettings();
508
+ const selector: CacheSearchSelector = {
509
+ docRef: params.docRef?.trim(),
510
+ libraryId: params.libraryId?.trim(),
511
+ libraryName: params.libraryName?.trim(),
512
+ libraryVersion: params.libraryVersion?.trim(),
513
+ query: params.query?.trim(),
514
+ topic: params.topic?.trim(),
515
+ page: typeof params.page === 'number' ? Math.max(1, Math.floor(params.page)) : undefined,
516
+ };
517
+
518
+ if (selector.docRef) {
519
+ const cached = await getDocCacheByRef(settings, selector.docRef);
520
+ if (!cached.entry) {
521
+ throw new Error(
522
+ `No cached Context7 document found for docRef ${selector.docRef}. Call doc_search_get_library_docs first.`,
523
+ );
524
+ }
525
+ return {
526
+ content: [{ type: 'text' as const, text: cached.entry.rawText }],
527
+ details: {
528
+ docRef: cached.entry.docRef,
529
+ libraryId: cached.entry.libraryId,
530
+ libraryName: cached.entry.libraryName,
531
+ libraryVersion: cached.entry.libraryVersion,
532
+ cacheStatus: cached.fresh ? 'fresh' : 'stale',
533
+ query: cached.entry.query,
534
+ topic: cached.entry.topic,
535
+ page: cached.entry.page,
536
+ },
537
+ };
538
+ }
539
+
540
+ if (
541
+ !selector.libraryId &&
542
+ !selector.libraryName &&
543
+ !selector.libraryVersion &&
544
+ !selector.query &&
545
+ !selector.topic &&
546
+ selector.page === undefined
547
+ ) {
548
+ throw new Error('doc_search_get_cached_doc_raw requires docRef or semantic lookup fields.');
549
+ }
550
+
551
+ const candidates = await findDocCacheCandidates(settings, selector);
552
+ if (candidates.length === 0) {
553
+ throw new Error(
554
+ 'No cached Context7 documents matched that lookup. Call doc_search_get_library_docs first.',
555
+ );
556
+ }
557
+
558
+ if (candidates.length > 1) {
559
+ return {
560
+ content: [
561
+ {
562
+ type: 'text' as const,
563
+ text: `Multiple cached Context7 documents matched that lookup. Refine the lookup or use one of these docRefs:\n\n${formatCacheCandidates(candidates)}`,
564
+ },
565
+ ],
566
+ details: {
567
+ ambiguous: true,
568
+ candidates,
569
+ },
570
+ };
571
+ }
572
+
573
+ const entry = await loadDocCacheObject(settings, candidates[0]);
574
+ if (!entry) {
575
+ throw new Error(
576
+ `Cached Context7 document metadata exists for ${candidates[0].docRef}, but the object file is missing.`,
577
+ );
578
+ }
579
+
580
+ return {
581
+ content: [{ type: 'text' as const, text: entry.rawText }],
582
+ details: {
583
+ docRef: entry.docRef,
584
+ libraryId: entry.libraryId,
585
+ libraryName: entry.libraryName,
586
+ libraryVersion: entry.libraryVersion,
587
+ cacheStatus: new Date(entry.expiresAt).getTime() > Date.now() ? 'fresh' : 'stale',
588
+ query: entry.query,
589
+ topic: entry.topic,
590
+ page: entry.page,
591
+ },
592
+ };
593
+ },
594
+ });
595
+ }
596
+
597
+ export function registerDocSearchTools(pi: ExtensionAPI) {
598
+ pi.registerTool(
599
+ createResolveTool(
600
+ 'doc_search_resolve_library_id',
601
+ 'Resolve a library or package name to one exact Context7 library ID when the name is ambiguous. Most callers should skip this and pass a name straight to doc_search_get_library_docs.',
602
+ true,
603
+ ),
604
+ );
605
+
606
+ pi.registerTool(
607
+ createDocsTool(
608
+ 'doc_search_get_library_docs',
609
+ 'Fetch current, version-accurate library documentation from Context7 by name or ID. Use before coding against a third-party API instead of relying on trained-in memory.',
610
+ true,
611
+ ),
612
+ );
613
+
614
+ pi.registerTool(createRawDocsTool());
615
+ }