@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 +21 -0
- package/README.md +82 -0
- package/extensions/cache.ts +597 -0
- package/extensions/context7.ts +562 -0
- package/package.json +45 -0
- package/skills/context7/SKILL.md +83 -0
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
|