@mario-gc/pi-context7 0.1.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mario
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @mario-gc/pi-context7
2
+
3
+ Context7 integration for pi coding agent. Fetch up-to-date library documentation and code examples directly from Context7.
4
+
5
+ ## Installation
6
+
7
+ **Global install (npm):**
8
+ ```bash
9
+ pi install npm:@mario-gc/pi-context7
10
+ ```
11
+
12
+ **Global install (GitHub):**
13
+ ```bash
14
+ pi install git:github.com/mario-gc/pi-context7
15
+ ```
16
+
17
+ **Project install (adds to `.pi/settings.json`):**
18
+ ```bash
19
+ pi install -l npm:@mario-gc/pi-context7
20
+ pi install -l git:github.com/mario-gc/pi-context7
21
+ ```
22
+
23
+ **Local development:**
24
+ ```bash
25
+ pi -e ./extensions/context7.ts
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ This package provides two tools for the agent:
31
+
32
+ 1. **context7_search_library** — Search Context7 for libraries by name. Resolves a library name to a Context7 library ID.
33
+ 2. **context7_get_context** — Get up-to-date documentation context and code examples for a library from Context7.
34
+
35
+ Workflow: search for a library → get documentation context for code examples and API reference.
36
+
37
+ ### Skill
38
+
39
+ A companion skill (`context7`) is also available. Use `/skill:context7` to load it.
40
+
41
+ ## API Key Setup
42
+
43
+ Context7 uses an API key for authenticated access (higher rate limits). Unauthenticated requests work but have stricter rate limits.
44
+
45
+ **Environment variable:**
46
+ ```bash
47
+ export CONTEXT7_API_KEY=ctx7sk-your-api-key-here
48
+ ```
49
+
50
+ **Auth file** (`~/.pi/agent/auth.json`):
51
+ ```json
52
+ {
53
+ "context7": {
54
+ "apiKey": "ctx7sk-your-api-key-here"
55
+ }
56
+ }
57
+ ```
58
+
59
+ Generate an API key at [https://context7.com/dashboard](https://context7.com/dashboard).
60
+
61
+ ## Cache
62
+
63
+ Responses are cached locally for performance and offline resilience.
64
+
65
+ **Cache location:** `~/.pi/agent/cache/context7/`
66
+
67
+ - Search results cached for 7 days
68
+ - Documentation context cached for 3 days
69
+
70
+ **Offline mode:** Set `PI_OFFLINE=1` to use cached results only:
71
+ ```bash
72
+ PI_OFFLINE=1 pi -e ./extensions/context7.ts
73
+ ```
74
+
75
+ **Cache TTL override:** Set `CONTEXT7_CACHE_TTL` in minutes:
76
+ ```bash
77
+ CONTEXT7_CACHE_TTL=60 pi -e ./extensions/context7.ts
78
+ ```
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,597 @@
1
+ /**
2
+ * Context7 Cache Layer
3
+ *
4
+ * A BM25-backed semantic cache for Context7 API responses. Stores search results
5
+ * and documentation context on disk, retrieves them by exact MD5 match or BM25
6
+ * semantic similarity. Parallel-safe with zero runtime dependencies.
7
+ *
8
+ * On-disk structure:
9
+ * ~/.pi/agent/cache/context7/
10
+ * ├── libraries/ # Cached search response files (hash.json)
11
+ * ├── libraries.json # Manifest for search cache
12
+ * ├── contexts/ # Cached context response files (hash.json)
13
+ * └── contexts.json # Manifest for context cache
14
+ *
15
+ * @module extensions/cache
16
+ */
17
+
18
+ import { mkdir, readFile, writeFile, rename, readdir, unlink } from "node:fs/promises";
19
+ import { join } from "node:path";
20
+ import { createHash } from "node:crypto";
21
+ import { homedir } from "node:os";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Constants
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const CACHE_ROOT = join(homedir(), ".pi", "agent", "cache", "context7");
28
+
29
+ /** Subdirectory name for each endpoint. */
30
+ const DIR_NAMES: Record<string, string> = {
31
+ search: "libraries",
32
+ context: "contexts",
33
+ };
34
+
35
+ /** Default TTLs in seconds. */
36
+ const DEFAULT_TTL: Record<string, number> = {
37
+ search: 604_800, // 7 days
38
+ context: 259_200, // 3 days
39
+ };
40
+
41
+ const MAX_CACHE_SIZE = 52_428_800; // 50 MB in bytes
42
+
43
+ const BM25_K1 = 1.2;
44
+ const BM25_B = 0.75;
45
+ const BM25_CONSTANT_IDF = 1.5;
46
+
47
+ const BM25_THRESHOLD_ONLINE = 0.7;
48
+ const BM25_THRESHOLD_OFFLINE = 0.5;
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Types
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export interface CacheEntry {
55
+ response: unknown;
56
+ cachedAt: number; // Unix timestamp ms
57
+ ttl: number; // Seconds
58
+ }
59
+
60
+ export interface CacheResult {
61
+ data: unknown;
62
+ source: "exact" | "bm25" | "stale" | null;
63
+ entry?: CacheEntry;
64
+ }
65
+
66
+ interface ManifestEntry {
67
+ scope: Record<string, string>;
68
+ query: string;
69
+ hash: string;
70
+ cachedAt: number;
71
+ ttl: number;
72
+ size: number;
73
+ }
74
+
75
+ interface Manifest {
76
+ entries: ManifestEntry[];
77
+ }
78
+
79
+ export interface CacheModule {
80
+ init(): Promise<void>;
81
+ get(
82
+ endpoint: "search" | "context",
83
+ scope: Record<string, string>,
84
+ params: Record<string, string | boolean | undefined>,
85
+ ): Promise<CacheResult>;
86
+ set(
87
+ endpoint: "search" | "context",
88
+ scope: Record<string, string>,
89
+ params: Record<string, string | boolean | undefined>,
90
+ data: unknown,
91
+ ): Promise<void>;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Module state
96
+ // ---------------------------------------------------------------------------
97
+
98
+ let initialized = false;
99
+ const manifests: Record<string, Manifest> = {
100
+ search: { entries: [] },
101
+ context: { entries: [] },
102
+ };
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Helpers
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /** Check whether the agent is in offline mode. */
109
+ function isOffline(): boolean {
110
+ const v = process.env.PI_OFFLINE ?? process.env.CONTEXT7_OFFLINE;
111
+ return v === "true" || v === "1";
112
+ }
113
+
114
+ /** Return the effective TTL (seconds) for the given endpoint. */
115
+ function getTTL(endpoint: "search" | "context"): number {
116
+ const env = process.env.CONTEXT7_CACHE_TTL;
117
+ if (env) {
118
+ const minutes = parseInt(env, 10);
119
+ if (!isNaN(minutes) && minutes > 0) return minutes * 60;
120
+ }
121
+ return DEFAULT_TTL[endpoint];
122
+ }
123
+
124
+ /**
125
+ * Compute an MD5 hash from canonical params.
126
+ *
127
+ * Sorts keys alphabetically, omits `undefined` values, JSON-stringifies,
128
+ * then returns the MD5 hex digest.
129
+ */
130
+ function computeHash(params: Record<string, string | boolean | undefined>): string {
131
+ const canonical: Record<string, string | boolean> = {};
132
+ for (const key of Object.keys(params).sort()) {
133
+ if (params[key] !== undefined) {
134
+ canonical[key] = params[key] as string | boolean;
135
+ }
136
+ }
137
+ return createHash("md5").update(JSON.stringify(canonical)).digest("hex");
138
+ }
139
+
140
+ /**
141
+ * Extract the natural-language query text from params for BM25 matching.
142
+ *
143
+ * Looks for common keys (`query`, `q`) and returns the first non-empty string found.
144
+ */
145
+ function extractQueryText(params: Record<string, string | boolean | undefined>): string {
146
+ for (const key of ["query", "q"]) {
147
+ const val = params[key];
148
+ if (typeof val === "string" && val.trim().length > 0) return val.trim();
149
+ }
150
+ return "";
151
+ }
152
+
153
+ /**
154
+ * Tokenize text for BM25 scoring.
155
+ *
156
+ * Lowercases, splits on non-alphanumeric characters, filters empty tokens.
157
+ */
158
+ function tokenize(text: string): string[] {
159
+ return text
160
+ .toLowerCase()
161
+ .split(/[^a-z0-9]+/)
162
+ .filter((t) => t.length >= 1);
163
+ }
164
+
165
+ /**
166
+ * Compute BM25 score for a single query/document pair.
167
+ *
168
+ * Implements the simplified BM25 formula described in the spec.
169
+ */
170
+ function bm25Score(queryTokens: string[], docTokens: string[], avgDocLen: number): number {
171
+ const docLen = docTokens.length;
172
+ const avgLen = avgDocLen > 0 ? avgDocLen : 1;
173
+
174
+ let score = 0;
175
+ for (const term of queryTokens) {
176
+ const freq = docTokens.filter((t) => t === term).length;
177
+ if (freq > 0) {
178
+ const tf =
179
+ (freq * (BM25_K1 + 1)) /
180
+ (freq + BM25_K1 * (1 - BM25_B + BM25_B * (docLen / avgLen)));
181
+ score += tf * BM25_CONSTANT_IDF;
182
+ }
183
+ }
184
+ return score;
185
+ }
186
+
187
+ /**
188
+ * Run BM25 against a list of manifest entries and return the best match.
189
+ *
190
+ * @returns The best matching entry, or null if none reach the threshold.
191
+ */
192
+ function bm25Find(query: string, entries: ManifestEntry[], threshold: number): ManifestEntry | null {
193
+ const queryTokens = tokenize(query);
194
+ if (queryTokens.length === 0) return null;
195
+
196
+ const docTokenLists = entries.map((e) => tokenize(e.query));
197
+ const avgDocLen =
198
+ docTokenLists.reduce((sum, t) => sum + t.length, 0) / Math.max(entries.length, 1);
199
+
200
+ let bestScore = 0;
201
+ let bestEntry: ManifestEntry | null = null;
202
+
203
+ for (let i = 0; i < entries.length; i++) {
204
+ const score = bm25Score(queryTokens, docTokenLists[i], avgDocLen);
205
+ if (score > bestScore) {
206
+ bestScore = score;
207
+ bestEntry = entries[i];
208
+ }
209
+ }
210
+
211
+ return bestScore >= threshold ? bestEntry : null;
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Path helpers
216
+ // ---------------------------------------------------------------------------
217
+
218
+ function getEndpointDir(endpoint: "search" | "context"): string {
219
+ return join(CACHE_ROOT, DIR_NAMES[endpoint]);
220
+ }
221
+
222
+ function getManifestPath(endpoint: "search" | "context"): string {
223
+ return join(CACHE_ROOT, `${DIR_NAMES[endpoint]}.json`);
224
+ }
225
+
226
+ function getManifestTempPath(endpoint: "search" | "context"): string {
227
+ return join(CACHE_ROOT, `${DIR_NAMES[endpoint]}.json.tmp`);
228
+ }
229
+
230
+ function getEntryPath(endpoint: "search" | "context", hash: string): string {
231
+ return join(getEndpointDir(endpoint), `${hash}.json`);
232
+ }
233
+
234
+ function getEntryTempPath(endpoint: "search" | "context", hash: string): string {
235
+ return join(getEndpointDir(endpoint), `${hash}.json.tmp`);
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Manifest persistence
240
+ // ---------------------------------------------------------------------------
241
+
242
+ async function writeManifest(endpoint: "search" | "context"): Promise<void> {
243
+ const tmpPath = getManifestTempPath(endpoint);
244
+ const finalPath = getManifestPath(endpoint);
245
+ await writeFile(tmpPath, JSON.stringify(manifests[endpoint]), "utf-8");
246
+ await rename(tmpPath, finalPath);
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Task 4: Eviction
251
+ // ---------------------------------------------------------------------------
252
+
253
+ /**
254
+ * Check total cache size across both manifests and evict oldest entries
255
+ * if the total exceeds 50 MB.
256
+ */
257
+ async function evictIfNeeded(): Promise<void> {
258
+ type TaggedEntry = { entry: ManifestEntry; endpoint: "search" | "context" };
259
+
260
+ let totalSize = 0;
261
+ const allEntries: TaggedEntry[] = [];
262
+
263
+ for (const endpoint of ["search", "context"] as const) {
264
+ for (const e of manifests[endpoint].entries) {
265
+ totalSize += e.size;
266
+ allEntries.push({ entry: e, endpoint });
267
+ }
268
+ }
269
+
270
+ if (totalSize <= MAX_CACHE_SIZE) return;
271
+
272
+ // Sort oldest-first
273
+ allEntries.sort((a, b) => a.entry.cachedAt - b.entry.cachedAt);
274
+
275
+ let currentSize = totalSize;
276
+ let evictedCount = 0;
277
+
278
+ for (const { entry, endpoint } of allEntries) {
279
+ if (currentSize <= MAX_CACHE_SIZE) break;
280
+
281
+ // Delete the cached response file
282
+ const filePath = getEntryPath(endpoint, entry.hash);
283
+ try {
284
+ await unlink(filePath);
285
+ } catch {
286
+ // File may already be gone — that's fine
287
+ }
288
+
289
+ // Remove from in-memory manifest
290
+ const idx = manifests[endpoint].entries.findIndex((e) => e.hash === entry.hash);
291
+ if (idx !== -1) {
292
+ manifests[endpoint].entries.splice(idx, 1);
293
+ }
294
+
295
+ currentSize -= entry.size;
296
+ evictedCount++;
297
+ }
298
+
299
+ // Persist updated manifests atomically
300
+ for (const endpoint of ["search", "context"] as const) {
301
+ await writeManifest(endpoint);
302
+ }
303
+
304
+ const freedMB = ((totalSize - currentSize) / 1024 / 1024).toFixed(1);
305
+ console.log(`[cache] Evicted ${evictedCount} entries (${freedMB} MB freed)`);
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Task 1: Init, Manifest Loading, Exact Match
310
+ // ---------------------------------------------------------------------------
311
+
312
+ /**
313
+ * Initialize the cache layer.
314
+ *
315
+ * 1. Create directories (`libraries/`, `contexts/`)
316
+ * 2. Load manifests into memory (or initialize as empty)
317
+ * 3. Clean stale `.tmp` files
318
+ * 4. Run eviction check
319
+ */
320
+ async function init(): Promise<void> {
321
+ if (initialized) return;
322
+
323
+ // 1. Ensure directories exist
324
+ await mkdir(join(CACHE_ROOT, "libraries"), { recursive: true });
325
+ await mkdir(join(CACHE_ROOT, "contexts"), { recursive: true });
326
+
327
+ // 2. Load manifests
328
+ for (const endpoint of ["search", "context"] as const) {
329
+ const mPath = getManifestPath(endpoint);
330
+ try {
331
+ const content = await readFile(mPath, "utf-8");
332
+ const parsed: Manifest = JSON.parse(content);
333
+ manifests[endpoint] = {
334
+ entries: Array.isArray(parsed.entries) ? parsed.entries : [],
335
+ };
336
+ } catch {
337
+ manifests[endpoint] = { entries: [] };
338
+ }
339
+ }
340
+
341
+ // 3. Clean stale .tmp files from both directories and root
342
+ for (const endpoint of ["search", "context"] as const) {
343
+ const dir = getEndpointDir(endpoint);
344
+ try {
345
+ const files = await readdir(dir);
346
+ for (const file of files) {
347
+ if (file.endsWith(".tmp")) {
348
+ try {
349
+ await unlink(join(dir, file));
350
+ } catch {
351
+ // Race with another init() — ignore
352
+ }
353
+ }
354
+ }
355
+ } catch {
356
+ // Directory may not exist yet
357
+ }
358
+ }
359
+
360
+ // Also clean manifest .tmp files from the root cache dir
361
+ try {
362
+ const rootFiles = await readdir(CACHE_ROOT);
363
+ for (const file of rootFiles) {
364
+ if (file.endsWith(".tmp")) {
365
+ try {
366
+ await unlink(join(CACHE_ROOT, file));
367
+ } catch {
368
+ // ignore
369
+ }
370
+ }
371
+ }
372
+ } catch {
373
+ // Root may not exist
374
+ }
375
+
376
+ // 4. Eviction check
377
+ await evictIfNeeded();
378
+
379
+ initialized = true;
380
+ }
381
+
382
+ /**
383
+ * Read a cached entry from disk by hash.
384
+ */
385
+ async function readCacheEntry(endpoint: "search" | "context", hash: string): Promise<CacheEntry | null> {
386
+ const filePath = getEntryPath(endpoint, hash);
387
+ try {
388
+ const content = await readFile(filePath, "utf-8");
389
+ return JSON.parse(content) as CacheEntry;
390
+ } catch {
391
+ return null;
392
+ }
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Task 2: BM25 Semantic Lookup
397
+ // ---------------------------------------------------------------------------
398
+
399
+ /**
400
+ * Attempt a BM25 semantic lookup against manifest entries filtered by scope.
401
+ *
402
+ * - **search** endpoint: filters by `scope.libraryName`
403
+ * - **context** endpoint: filters by `scope.libraryId`
404
+ *
405
+ * @param excludeHash - If provided, this entry is excluded from candidates.
406
+ * Used when the exact match was found (but stale) to avoid BM25 returning
407
+ * the same entry we already know is stale.
408
+ */
409
+ async function tryBm25(
410
+ endpoint: "search" | "context",
411
+ scope: Record<string, string>,
412
+ params: Record<string, string | boolean | undefined>,
413
+ threshold: number,
414
+ excludeHash?: string,
415
+ ): Promise<CacheResult | null> {
416
+ const query = extractQueryText(params);
417
+ if (!query) return null;
418
+
419
+ // Filter manifest entries by scope
420
+ let candidates = manifests[endpoint].entries;
421
+
422
+ if (endpoint === "search") {
423
+ // Only match entries for the same library
424
+ if (scope.libraryName) {
425
+ candidates = candidates.filter((e) => e.scope.libraryName === scope.libraryName);
426
+ }
427
+ } else {
428
+ // Only match entries for the same library/document
429
+ if (scope.libraryId) {
430
+ candidates = candidates.filter((e) => e.scope.libraryId === scope.libraryId);
431
+ }
432
+ }
433
+
434
+ // Exclude the stale-exact-match entry so BM25 finds a *different* cached query
435
+ if (excludeHash) {
436
+ candidates = candidates.filter((e) => e.hash !== excludeHash);
437
+ }
438
+
439
+ if (candidates.length === 0) return null;
440
+
441
+ const match = bm25Find(query, candidates, threshold);
442
+ if (!match) return null;
443
+
444
+ // Read the matched entry's cached data from disk
445
+ const entry = await readCacheEntry(endpoint, match.hash);
446
+ if (!entry) return null;
447
+
448
+ return { data: entry.response, source: "bm25" };
449
+ }
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // Task 5: Offline Mode
453
+ // ---------------------------------------------------------------------------
454
+
455
+ /**
456
+ * Retrieve a cached response.
457
+ *
458
+ * Flow:
459
+ * 1. Compute canonical hash → try exact match
460
+ * 2. If exact fresh → return `source:"exact"`
461
+ * 3. If exact stale (online) → run BM25; fall back to stale if BM25 fails
462
+ * 4. If exact stale (offline) → return `source:"stale"` directly
463
+ * 5. If exact not found → run BM25 with appropriate threshold
464
+ * 6. If nothing matches → return `source:null`
465
+ */
466
+ async function get(
467
+ endpoint: "search" | "context",
468
+ scope: Record<string, string>,
469
+ params: Record<string, string | boolean | undefined>,
470
+ ): Promise<CacheResult> {
471
+ await init();
472
+
473
+ const hash = computeHash(params);
474
+ const offline = isOffline();
475
+ const threshold = offline ? BM25_THRESHOLD_OFFLINE : BM25_THRESHOLD_ONLINE;
476
+
477
+ // 1. Try exact match
478
+ const entry = await readCacheEntry(endpoint, hash);
479
+
480
+ if (entry) {
481
+ const isFresh = entry.cachedAt + entry.ttl * 1000 > Date.now();
482
+
483
+ if (isFresh) {
484
+ // Fresh exact match → return immediately
485
+ return { data: entry.response, source: "exact" };
486
+ }
487
+
488
+ // Stale exact match
489
+ if (offline) {
490
+ // Offline: return stale data directly — caller prepends [cached, may be outdated]
491
+ return { data: entry.response, source: "stale" };
492
+ }
493
+
494
+ // Online: try BM25 first (maybe a similar query was cached more recently)
495
+ // Pass the current hash so BM25 skips the same stale entry.
496
+ const bm25Result = await tryBm25(endpoint, scope, params, threshold, hash);
497
+ if (bm25Result) {
498
+ return bm25Result;
499
+ }
500
+
501
+ // No BM25 match either — fall back to the stale exact match
502
+ return { data: entry.response, source: "stale" };
503
+ }
504
+
505
+ // 2. No exact match → try BM25 semantic lookup
506
+ const bm25Result = await tryBm25(endpoint, scope, params, threshold);
507
+ if (bm25Result) {
508
+ return bm25Result;
509
+ }
510
+
511
+ // 3. Nothing cached
512
+ return { data: null, source: null };
513
+ }
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // Task 3: Atomic Writes
517
+ // ---------------------------------------------------------------------------
518
+
519
+ /**
520
+ * Store a response in the cache atomically.
521
+ *
522
+ * 1. Compute hash from canonical params
523
+ * 2. Write `{ response, cachedAt, ttl }` to `<hash>.json.tmp`
524
+ * 3. `rename()` → `<hash>.json` (atomic on same filesystem)
525
+ * 4. Update in-memory manifest with size tracking
526
+ * 5. Write manifest to `<endpoint>.json.tmp` → `rename()` to `<endpoint>.json`
527
+ */
528
+ async function set(
529
+ endpoint: "search" | "context",
530
+ scope: Record<string, string>,
531
+ params: Record<string, string | boolean | undefined>,
532
+ data: unknown,
533
+ ): Promise<void> {
534
+ await init();
535
+
536
+ const hash = computeHash(params);
537
+ const ttl = getTTL(endpoint);
538
+ const now = Date.now();
539
+
540
+ // Build the cache entry
541
+ const entry: CacheEntry = {
542
+ response: data,
543
+ cachedAt: now,
544
+ ttl,
545
+ };
546
+
547
+ // 1. Write entry file atomically
548
+ const tmpPath = getEntryTempPath(endpoint, hash);
549
+ const finalPath = getEntryPath(endpoint, hash);
550
+ await writeFile(tmpPath, JSON.stringify(entry), "utf-8");
551
+ await rename(tmpPath, finalPath);
552
+
553
+ // 2. Compute response size (in bytes) for eviction accounting
554
+ const size = Buffer.byteLength(JSON.stringify(data));
555
+ const query = extractQueryText(params);
556
+
557
+ // 3. Update in-memory manifest
558
+ const existingIdx = manifests[endpoint].entries.findIndex((e) => e.hash === hash);
559
+ const manifestEntry: ManifestEntry = {
560
+ scope: { ...scope },
561
+ query,
562
+ hash,
563
+ cachedAt: now,
564
+ ttl,
565
+ size,
566
+ };
567
+
568
+ if (existingIdx !== -1) {
569
+ // Update existing entry
570
+ manifests[endpoint].entries[existingIdx] = manifestEntry;
571
+ } else {
572
+ // Append new entry
573
+ manifests[endpoint].entries.push(manifestEntry);
574
+ }
575
+
576
+ // 4. Write manifest atomically
577
+ await writeManifest(endpoint);
578
+ }
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // Public API
582
+ // ---------------------------------------------------------------------------
583
+
584
+ /**
585
+ * Create a new cache module instance.
586
+ *
587
+ * Usage:
588
+ * ```typescript
589
+ * import { createCache } from "./extensions/cache";
590
+ * const cache = createCache();
591
+ * await cache.init();
592
+ * const result = await cache.get("search", { libraryName: "react" }, { query: "useState" });
593
+ * ```
594
+ */
595
+ export function createCache(): CacheModule {
596
+ return { init, get, set };
597
+ }
@@ -0,0 +1,562 @@
1
+ /**
2
+ * Context7 Extension for pi coding agent.
3
+ *
4
+ * Registers two tools backed by the cache layer from cache.ts:
5
+ * 1. context7_search_library — Search Context7 for libraries by name
6
+ * 2. context7_get_context — Get documentation context and code examples
7
+ *
8
+ * @module extensions/context7
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
+ import { Type } from "typebox";
13
+ import { StringEnum } from "@mariozechner/pi-ai";
14
+ import { readFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { createCache, type CacheModule } from "./cache.js";
18
+
19
+ export default function (pi: ExtensionAPI) {
20
+ let cache: CacheModule;
21
+ let apiKey: string | undefined;
22
+
23
+ // -----------------------------------------------------------------------
24
+ // API Key Resolution
25
+ // -----------------------------------------------------------------------
26
+
27
+ function resolveApiKey(): string | undefined {
28
+ if (process.env.CONTEXT7_API_KEY) return process.env.CONTEXT7_API_KEY;
29
+
30
+ try {
31
+ const authPath = join(homedir(), ".pi", "agent", "auth.json");
32
+ const auth = JSON.parse(readFileSync(authPath, "utf8"));
33
+ return auth?.context7?.apiKey;
34
+ } catch {
35
+ /* not found, unauthenticated */
36
+ }
37
+
38
+ return undefined;
39
+ }
40
+
41
+ // -----------------------------------------------------------------------
42
+ // Session Init
43
+ // -----------------------------------------------------------------------
44
+
45
+ pi.on("session_start", async (_event, ctx) => {
46
+ cache = await createCache();
47
+ apiKey = resolveApiKey();
48
+
49
+ if (!apiKey) {
50
+ ctx.ui.notify(
51
+ "Context7: no API key set. Rate limits apply. " +
52
+ "Set CONTEXT7_API_KEY for higher limits.",
53
+ "info",
54
+ );
55
+ }
56
+ });
57
+
58
+ // -----------------------------------------------------------------------
59
+ // Signal helper — race caller signal with timeout
60
+ // -----------------------------------------------------------------------
61
+
62
+ function createCombinedSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal {
63
+ if (!signal) return AbortSignal.timeout(timeoutMs);
64
+
65
+ // AbortSignal.any is available in Node 20+
66
+ if (typeof AbortSignal.any === "function") {
67
+ return AbortSignal.any([AbortSignal.timeout(timeoutMs), signal]);
68
+ }
69
+
70
+ // Fallback: create a controller aborted by either signal
71
+ const controller = new AbortController();
72
+ const onAbort = () => {
73
+ try {
74
+ controller.abort();
75
+ } catch {
76
+ /* already aborted */
77
+ }
78
+ };
79
+ signal.addEventListener("abort", onAbort, { once: true });
80
+ AbortSignal.timeout(timeoutMs).addEventListener("abort", onAbort, { once: true });
81
+ return controller.signal;
82
+ }
83
+
84
+ // -----------------------------------------------------------------------
85
+ // HTTP Client (Context7-specific)
86
+ // -----------------------------------------------------------------------
87
+
88
+ async function context7Fetch<T>(
89
+ endpoint: string,
90
+ params: Record<string, string | boolean | undefined>,
91
+ apiKey?: string,
92
+ signal?: AbortSignal,
93
+ ): Promise<T> {
94
+ const url = new URL(`https://context7.com/api/v2/${endpoint}`);
95
+
96
+ for (const [key, value] of Object.entries(params)) {
97
+ if (value === undefined) continue;
98
+ url.searchParams.set(key, String(value));
99
+ }
100
+
101
+ const headers: Record<string, string> = {};
102
+ if (apiKey) {
103
+ headers["Authorization"] = `Bearer ${apiKey}`;
104
+ }
105
+
106
+ const maxRetries = 3;
107
+ let lastError: Error | null = null;
108
+
109
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
110
+ try {
111
+ const combinedSignal = createCombinedSignal(10_000, signal);
112
+ const response = await fetch(url.toString(), { headers, signal: combinedSignal });
113
+
114
+ if (response.ok) {
115
+ return (await response.json()) as T;
116
+ }
117
+
118
+ // Try to parse structured error body
119
+ let apiMessage: string | undefined;
120
+ try {
121
+ const errorBody = await response.json();
122
+ if (errorBody?.message) apiMessage = errorBody.message;
123
+ } catch {
124
+ /* ignore parse errors */
125
+ }
126
+
127
+ // 401 — Invalid API key
128
+ if (response.status === 401) {
129
+ throw new Error(
130
+ "Context7 API error: Invalid API key. Generate one at " +
131
+ "https://context7.com/dashboard and set CONTEXT7_API_KEY.",
132
+ );
133
+ }
134
+
135
+ // 403 — Access denied
136
+ if (response.status === 403) {
137
+ throw new Error(
138
+ "Context7 API error: Access denied. Your plan may not include this library.",
139
+ );
140
+ }
141
+
142
+ // 404 — Not found
143
+ if (response.status === 404) {
144
+ throw new Error(
145
+ "Context7 API error: Library not found. Check the library ID.",
146
+ );
147
+ }
148
+
149
+ // 429 — Rate limited
150
+ if (response.status === 429) {
151
+ if (attempt < maxRetries) {
152
+ const retryAfter = response.headers.get("Retry-After");
153
+ const delayMs = retryAfter
154
+ ? parseInt(retryAfter, 10) * 1000
155
+ : Math.pow(2, attempt) * 1000;
156
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
157
+ continue;
158
+ }
159
+ throw new Error(
160
+ "Context7 API error: Rate limit exceeded. Wait and retry, " +
161
+ "or set CONTEXT7_API_KEY for higher limits.",
162
+ );
163
+ }
164
+
165
+ // 5xx — Server error
166
+ if (response.status >= 500) {
167
+ throw new Error(
168
+ `Context7 API error: Server error (${response.status}). Try again later.`,
169
+ );
170
+ }
171
+
172
+ // Other 4xx
173
+ throw new Error(
174
+ apiMessage
175
+ ? `Context7 API error: ${apiMessage}`
176
+ : `Context7 API error: HTTP ${response.status}`,
177
+ );
178
+ } catch (err) {
179
+ const isContext7Error =
180
+ err instanceof Error && err.message.startsWith("Context7 API error");
181
+
182
+ if (isContext7Error) {
183
+ // These are already formatted — throw immediately
184
+ throw err;
185
+ }
186
+
187
+ // Network / timeout / other transient errors — retry with backoff
188
+ lastError = err instanceof Error ? err : new Error(String(err));
189
+
190
+ if (attempt < maxRetries) {
191
+ const delayMs = Math.pow(2, attempt) * 1000;
192
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
193
+ continue;
194
+ }
195
+
196
+ throw new Error(
197
+ `Context7 API error: Network error after ${maxRetries + 1} attempts. ${lastError.message}`,
198
+ );
199
+ }
200
+ }
201
+
202
+ // Should not reach here, but satisfy TypeScript
203
+ throw lastError ?? new Error("Context7 API error: Request failed after retries.");
204
+ }
205
+
206
+ // -----------------------------------------------------------------------
207
+ // Tool: context7_search_library
208
+ // -----------------------------------------------------------------------
209
+
210
+ pi.registerTool({
211
+ name: "context7_search_library",
212
+ label: "Context7 Search Library",
213
+ description:
214
+ "Search Context7 for libraries by name. Returns matching libraries with IDs, descriptions, " +
215
+ "trust scores, and available versions. Use this first to resolve a library name to a " +
216
+ "Context7 library ID before calling context7_get_context.",
217
+ promptSnippet: "Search for libraries on Context7 by name (e.g., 'react', 'nextjs')",
218
+ parameters: Type.Object({
219
+ libraryName: Type.String({
220
+ description:
221
+ "Library name to search for (e.g., 'react', 'nextjs', 'express', 'prisma')",
222
+ }),
223
+ query: Type.String({
224
+ description:
225
+ "The user's full question or task — used for intelligent relevance ranking. " +
226
+ "Be specific. Good: 'How to set up JWT auth in Express middleware', Bad: 'auth'",
227
+ }),
228
+ fast: Type.Optional(
229
+ Type.Boolean({
230
+ description:
231
+ "When true, skip LLM reranking for faster but less accurate results",
232
+ default: false,
233
+ }),
234
+ ),
235
+ }),
236
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
237
+ try {
238
+ const currentApiKey = apiKey;
239
+
240
+ // Build params for fetch + cache
241
+ const fetchParams: Record<string, string | boolean | undefined> = {
242
+ libraryName: params.libraryName,
243
+ query: params.query,
244
+ };
245
+ if (params.fast) fetchParams.fast = true;
246
+
247
+ // Try cache first
248
+ const cached = await cache.get(
249
+ "search",
250
+ { libraryName: params.libraryName },
251
+ fetchParams,
252
+ );
253
+
254
+ let results: unknown[];
255
+ let cacheNote = "";
256
+
257
+ if (cached.source !== null) {
258
+ // Cache hit
259
+ results = ((cached.data as { results?: unknown[] })?.results ?? []) as unknown[];
260
+ if (cached.source === "exact") {
261
+ cacheNote = "\n[cache hit]";
262
+ } else if (cached.source === "bm25") {
263
+ cacheNote = "\n[semantic cache match]";
264
+ } else if (cached.source === "stale") {
265
+ cacheNote = "\n[cached, may be outdated]";
266
+ }
267
+ } else {
268
+ // Cache miss — fetch from API
269
+ const raw = await context7Fetch<{ results: unknown[] }>(
270
+ "libs/search",
271
+ fetchParams,
272
+ currentApiKey,
273
+ signal,
274
+ );
275
+ results = raw.results ?? [];
276
+ // Store in cache (fire-and-forget)
277
+ cache.set("search", { libraryName: params.libraryName }, fetchParams, raw).catch(() => {});
278
+ cacheNote = "\n[fetched from API]";
279
+ }
280
+
281
+ if (results.length === 0) {
282
+ return {
283
+ content: [
284
+ {
285
+ type: "text",
286
+ text: `No libraries found for '${params.libraryName}'. Try a different name or be more specific.`,
287
+ },
288
+ ],
289
+ details: { results: [] },
290
+ };
291
+ }
292
+
293
+ // Format output
294
+ const lines: string[] = [
295
+ `Found ${results.length} libraries for "${params.libraryName}":`,
296
+ ];
297
+
298
+ for (let i = 0; i < results.length; i++) {
299
+ const lib = results[i] as Record<string, unknown>;
300
+ const idx = i + 1;
301
+ const id = lib.id ?? "";
302
+ const title = lib.title ?? lib.name ?? "Unknown";
303
+ const description = lib.description ?? "";
304
+ const versions = Array.isArray(lib.versions)
305
+ ? (lib.versions as string[]).join(", ")
306
+ : "";
307
+ const trust = lib.trustScore ?? lib.trust_score ?? "?";
308
+ const bench = lib.benchmarkScore ?? lib.benchmark_score ?? "?";
309
+ const stars = lib.stars ?? lib.githubStars ?? lib.github_stars ?? "?";
310
+
311
+ lines.push("");
312
+ lines.push(`${idx}. ${title} — ${id}`);
313
+ lines.push(` ${description}`);
314
+ if (versions) lines.push(` Versions: ${versions}`);
315
+ lines.push(` Trust: ${trust}/10 · Benchmark: ${bench}/100 · ⭐ ${stars}`);
316
+ }
317
+
318
+ lines.push("");
319
+ lines.push(
320
+ "Use the library ID (e.g., " +
321
+ (results[0] as Record<string, unknown>)?.id +
322
+ ") with context7_get_context.",
323
+ );
324
+ if (cacheNote) lines.push(cacheNote);
325
+
326
+ return {
327
+ content: [{ type: "text", text: lines.join("\n") }],
328
+ details: { results },
329
+ };
330
+ } catch (err) {
331
+ const message = err instanceof Error ? err.message : String(err);
332
+ return {
333
+ content: [{ type: "text", text: message }],
334
+ details: { error: message },
335
+ };
336
+ }
337
+ },
338
+ });
339
+
340
+ // -----------------------------------------------------------------------
341
+ // Tool: context7_get_context
342
+ // -----------------------------------------------------------------------
343
+
344
+ pi.registerTool({
345
+ name: "context7_get_context",
346
+ label: "Context7 Get Context",
347
+ description:
348
+ "Get up-to-date documentation context and code examples for a library from Context7. " +
349
+ "Requires a libraryId from context7_search_library (format: /owner/repo or /owner/repo@version). " +
350
+ "Always prefer Context7 over training data for library-specific questions.",
351
+ promptSnippet: "Retrieve documentation and code examples for a Context7 library ID",
352
+ promptGuidelines: [
353
+ "Use context7_get_context for library documentation instead of relying on training data. Training data may be outdated.",
354
+ "When context7_get_context returns insufficient results, retry with researchMode: true for a deeper search.",
355
+ "Always run context7_search_library first to resolve library names to Context7 IDs before calling context7_get_context.",
356
+ ],
357
+ parameters: Type.Object({
358
+ libraryId: Type.String({
359
+ description:
360
+ "Context7 library ID in format /owner/repo or /owner/repo@version " +
361
+ "(e.g., '/facebook/react', '/vercel/next.js@v15.1.8')",
362
+ }),
363
+ query: Type.String({
364
+ description:
365
+ "The specific question or task. Be specific and include relevant details. " +
366
+ "Good: 'How to set up JWT authentication in Express middleware', Bad: 'auth'",
367
+ }),
368
+ type: Type.Optional(
369
+ StringEnum(["json", "txt"] as const, {
370
+ description:
371
+ "Response format. 'json' returns structured snippets, 'txt' returns raw text.",
372
+ default: "json",
373
+ }),
374
+ ),
375
+ researchMode: Type.Optional(
376
+ Type.Boolean({
377
+ description:
378
+ "When true, use deeper agentic research (sandboxed agents, live web search). " +
379
+ "Slower but higher quality. Use as retry if default results are insufficient.",
380
+ default: false,
381
+ }),
382
+ ),
383
+ }),
384
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
385
+ try {
386
+ // 1. Validate libraryId format: /owner/repo or /owner/repo@version or /owner/repo/version
387
+ const libIdRegex = /^\/[^/]+\/[^/]+([/@][^/]+)?$/;
388
+ if (!libIdRegex.test(params.libraryId)) {
389
+ return {
390
+ content: [
391
+ {
392
+ type: "text",
393
+ text: "Invalid libraryId format. Expected /owner/repo or /owner/repo@version",
394
+ },
395
+ ],
396
+ details: { error: "Invalid libraryId format" },
397
+ };
398
+ }
399
+
400
+ const currentApiKey = apiKey;
401
+ const responseType = params.type ?? "json";
402
+
403
+ const fetchParams: Record<string, string | boolean | undefined> = {
404
+ libraryId: params.libraryId,
405
+ query: params.query,
406
+ type: responseType,
407
+ };
408
+ if (params.researchMode) fetchParams.researchMode = true;
409
+
410
+ // Try cache
411
+ const cached = await cache.get(
412
+ "context",
413
+ { libraryId: params.libraryId },
414
+ fetchParams,
415
+ );
416
+
417
+ let data: Record<string, unknown>;
418
+ let cacheNote = "";
419
+
420
+ if (cached.source !== null) {
421
+ // Cache hit
422
+ data = cached.data as Record<string, unknown>;
423
+ if (cached.source === "exact") {
424
+ cacheNote = "\n[cache hit]";
425
+ } else if (cached.source === "bm25") {
426
+ cacheNote = "\n[semantic cache match]";
427
+ } else if (cached.source === "stale") {
428
+ cacheNote = "\n[cached, may be outdated]";
429
+ }
430
+ } else {
431
+ // Cache miss — fetch from API
432
+ data = await context7Fetch<Record<string, unknown>>(
433
+ "context",
434
+ fetchParams,
435
+ currentApiKey,
436
+ signal,
437
+ );
438
+ // Store in cache (fire-and-forget)
439
+ cache
440
+ .set("context", { libraryId: params.libraryId }, fetchParams, data)
441
+ .catch(() => {});
442
+ cacheNote = "\n[fetched from API]";
443
+ }
444
+
445
+ // Format output
446
+ const outputLines: string[] = [];
447
+ outputLines.push(`## Context7 Documentation for ${params.libraryId}`);
448
+ outputLines.push("");
449
+
450
+ if (responseType === "txt") {
451
+ // Raw text mode
452
+ const textContent =
453
+ (data?.text as string) ??
454
+ (data?.content as string) ??
455
+ JSON.stringify(data, null, 2);
456
+ outputLines.push(String(textContent));
457
+ } else {
458
+ // Structured JSON mode
459
+ const codeSnippets = data?.codeSnippets as
460
+ | Array<Record<string, unknown>>
461
+ | undefined;
462
+ if (codeSnippets && codeSnippets.length > 0) {
463
+ outputLines.push("### Code Snippets");
464
+ outputLines.push("");
465
+
466
+ for (const snippet of codeSnippets) {
467
+ // Use codeTitle/codeDescription from the API, with fallbacks
468
+ const snippetTitle = (snippet.codeTitle ?? snippet.title ?? "Code Example") as string;
469
+ const snippetDesc = snippet.codeDescription as string | undefined;
470
+
471
+ // Use codeLanguage from the API, with fallback
472
+ const snippetLang = (snippet.codeLanguage ?? snippet.language ?? snippet.lang ?? "typescript") as string;
473
+
474
+ // codeList is an array of { language, code } from the API
475
+ const codeList = (snippet.codeList ?? []) as Array<Record<string, unknown>>;
476
+
477
+ // Source URL from the API (codeId) with fallbacks
478
+ const sourceUrl = (snippet.codeId ?? snippet.source ?? snippet.pageTitle) as string | undefined;
479
+
480
+ if (codeList.length > 0) {
481
+ // Each codeList item gets its own code block
482
+ outputLines.push(`**${snippetTitle}**`);
483
+ if (snippetDesc) outputLines.push(`> ${snippetDesc}`);
484
+ outputLines.push("");
485
+
486
+ for (const item of codeList) {
487
+ const itemLang = (item.language ?? snippetLang) as string;
488
+ const itemCode = (item.code ?? "") as string;
489
+ if (itemCode) {
490
+ outputLines.push("```" + itemLang);
491
+ outputLines.push(String(itemCode).trimEnd());
492
+ outputLines.push("```");
493
+ }
494
+ }
495
+
496
+ if (sourceUrl) outputLines.push(`Source: ${sourceUrl}`);
497
+ outputLines.push("");
498
+ } else {
499
+ // Fallback: try top-level code property (alternate format)
500
+ const legacyCode = (snippet.code ?? snippet.content ?? "") as string;
501
+ if (legacyCode) {
502
+ outputLines.push(`**${snippetTitle}** (${snippetLang})`);
503
+ if (snippetDesc) outputLines.push(`> ${snippetDesc}`);
504
+ outputLines.push("```" + snippetLang);
505
+ outputLines.push(String(legacyCode).trimEnd());
506
+ outputLines.push("```");
507
+ if (sourceUrl) outputLines.push(`Source: ${sourceUrl}`);
508
+ outputLines.push("");
509
+ }
510
+ }
511
+ }
512
+ }
513
+
514
+ // Info / Documentation snippets
515
+ const infoSnippets = data?.infoSnippets as
516
+ | Array<Record<string, unknown>>
517
+ | undefined;
518
+ if (infoSnippets && infoSnippets.length > 0) {
519
+ outputLines.push("### Documentation");
520
+ outputLines.push("");
521
+
522
+ for (const snippet of infoSnippets) {
523
+ const title = snippet.title ?? "Info";
524
+ const snippetText =
525
+ (snippet.content as string) ??
526
+ (snippet.text as string) ??
527
+ (snippet.description as string) ??
528
+ "";
529
+ outputLines.push(`**${title}** — ${snippetText}`);
530
+ outputLines.push("");
531
+ }
532
+ }
533
+
534
+ // Research mode note
535
+ if (params.researchMode) {
536
+ outputLines.push("[Research mode — deeper analysis]");
537
+ outputLines.push("");
538
+ }
539
+ }
540
+
541
+ if (cacheNote) outputLines.push(cacheNote);
542
+
543
+ const formatted = outputLines.join("\n").trim();
544
+
545
+ return {
546
+ content: [{ type: "text", text: formatted || "No context data returned." }],
547
+ details: {
548
+ codeSnippets: (data?.codeSnippets as unknown) ?? [],
549
+ infoSnippets: (data?.infoSnippets as unknown) ?? [],
550
+ rules: (data?.rules as unknown) ?? [],
551
+ },
552
+ };
553
+ } catch (err) {
554
+ const message = err instanceof Error ? err.message : String(err);
555
+ return {
556
+ content: [{ type: "text", text: message }],
557
+ details: { error: message },
558
+ };
559
+ }
560
+ },
561
+ });
562
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@mario-gc/pi-context7",
3
+ "version": "0.1.1",
4
+ "description": "Context7 integration for pi coding agent — fetch up-to-date library documentation and code examples",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "pi-package",
8
+ "context7",
9
+ "documentation",
10
+ "api-reference"
11
+ ],
12
+ "pi": {
13
+ "extensions": [
14
+ "./extensions/context7.ts"
15
+ ],
16
+ "skills": [
17
+ "./skills"
18
+ ]
19
+ },
20
+ "peerDependencies": {
21
+ "@mariozechner/pi-ai": "*",
22
+ "@mariozechner/pi-coding-agent": "*",
23
+ "typebox": "*"
24
+ },
25
+ "files": [
26
+ "extensions/",
27
+ "skills/",
28
+ "README.md"
29
+ ],
30
+ "devDependencies": {
31
+ "@mariozechner/pi-ai": "^0.73.0",
32
+ "@mariozechner/pi-coding-agent": "^0.73.0",
33
+ "@types/node": "^25.6.0",
34
+ "typebox": "^1.1.37",
35
+ "typescript": "^6.0.3"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/mario-gc/pi-context7.git"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/mario-gc/pi-context7/issues"
43
+ },
44
+ "homepage": "https://github.com/mario-gc/pi-context7#readme"
45
+ }
@@ -0,0 +1,83 @@
1
+ ---
2
+ name: context7
3
+ description: >-
4
+ Fetches up-to-date documentation, API references, and code examples for any
5
+ library, framework, or tool via Context7. Use this skill when the user asks
6
+ about library setup, configuration, API syntax, version-specific behavior, or
7
+ needs code examples involving specific libraries (React, Next.js, Prisma,
8
+ Express, Tailwind, Supabase, etc.). Always prefer Context7 over training data
9
+ for library-specific questions — training data may be outdated. Do not use for
10
+ general programming questions, pure language syntax, or questions answerable
11
+ without library documentation.
12
+ ---
13
+
14
+ # Context7 Documentation Lookup
15
+
16
+ Retrieve current, version-specific documentation and code examples using Context7.
17
+
18
+ ## Workflow
19
+
20
+ ### Step 1: Resolve the Library
21
+
22
+ Call `context7_search_library` with:
23
+
24
+ - `libraryName`: The library extracted from the user's question (e.g., "react", "nextjs", "prisma")
25
+ - `query`: The user's full question or task description — improves ranking
26
+ - `fast`: Set to `true` only for latency-sensitive cases (trades accuracy)
27
+
28
+ ### Step 2: Select the Best Match
29
+
30
+ From the results, choose based on:
31
+ - Exact or closest name match to what the user asked for
32
+ - Higher benchmark scores (out of 100) indicate better documentation quality
33
+ - Higher trust scores (out of 10) indicate more authoritative sources
34
+ - If the user mentioned a version (e.g., "React 19"), prefer version-specific IDs from the `versions` list
35
+
36
+ ### Step 3: Fetch Documentation
37
+
38
+ Call `context7_get_context` with:
39
+
40
+ - `libraryId`: The selected Context7 library ID (e.g., `/vercel/next.js`)
41
+ - `query`: The user's specific question — be descriptive
42
+ - `type`: Use "json" for structured snippets (default), "txt" for plain text
43
+ - `researchMode`: Only use this as a **retry** if the initial results are insufficient
44
+
45
+ ### Step 4: Use the Documentation
46
+
47
+ Incorporate the fetched documentation into your response:
48
+ - Answer the user's question using current, accurate information
49
+ - Include relevant code examples from the docs
50
+ - Cite the library version when relevant
51
+ - Reference the source page/breadcrumb when helpful (from `pageTitle` or `breadcrumb`)
52
+
53
+ ## Query Quality
54
+
55
+ | Good | Bad |
56
+ |------|-----|
57
+ | "How to set up JWT authentication in Express middleware" | "auth" |
58
+ | "React useEffect cleanup function with async operations" | "hooks" |
59
+ | "Prisma one-to-many relation with cascade delete" | "relations" |
60
+
61
+ Pass the user's intent and relevant details — single-word queries return generic results.
62
+
63
+ ## Version Awareness
64
+
65
+ When users mention specific versions:
66
+ - Use version-specific library IDs from the search results: `/vercel/next.js@v15.1.8`
67
+ - Both `@` and `/` separators work: `/vercel/next.js/v15.1.8`
68
+ - If the exact version isn't available, pick the closest match
69
+
70
+ ## Retry Strategy
71
+
72
+ If `context7_get_context` returns insufficient or irrelevant results:
73
+ 1. Retry with `researchMode: true` — this uses deeper agentic search
74
+ 2. If still insufficient, consider refining the query with more specific terms
75
+ 3. Do not silently fall back to training data without telling the user
76
+
77
+ ## Guidelines
78
+
79
+ - Always run `context7_search_library` before `context7_get_context` — you need a valid library ID
80
+ - Pass the user's full question as `query` for better relevance
81
+ - Prefer official/primary packages over community forks when multiple matches exist
82
+ - If you encounter rate limits or quota errors, inform the user they can set `CONTEXT7_API_KEY` for higher limits. API keys are available at https://context7.com/dashboard.
83
+ - Results are cached locally for reuse — similar queries may hit the cache automatically