pi-web-kit 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-06-13
10
+
11
+ ### Added
12
+
13
+ - Add `library_search` and `library_docs` tools backed by Context7.
14
+ - Add `code_search` backed by Exa Code.
15
+ - Gate optional developer-search tool registration on API key availability.
16
+
17
+ ## [0.1.6] - 2026-06-06
18
+
19
+ ### Changed
20
+
21
+ - Extract install telemetry to separate `src/install-telemetry.ts` module for consistency with monorepo guidelines.
22
+ - Add root `index.ts` re-export and fix `pi.extensions` path to `./index.ts`.
23
+ - Update `peerDependencies` to use `*` for Pi core packages.
24
+ - Add Pi core packages to `devDependencies` for type checking.
25
+ - Update `author` field to full name.
26
+
9
27
  ## [0.1.5] - 2026-05-20
10
28
 
11
29
  ### Changed
package/README.md CHANGED
@@ -1,14 +1,16 @@
1
1
  # pi-web-kit
2
2
 
3
- Context-efficient web search and fetch tools for [Pi](https://pi.dev): `web_search` and `web_fetch`.
3
+ Context-efficient web and developer search tools for [Pi](https://pi.dev): `web_search`, `web_fetch`, `library_search`, `library_docs`, and `code_search`.
4
4
 
5
- `pi-web-kit` provides provider-backed search and page fetching with bounded output, chunked reads, URL validation, and an in-memory fetch cache designed for agent workflows.
5
+ `pi-web-kit` provides provider-backed search, page fetching, library docs lookup, and code-context search with bounded output, chunked reads, URL validation, and an in-memory fetch cache designed for agent workflows.
6
6
 
7
7
  ## Features
8
8
 
9
9
  - `web_search` for current/external web information, including multi-query searches.
10
10
  - `web_fetch` for reading one or more URLs, with `offset` / `limit` chunk reads for long pages.
11
- - Multiple provider backends: Exa MCP, Exa API, TinyFish, Brave Search, Firecrawl, and markdown.new.
11
+ - `library_search` and `library_docs` for library resolution and current, version-aware documentation/code examples.
12
+ - `code_search` for practical examples and implementation context.
13
+ - Multiple provider backends: Exa MCP, Exa API, TinyFish, Brave Search, Firecrawl, markdown.new, Context7, and Exa Code.
12
14
  - Provider-tailored tool schemas at Pi startup/reload.
13
15
  - URL validation: HTTP(S)-only, no embedded credentials, fragment stripping, duplicate removal, and length/count limits.
14
16
  - In-memory fetch cache with TTL, LRU eviction, max entry count, max byte count, and cache keys based on provider/config/fetch-affecting options.
@@ -96,7 +98,8 @@ PI_OFFLINE=1 # disables install/update telemetry
96
98
  PI_TELEMETRY=0 # disables install/update telemetry
97
99
  PI_WEB_KIT_PROVIDER_SEARCH=exa_mcp|exa|tinyfish|brave|firecrawl
98
100
  PI_WEB_KIT_PROVIDER_FETCH=exa_mcp|exa|tinyfish|markdown_new|firecrawl
99
- EXA_API_KEY=...
101
+ EXA_API_KEY=... # enables Exa provider and code_search
102
+ CONTEXT7_API_KEY=... # enables library_search and library_docs
100
103
  TINYFISH_API_KEY=...
101
104
  BRAVE_SEARCH_API_KEY=...
102
105
  FIRECRAWL_API_KEY=...
@@ -118,7 +121,9 @@ Example:
118
121
  "provider_search": "firecrawl",
119
122
  "provider_fetch": "markdown_new",
120
123
  "apiKeys": {
121
- "firecrawl": "..."
124
+ "firecrawl": "...",
125
+ "context7": "...",
126
+ "exa": "..."
122
127
  },
123
128
  "markdownNew": {
124
129
  "method": "auto",
@@ -165,6 +170,39 @@ Fetches page content with the active fetch provider. Results are cached in memor
165
170
 
166
171
  Provider-specific parameters are exposed only for the configured provider, such as TinyFish `format`, markdown.new `method` / `retainImages`, or Firecrawl `format`, `waitFor`, `mobile`, `location`, and `maxAge`.
167
172
 
173
+ ### `library_search`
174
+
175
+ Resolves packages, frameworks, SDKs, APIs, CLIs, and libraries to canonical library IDs.
176
+
177
+ | Parameter | Type | Description |
178
+ |---|---|---|
179
+ | `libraryName` | string | Library/package/framework name to search for. |
180
+ | `query` | string | Optional user task/question for relevance ranking. |
181
+ | `fast` | boolean | Skip LLM reranking for lower latency. |
182
+ | `limit` | integer | Maximum libraries to return. Range: 1-20. Default: 10. |
183
+
184
+ ### `library_docs`
185
+
186
+ Fetches current docs and code snippets for a library. Provide `libraryId`, or provide `libraryName` and the tool resolves the best match first.
187
+
188
+ | Parameter | Type | Description |
189
+ |---|---|---|
190
+ | `libraryId` | string | Canonical library ID, such as `/vercel/next.js`. |
191
+ | `libraryName` | string | Library name to resolve when `libraryId` is not known. |
192
+ | `query` | string | Specific docs question or coding task. |
193
+ | `version` | string | Optional version/tag to pin, appended as `@version`. |
194
+ | `fast` | boolean | Skip LLM reranking for lower latency. |
195
+ | `limit` | integer | Maximum code and info snippets to return. Range: 1-20. Default: 10. |
196
+
197
+ ### `code_search`
198
+
199
+ Finds practical code examples, implementation context, setup snippets, migrations, usage patterns, and error-message research.
200
+
201
+ | Parameter | Type | Description |
202
+ |---|---|---|
203
+ | `query` | string | Code-context query. |
204
+ | `tokensNum` | `"dynamic"` or integer | Output token target. Integer range: 50-100000. Default: `"dynamic"`. |
205
+
168
206
  ## Cache and limits
169
207
 
170
208
  `web_fetch` uses an in-memory cache for the current Pi process.
@@ -183,7 +221,7 @@ Cache keys include the provider, canonical URL, fetch-affecting parameters, rele
183
221
 
184
222
  ## Privacy and security
185
223
 
186
- `pi-web-kit` sends search queries and fetched URLs to the configured provider. Fetch providers may also receive provider-specific options. API keys are read from environment variables or local config files and are used only for provider requests.
224
+ `pi-web-kit` sends search queries and fetched URLs to the configured provider. Developer-search tools send library/doc queries to Context7 and code-context queries to Exa when those tools are enabled. Fetch providers may also receive provider-specific options. API keys are read from environment variables or local config files and are used only for provider requests.
187
225
 
188
226
  The extension rejects non-HTTP(S) URLs and URLs with embedded username/password credentials. Provider responses are not sandboxed; they are returned to Pi as tool output.
189
227
 
@@ -1,6 +1,3 @@
1
- import { readFileSync } from "node:fs";
2
- import { mkdir, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
4
1
  import { type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
5
2
  import { Text } from "@earendil-works/pi-tui";
6
3
  import { Type } from "typebox";
@@ -8,16 +5,11 @@ import { fetchCache, type CachedPage } from "../src/cache.js";
8
5
  import { resolveConfig } from "../src/config.js";
9
6
  import { DEFAULT_FETCH_LIMIT, DEFAULT_NUM_RESULTS, MAX_LIMIT, MAX_NUM_RESULTS, MAX_OFFSET, MAX_QUERY_COUNT, MAX_URL_COUNT, MULTI_FETCH_LIMIT } from "../src/limits.js";
10
7
  import { truncateText } from "../src/http.js";
11
- import { createFetchProvider, createSearchProvider } from "../src/providers/index.js";
8
+ import { createCodeSearchProvider, createContext7Provider, createFetchProvider, createSearchProvider } from "../src/providers/index.js";
12
9
  import { mapFetchResults } from "../src/providers/fallback.js";
13
10
  import type { FetchProviderName, SearchProviderName, WebFetchResult } from "../src/types.js";
14
11
  import { canonicalWebUrl, normalizeUrlInput } from "../src/urls.js";
15
-
16
- const PACKAGE_NAME = "pi-web-kit";
17
- const INSTALL_TELEMETRY_URL = "https://mocito.dev/api/report-install";
18
- const INSTALL_TELEMETRY_TIMEOUT_MS = 5000;
19
-
20
- type InstallTelemetryState = { lastReportedVersion?: string };
12
+ import { reportInstallTelemetry } from "../src/install-telemetry.js";
21
13
 
22
14
  export default function (pi: ExtensionAPI) {
23
15
  void reportInstallTelemetry();
@@ -108,6 +100,69 @@ export default function (pi: ExtensionAPI) {
108
100
  return renderWebResult("fetch", result, options, theme, context);
109
101
  },
110
102
  });
103
+
104
+ if (startupConfig.apiKeys.context7) {
105
+ pi.registerTool({
106
+ name: "library_search",
107
+ label: "Library Search",
108
+ description: "Resolve library, package, framework, SDK, API, or CLI names to canonical library IDs.",
109
+ promptSnippet: "Resolve a library name to a canonical library ID before querying docs.",
110
+ promptGuidelines: ["Use library_search when a library/framework/package is ambiguous or you need a canonical library ID."],
111
+ parameters: buildLibrarySearchSchema(),
112
+ async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) {
113
+ const params = rawParams as Record<string, any>;
114
+ const libraryName = requiredString(params.libraryName, "libraryName");
115
+ const query = optionalString(params.query, "query") ?? libraryName;
116
+ const limit = parseInteger(params.limit, 10, "limit", 1, MAX_NUM_RESULTS);
117
+ const provider = createContext7Provider(runtimeConfig(pi, ctx.cwd));
118
+ const result = await provider.searchLibraries({ libraryName, query, fast: params.fast === true, limit }, signal);
119
+ return jsonToolResult(result);
120
+ },
121
+ });
122
+
123
+ pi.registerTool({
124
+ name: "library_docs",
125
+ label: "Library Docs",
126
+ description: "Fetch current, version-aware documentation and code examples for a library.",
127
+ promptSnippet: "Get current library documentation and code examples.",
128
+ promptGuidelines: ["Use library_docs for current APIs, framework behavior, SDK examples, package docs, and version-specific library questions."],
129
+ parameters: buildLibraryDocsSchema(),
130
+ async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) {
131
+ const params = rawParams as Record<string, any>;
132
+ const query = requiredString(params.query, "query");
133
+ const limit = parseInteger(params.limit, 10, "limit", 1, MAX_NUM_RESULTS);
134
+ const provider = createContext7Provider(runtimeConfig(pi, ctx.cwd));
135
+ let libraryId = optionalString(params.libraryId, "libraryId");
136
+ if (!libraryId) {
137
+ const libraryName = requiredString(params.libraryName, "libraryName");
138
+ const resolved = await provider.searchLibraries({ libraryName, query, fast: params.fast === true, limit: 1 }, signal);
139
+ libraryId = resolved.results[0]?.id;
140
+ if (!libraryId) throw new Error(`No library found for '${libraryName}'. Try library_search with a more specific name.`);
141
+ }
142
+ const result = await provider.getDocs({ libraryId, query, version: optionalString(params.version, "version"), type: "json", fast: params.fast === true, limit }, signal);
143
+ return jsonToolResult(result);
144
+ },
145
+ });
146
+ }
147
+
148
+ if (startupConfig.apiKeys.exa) {
149
+ pi.registerTool({
150
+ name: "code_search",
151
+ label: "Code Search",
152
+ description: "Find practical code examples, usage patterns, setup snippets, migrations, and error context.",
153
+ promptSnippet: "Find real-world code examples, usage patterns, migrations, and error context.",
154
+ promptGuidelines: ["Use code_search for real-world code examples, GitHub/open-source usage patterns, API syntax examples, setup snippets, migrations, and error messages."],
155
+ parameters: buildCodeSearchSchema(),
156
+ async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) {
157
+ const params = rawParams as Record<string, any>;
158
+ const query = requiredString(params.query, "query");
159
+ const tokensNum = parseTokensNum(params.tokensNum);
160
+ const provider = createCodeSearchProvider(runtimeConfig(pi, ctx.cwd));
161
+ const result = await provider.searchCode({ query, tokensNum }, signal);
162
+ return jsonToolResult(result);
163
+ },
164
+ });
165
+ }
111
166
  }
112
167
 
113
168
  type ProgressKind = "search" | "fetch";
@@ -160,72 +215,41 @@ export function buildFetchSchema(provider: FetchProviderName) {
160
215
  return Type.Object(props, { additionalProperties: false });
161
216
  }
162
217
 
163
- function buildSearchDescription(provider: SearchProviderName): string {
164
- return `Search the web with startup provider '${provider}'. Use query or queries; returns compact results grouped by query. Restart/reload pi after provider changes.`;
165
- }
166
-
167
- function buildFetchDescription(provider: FetchProviderName): string {
168
- return `Fetch URL content with startup provider '${provider}'. Results are cached by URL/options; use offset/limit to read long pages in chunks. Restart/reload pi after provider changes.`;
218
+ export function buildLibrarySearchSchema() {
219
+ return Type.Object({
220
+ libraryName: Type.String({ description: "Library, package, framework, SDK, API, CLI, or product name", minLength: 1, maxLength: 500 }),
221
+ query: Type.Optional(Type.String({ description: "User task/question used for relevance ranking", minLength: 1, maxLength: 500 })),
222
+ fast: Type.Optional(Type.Boolean({ description: "Skip LLM reranking for lower latency" })),
223
+ limit: Type.Optional(int("Maximum libraries to return", 1, MAX_NUM_RESULTS)),
224
+ }, { additionalProperties: false });
169
225
  }
170
226
 
171
- function readJsonFile(path: string): unknown {
172
- try {
173
- return JSON.parse(readFileSync(path, "utf8"));
174
- } catch {
175
- return {};
176
- }
177
- }
178
-
179
- function isTruthyEnvFlag(value: string | undefined): boolean {
180
- if (!value) return false;
181
- return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
227
+ export function buildLibraryDocsSchema() {
228
+ return Type.Object({
229
+ libraryId: Type.Optional(Type.String({ description: "Canonical library ID, for example /vercel/next.js", minLength: 1, maxLength: 500 })),
230
+ libraryName: Type.Optional(Type.String({ description: "Library name to resolve when libraryId is not known", minLength: 1, maxLength: 500 })),
231
+ query: Type.String({ description: "Specific docs question or coding task", minLength: 1, maxLength: 500 }),
232
+ version: Type.Optional(Type.String({ description: "Optional version/tag to pin, appended as @version", minLength: 1, maxLength: 100 })),
233
+ fast: Type.Optional(Type.Boolean({ description: "Skip LLM reranking for lower latency" })),
234
+ limit: Type.Optional(int("Maximum code and info snippets to return", 1, MAX_NUM_RESULTS)),
235
+ }, { additionalProperties: false });
182
236
  }
183
237
 
184
- function isInstallTelemetryEnabled(): boolean {
185
- if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
186
- if (isTruthyEnvFlag(process.env.CI) || isTruthyEnvFlag(process.env.GITHUB_ACTIONS)) return false;
187
- if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
188
- const settings = readJsonFile(join(getAgentDir(), "settings.json")) as { enableInstallTelemetry?: unknown };
189
- return settings.enableInstallTelemetry !== false;
238
+ export function buildCodeSearchSchema() {
239
+ return Type.Object({
240
+ query: Type.String({ description: "Code-context query for examples, APIs, setup, migrations, or errors", minLength: 1, maxLength: 2000 }),
241
+ tokensNum: Type.Optional(Type.Union([Type.Literal("dynamic"), int("Target response tokens", 50, MAX_LIMIT)])),
242
+ }, { additionalProperties: false });
190
243
  }
191
244
 
192
- function getPackageVersion(): string {
193
- try {
194
- const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: unknown };
195
- return typeof packageJson.version === "string" && packageJson.version.length > 0 ? packageJson.version : "0.0.0";
196
- } catch {
197
- return "0.0.0";
198
- }
245
+ function buildSearchDescription(_provider: SearchProviderName): string {
246
+ return "Search the web. Use query or queries; returns compact results grouped by query.";
199
247
  }
200
248
 
201
- function getInstallTelemetryUserAgent(version: string): string {
202
- const runtimeVersions = process.versions as NodeJS.ProcessVersions & { bun?: string };
203
- const runtime = runtimeVersions.bun ? `bun/${runtimeVersions.bun}` : `node/${process.version}`;
204
- return `${PACKAGE_NAME}/${version} (${process.platform}; ${runtime}; ${process.arch})`;
249
+ function buildFetchDescription(_provider: FetchProviderName): string {
250
+ return "Fetch URL content. Results are cached by URL/options; use offset/limit to read long pages in chunks.";
205
251
  }
206
252
 
207
- export async function reportInstallTelemetry(): Promise<void> {
208
- try {
209
- if (!isInstallTelemetryEnabled()) return;
210
-
211
- const version = getPackageVersion();
212
- const telemetryDir = join(getAgentDir(), "extensions");
213
- const statePath = join(telemetryDir, "pi-web-kit-install.json");
214
- const state = readJsonFile(statePath) as InstallTelemetryState;
215
- if (state.lastReportedVersion === version) return;
216
-
217
- await mkdir(telemetryDir, { recursive: true });
218
- await writeFile(statePath, `${JSON.stringify({ lastReportedVersion: version }, null, 2)}\n`);
219
-
220
- const params = new URLSearchParams({ tool: PACKAGE_NAME, version });
221
- await fetch(`${INSTALL_TELEMETRY_URL}?${params.toString()}`, {
222
- headers: { "User-Agent": getInstallTelemetryUserAgent(version) },
223
- signal: AbortSignal.timeout(INSTALL_TELEMETRY_TIMEOUT_MS),
224
- });
225
- } catch {
226
- // Best-effort install telemetry: ignore settings, filesystem, and network failures.
227
- }
228
- }
229
253
 
230
254
  function normalizeQueries(params: Record<string, any>): string[] {
231
255
  const raw = Array.isArray(params.queries) ? params.queries : params.query ? [params.query] : [];
@@ -315,12 +339,37 @@ export function buildCacheKey(provider: FetchProviderName, url: string, params:
315
339
  return `${provider}\0${scope}\0${JSON.stringify(affecting)}\0${canonical}`;
316
340
  }
317
341
 
342
+ function runtimeConfig(pi: ExtensionAPI, cwd: string) {
343
+ return resolveConfig({ providerSearch: pi.getFlag("web-provider-search"), providerFetch: pi.getFlag("web-provider-fetch") }, cwd);
344
+ }
345
+
346
+ function jsonToolResult(result: unknown) {
347
+ const text = truncateText(JSON.stringify(result, null, 2));
348
+ return { content: [{ type: "text" as const, text }], details: boundedDetails(result) };
349
+ }
350
+
318
351
  function parseInteger(value: unknown, defaultValue: number, name: string, min: number, max: number): number {
319
352
  if (value == null) return defaultValue;
320
353
  if (typeof value !== "number" || !Number.isInteger(value) || !Number.isFinite(value) || value < min || value > max) throw new Error(`${name} must be a finite integer between ${min} and ${max}.`);
321
354
  return value;
322
355
  }
323
356
 
357
+ function requiredString(value: unknown, name: string): string {
358
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${name} must be a non-empty string.`);
359
+ return value.trim();
360
+ }
361
+
362
+ function optionalString(value: unknown, name: string): string | undefined {
363
+ if (value == null) return undefined;
364
+ return requiredString(value, name);
365
+ }
366
+
367
+ function parseTokensNum(value: unknown): "dynamic" | number {
368
+ if (value == null) return "dynamic";
369
+ if (value === "dynamic") return "dynamic";
370
+ return parseInteger(value, 0, "tokensNum", 50, MAX_LIMIT);
371
+ }
372
+
324
373
  function fetchConfigDefaults(provider: FetchProviderName, config?: any): Record<string, unknown> {
325
374
  if (provider === "markdown_new") return { method: config?.markdownNew?.method ?? "auto", retainImages: config?.markdownNew?.retainImages ?? false };
326
375
  if (provider === "firecrawl") return { onlyMainContent: true, format: "markdown" };
@@ -343,11 +392,74 @@ function assertProviderUnchanged(tool: string, startup: string, runtime: string)
343
392
  }
344
393
 
345
394
  function boundedDetails(value: any): unknown {
346
- if (value?.queries && Array.isArray(value.queries)) return { provider: value.provider, queries: value.queries.map((q: any) => ({ query: q.query, resultCount: (q.results ?? []).length, results: (q.results ?? []).map((r: any) => ({ title: r.title, url: r.url, siteName: r.siteName, position: r.position })) })) };
347
- if (value?.results && Array.isArray(value.results)) return { provider: value.provider, results: value.results.map((r: any) => ({ url: r.url, fetchedUrl: r.fetchedUrl, title: r.title, format: r.format, cached: r.cached, refreshed: r.refreshed, cacheKey: r.cacheKey, range: r.range, error: r.error })) };
395
+ if (value?.queries && Array.isArray(value.queries)) return searchDetails(value);
396
+ if (value?.provider === "context7" && value?.results && Array.isArray(value.results)) return librarySearchDetails(value);
397
+ if (value?.provider === "context7" && value?.codeSnippets && value?.infoSnippets) return libraryDocsDetails(value);
398
+ if (value?.provider === "exa" && typeof value.response === "string") return codeSearchDetails(value);
399
+ if (value?.results && Array.isArray(value.results)) return fetchDetails(value);
348
400
  return value;
349
401
  }
350
402
 
403
+ function searchDetails(value: any) {
404
+ return {
405
+ provider: value.provider,
406
+ queries: value.queries.map((q: any) => ({
407
+ query: q.query,
408
+ resultCount: (q.results ?? []).length,
409
+ results: (q.results ?? []).map((r: any) => ({ title: r.title, url: r.url, siteName: r.siteName, position: r.position })),
410
+ })),
411
+ };
412
+ }
413
+
414
+ function librarySearchDetails(value: any) {
415
+ return {
416
+ provider: value.provider,
417
+ libraryName: value.libraryName,
418
+ resultCount: value.results.length,
419
+ results: value.results.map((r: any) => ({ id: r.id, title: r.title, state: r.state, trustScore: r.trustScore, versions: r.versions })),
420
+ };
421
+ }
422
+
423
+ function libraryDocsDetails(value: any) {
424
+ const codeSources = value.codeSnippets.map((s: any) => s.codeId);
425
+ const infoSources = value.infoSnippets.map((s: any) => s.pageId);
426
+ return {
427
+ provider: value.provider,
428
+ libraryId: value.libraryId,
429
+ query: value.query,
430
+ codeSnippetCount: value.codeSnippets.length,
431
+ infoSnippetCount: value.infoSnippets.length,
432
+ sources: [...codeSources, ...infoSources].filter(Boolean).slice(0, MAX_NUM_RESULTS),
433
+ };
434
+ }
435
+
436
+ function codeSearchDetails(value: any) {
437
+ return {
438
+ provider: value.provider,
439
+ query: value.query,
440
+ resultsCount: value.resultsCount,
441
+ outputTokens: value.outputTokens,
442
+ requestId: value.requestId,
443
+ };
444
+ }
445
+
446
+ function fetchDetails(value: any) {
447
+ return {
448
+ provider: value.provider,
449
+ results: value.results.map((r: any) => ({
450
+ url: r.url,
451
+ fetchedUrl: r.fetchedUrl,
452
+ title: r.title,
453
+ format: r.format,
454
+ cached: r.cached,
455
+ refreshed: r.refreshed,
456
+ cacheKey: r.cacheKey,
457
+ range: r.range,
458
+ error: r.error,
459
+ })),
460
+ };
461
+ }
462
+
351
463
  function createProgress(kind: ProgressKind, provider: string, labels: string[]): WebProgress {
352
464
  return { kind, provider, total: labels.length, completed: 0, items: labels.map((label) => ({ label, status: "pending" })) };
353
465
  }
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./extensions/index.js";
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "pi-web-kit",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Context-efficient web search and fetch tools for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "author": "jvm",
7
+ "author": "Jose Mocito",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/jvm/pi-mono.git",
@@ -20,6 +20,9 @@
20
20
  "pi",
21
21
  "web-search",
22
22
  "web-fetch",
23
+ "library-docs",
24
+ "code-search",
25
+ "context7",
23
26
  "exa",
24
27
  "firecrawl"
25
28
  ],
@@ -28,10 +31,11 @@
28
31
  },
29
32
  "pi": {
30
33
  "extensions": [
31
- "./extensions/index.ts"
34
+ "./index.ts"
32
35
  ]
33
36
  },
34
37
  "files": [
38
+ "index.ts",
35
39
  "extensions",
36
40
  "src",
37
41
  "README.md",
@@ -54,8 +58,12 @@
54
58
  "typebox": "*"
55
59
  },
56
60
  "devDependencies": {
61
+ "@earendil-works/pi-ai": "^0.78.0",
62
+ "@earendil-works/pi-coding-agent": "^0.78.0",
63
+ "@earendil-works/pi-tui": "^0.78.0",
57
64
  "@types/node": "^25.6.2",
58
- "tsx": "^4.21.0",
65
+ "tsx": "^4.22.4",
66
+ "typebox": "^1.1.39",
59
67
  "typescript": "^6.0.3"
60
68
  },
61
69
  "publishConfig": {
package/src/config.ts CHANGED
@@ -26,6 +26,7 @@ export function resolveConfig(flags: { providerSearch?: unknown; providerFetch?:
26
26
  tinyfish: env.TINYFISH_API_KEY,
27
27
  brave: env.BRAVE_SEARCH_API_KEY,
28
28
  firecrawl: env.FIRECRAWL_API_KEY,
29
+ context7: env.CONTEXT7_API_KEY,
29
30
  },
30
31
  });
31
32
  const home = env.HOME ?? homedir();
@@ -63,9 +64,9 @@ export function validateFetchProvider(name: string): asserts name is FetchProvid
63
64
  if (!FETCH.includes(name as FetchProviderName)) throw new Error(`Unknown fetch provider '${name}'. Expected one of: ${FETCH.join(", ")}.`);
64
65
  }
65
66
 
66
- const PROVIDER_ENV_NAMES = { exa: "EXA_API_KEY", tinyfish: "TINYFISH_API_KEY", brave: "BRAVE_SEARCH_API_KEY", firecrawl: "FIRECRAWL_API_KEY" } as const;
67
+ const PROVIDER_ENV_NAMES = { exa: "EXA_API_KEY", tinyfish: "TINYFISH_API_KEY", brave: "BRAVE_SEARCH_API_KEY", firecrawl: "FIRECRAWL_API_KEY", context7: "CONTEXT7_API_KEY" } as const;
67
68
 
68
- export function requireKey(config: WebKitConfig, provider: "exa" | "tinyfish" | "brave" | "firecrawl"): string {
69
+ export function requireKey(config: WebKitConfig, provider: "exa" | "tinyfish" | "brave" | "firecrawl" | "context7"): string {
69
70
  const key = config.apiKeys[provider];
70
71
  const envName = PROVIDER_ENV_NAMES[provider];
71
72
  if (!key) throw new Error(`${provider} provider requires ${envName} or apiKeys.${provider} in .pi-web-kit.json / ~/.pi/agent/pi-web-kit.json.`);
@@ -0,0 +1,69 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+
6
+ const PACKAGE_NAME = "pi-web-kit";
7
+ const INSTALL_TELEMETRY_URL = "https://mocito.dev/api/report-install";
8
+ const INSTALL_TELEMETRY_TIMEOUT_MS = 5000;
9
+
10
+ type InstallTelemetryState = { lastReportedVersion?: string };
11
+
12
+ function readJsonFile(path: string): unknown {
13
+ try {
14
+ return JSON.parse(readFileSync(path, "utf8"));
15
+ } catch {
16
+ return {};
17
+ }
18
+ }
19
+
20
+ function isTruthyEnvFlag(value: string | undefined): boolean {
21
+ if (!value) return false;
22
+ return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
23
+ }
24
+
25
+ function isInstallTelemetryEnabled(): boolean {
26
+ if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
27
+ if (isTruthyEnvFlag(process.env.CI) || isTruthyEnvFlag(process.env.GITHUB_ACTIONS)) return false;
28
+ if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
29
+ const settings = readJsonFile(join(getAgentDir(), "settings.json")) as { enableInstallTelemetry?: unknown };
30
+ return settings.enableInstallTelemetry !== false;
31
+ }
32
+
33
+ function getPackageVersion(): string {
34
+ try {
35
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: unknown };
36
+ return typeof packageJson.version === "string" && packageJson.version.length > 0 ? packageJson.version : "0.0.0";
37
+ } catch {
38
+ return "0.0.0";
39
+ }
40
+ }
41
+
42
+ function getInstallTelemetryUserAgent(version: string): string {
43
+ const runtimeVersions = process.versions as NodeJS.ProcessVersions & { bun?: string };
44
+ const runtime = runtimeVersions.bun ? `bun/${runtimeVersions.bun}` : `node/${process.version}`;
45
+ return `${PACKAGE_NAME}/${version} (${process.platform}; ${runtime}; ${process.arch})`;
46
+ }
47
+
48
+ export async function reportInstallTelemetry(): Promise<void> {
49
+ try {
50
+ if (!isInstallTelemetryEnabled()) return;
51
+
52
+ const version = getPackageVersion();
53
+ const telemetryDir = join(getAgentDir(), "extensions");
54
+ const statePath = join(telemetryDir, "pi-web-kit-install.json");
55
+ const state = readJsonFile(statePath) as InstallTelemetryState;
56
+ if (state.lastReportedVersion === version) return;
57
+
58
+ await mkdir(telemetryDir, { recursive: true });
59
+ await writeFile(statePath, `${JSON.stringify({ lastReportedVersion: version }, null, 2)}\n`);
60
+
61
+ const params = new URLSearchParams({ tool: PACKAGE_NAME, version });
62
+ await fetch(`${INSTALL_TELEMETRY_URL}?${params.toString()}`, {
63
+ headers: { "User-Agent": getInstallTelemetryUserAgent(version) },
64
+ signal: AbortSignal.timeout(INSTALL_TELEMETRY_TIMEOUT_MS),
65
+ });
66
+ } catch {
67
+ // Best-effort install telemetry: ignore settings, filesystem, and network failures.
68
+ }
69
+ }
@@ -0,0 +1,105 @@
1
+ import { requestJson } from "../http.js";
2
+ import type { Context7ContextInput, Context7DocsResult, Context7LibrarySearchInput, Context7LibrarySearchResult, WebKitConfig } from "../types.js";
3
+ import { requireKey } from "../config.js";
4
+
5
+ const BASE_URL = "https://context7.com/api/v2";
6
+
7
+ export class Context7Provider {
8
+ private key: string;
9
+ constructor(config: WebKitConfig) { this.key = requireKey(config, "context7"); }
10
+
11
+ async searchLibraries(input: Context7LibrarySearchInput, signal?: AbortSignal): Promise<Context7LibrarySearchResult> {
12
+ const query = input.query ?? input.libraryName;
13
+ const params = new URLSearchParams({ libraryName: input.libraryName, query });
14
+ addFastParam(params, input.fast);
15
+ const data = await requestJson<any>(`${BASE_URL}/libs/search?${params}`, {
16
+ headers: this.headers(),
17
+ signal,
18
+ timeoutMs: 30_000,
19
+ });
20
+ const limit = input.limit ?? 10;
21
+ return {
22
+ provider: "context7",
23
+ libraryName: input.libraryName,
24
+ query,
25
+ searchFilterApplied: data.searchFilterApplied,
26
+ results: (data.results ?? []).slice(0, limit).map(toLibraryResult).filter((r: any) => r.id),
27
+ };
28
+ }
29
+
30
+ async getDocs(input: Context7ContextInput, signal?: AbortSignal): Promise<Context7DocsResult> {
31
+ const libraryId = withVersion(input.libraryId, input.version);
32
+ const params = new URLSearchParams({ libraryId, query: input.query, type: input.type ?? "json" });
33
+ addFastParam(params, input.fast);
34
+ const data = await requestJson<any>(`${BASE_URL}/context?${params}`, {
35
+ headers: this.headers(),
36
+ signal,
37
+ timeoutMs: 45_000,
38
+ });
39
+ const limit = input.limit ?? 10;
40
+ return {
41
+ provider: "context7",
42
+ libraryId,
43
+ query: input.query,
44
+ codeSnippets: (data.codeSnippets ?? []).slice(0, limit).map(toCodeSnippet),
45
+ infoSnippets: (data.infoSnippets ?? []).slice(0, limit).map(toInfoSnippet),
46
+ rules: data.rules,
47
+ };
48
+ }
49
+
50
+ private headers(): HeadersInit {
51
+ return { authorization: `Bearer ${this.key}` };
52
+ }
53
+ }
54
+
55
+ function addFastParam(params: URLSearchParams, fast?: boolean) {
56
+ if (fast != null) params.set("fast", String(fast));
57
+ }
58
+
59
+ function toLibraryResult(r: any) {
60
+ return {
61
+ id: r.id,
62
+ title: r.title,
63
+ description: r.description,
64
+ branch: r.branch,
65
+ lastUpdateDate: r.lastUpdateDate,
66
+ state: r.state,
67
+ totalTokens: r.totalTokens,
68
+ totalSnippets: r.totalSnippets,
69
+ stars: r.stars,
70
+ trustScore: r.trustScore,
71
+ benchmarkScore: r.benchmarkScore,
72
+ versions: r.versions,
73
+ };
74
+ }
75
+
76
+ function toCodeSnippet(s: any) {
77
+ return {
78
+ codeTitle: s.codeTitle,
79
+ codeDescription: s.codeDescription,
80
+ codeLanguage: s.codeLanguage,
81
+ codeTokens: s.codeTokens,
82
+ codeId: s.codeId,
83
+ pageTitle: s.pageTitle,
84
+ sourceFile: s.sourceFile,
85
+ isDynamic: s.isDynamic,
86
+ codeList: s.codeList,
87
+ };
88
+ }
89
+
90
+ function toInfoSnippet(s: any) {
91
+ return {
92
+ pageId: s.pageId,
93
+ breadcrumb: s.breadcrumb,
94
+ content: s.content,
95
+ contentTokens: s.contentTokens,
96
+ };
97
+ }
98
+
99
+ function withVersion(libraryId: string, version?: string): string {
100
+ const id = libraryId.trim();
101
+ const v = version?.trim();
102
+ if (!v) return id;
103
+ if (id.endsWith(`@${v}`) || id.endsWith(`/${v}`)) return id;
104
+ return `${id}@${v}`;
105
+ }
@@ -1,7 +1,7 @@
1
1
  import { asSnippet, normalizeUrls, requestJson } from "../http.js";
2
2
  import { DEFAULT_NUM_RESULTS } from "../limits.js";
3
3
  import { urlsMatch } from "../urls.js";
4
- import type { FetchInput, FetchProvider, SearchInput, SearchProvider, WebFetchResult, WebKitConfig } from "../types.js";
4
+ import type { ExaCodeInput, ExaCodeResult, FetchInput, FetchProvider, SearchInput, SearchProvider, WebFetchResult, WebKitConfig } from "../types.js";
5
5
  import { requireKey } from "../config.js";
6
6
  import { applyExaFetchFallbacks } from "./fallback.js";
7
7
 
@@ -61,4 +61,26 @@ export class ExaProvider implements SearchProvider, FetchProvider {
61
61
  }) };
62
62
  return applyExaFetchFallbacks(this.config, input, urls, primary, signal);
63
63
  }
64
+
65
+ async searchCode(input: ExaCodeInput, signal?: AbortSignal): Promise<ExaCodeResult> {
66
+ const data = await requestJson<any>("https://api.exa.ai/context", {
67
+ method: "POST",
68
+ headers: { "content-type": "application/json", "x-api-key": this.key },
69
+ body: JSON.stringify({
70
+ query: input.query,
71
+ tokensNum: input.tokensNum ?? "dynamic",
72
+ }),
73
+ signal,
74
+ timeoutMs: 45_000,
75
+ });
76
+ return {
77
+ provider: "exa",
78
+ query: data.query ?? input.query,
79
+ response: data.response ?? "",
80
+ resultsCount: data.resultsCount,
81
+ searchTime: data.searchTime,
82
+ outputTokens: data.outputTokens,
83
+ requestId: data.requestId,
84
+ };
85
+ }
64
86
  }
@@ -1,6 +1,7 @@
1
1
  import { validateFetchProvider, validateSearchProvider } from "../config.js";
2
2
  import type { FetchProvider, SearchProvider, WebKitConfig } from "../types.js";
3
3
  import { BraveProvider } from "./brave.js";
4
+ import { Context7Provider } from "./context7.js";
4
5
  import { ExaMcpProvider } from "./exa-mcp.js";
5
6
  import { ExaProvider } from "./exa.js";
6
7
  import { FirecrawlProvider } from "./firecrawl.js";
@@ -28,3 +29,11 @@ export function createFetchProvider(config: WebKitConfig): FetchProvider {
28
29
  case "firecrawl": return new FirecrawlProvider(config);
29
30
  }
30
31
  }
32
+
33
+ export function createContext7Provider(config: WebKitConfig): Context7Provider {
34
+ return new Context7Provider(config);
35
+ }
36
+
37
+ export function createCodeSearchProvider(config: WebKitConfig): ExaProvider {
38
+ return new ExaProvider(config);
39
+ }
package/src/types.ts CHANGED
@@ -5,7 +5,7 @@ export type FetchFormat = "markdown" | "html" | "json";
5
5
  export interface WebKitConfig {
6
6
  provider_search: SearchProviderName;
7
7
  provider_fetch: FetchProviderName;
8
- apiKeys: Partial<Record<"exa" | "tinyfish" | "brave" | "firecrawl", string>>;
8
+ apiKeys: Partial<Record<"exa" | "tinyfish" | "brave" | "firecrawl" | "context7", string>>;
9
9
  markdownNew: { method: "auto" | "ai" | "browser"; retainImages: boolean };
10
10
  }
11
11
 
@@ -38,6 +38,77 @@ export interface WebFetchResult {
38
38
  results: Array<{ url: string; content?: string; format?: FetchFormat; title?: string; metadata?: Record<string, unknown>; error?: string }>;
39
39
  }
40
40
 
41
+ export interface Context7LibrarySearchInput {
42
+ libraryName: string;
43
+ query?: string;
44
+ fast?: boolean;
45
+ limit?: number;
46
+ }
47
+
48
+ export interface Context7LibrarySearchResult {
49
+ provider: "context7";
50
+ libraryName: string;
51
+ query: string;
52
+ searchFilterApplied?: boolean;
53
+ results: Array<{
54
+ id: string;
55
+ title?: string;
56
+ description?: string;
57
+ branch?: string;
58
+ lastUpdateDate?: string;
59
+ state?: string;
60
+ totalTokens?: number;
61
+ totalSnippets?: number;
62
+ stars?: number;
63
+ trustScore?: number;
64
+ benchmarkScore?: number;
65
+ versions?: string[];
66
+ }>;
67
+ }
68
+
69
+ export interface Context7ContextInput {
70
+ libraryId: string;
71
+ query: string;
72
+ version?: string;
73
+ type?: "json";
74
+ fast?: boolean;
75
+ limit?: number;
76
+ }
77
+
78
+ export interface Context7DocsResult {
79
+ provider: "context7";
80
+ libraryId: string;
81
+ query: string;
82
+ codeSnippets: Array<{
83
+ codeTitle?: string;
84
+ codeDescription?: string;
85
+ codeLanguage?: string;
86
+ codeTokens?: number;
87
+ codeId?: string;
88
+ pageTitle?: string;
89
+ sourceFile?: string;
90
+ isDynamic?: boolean;
91
+ codeList?: Array<{ language: string; code: string }>;
92
+ }>;
93
+ infoSnippets: Array<{ pageId?: string; breadcrumb?: string; content: string; contentTokens?: number }>;
94
+ rules?: unknown;
95
+ }
96
+
97
+ export interface ExaCodeInput {
98
+ query: string;
99
+ tokensNum?: "dynamic" | number;
100
+ }
101
+
102
+ export interface ExaCodeResult {
103
+ provider: "exa";
104
+ query: string;
105
+ response: string;
106
+ resultsCount?: number;
107
+ searchTime?: number;
108
+ outputTokens?: number;
109
+ requestId?: string;
110
+ }
111
+
41
112
  export interface SearchProvider {
42
113
  search(input: SearchInput, signal?: AbortSignal): Promise<WebSearchResult>;
43
114
  }