@mrclrchtr/supi-web 1.14.2 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/docs.ts CHANGED
@@ -1,198 +1,337 @@
1
1
  /**
2
2
  * SuPi Web Context7 extension — registers web_docs_search and web_docs_fetch tools.
3
3
  *
4
- * Uses @upstash/context7-sdk for up-to-date library documentation lookups.
5
4
  * API key is read automatically from the CONTEXT7_API_KEY environment variable.
6
5
  */
7
6
 
8
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
- import { Type } from "typebox";
10
- import { Context7Error, getContext, searchLibrary } from "./context7-client.ts";
7
+ import type {
8
+ AgentToolResult,
9
+ AgentToolUpdateCallback,
10
+ ExtensionAPI,
11
+ ExtensionContext,
12
+ TruncationResult,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import { getContext, searchLibrary } from "./context7-client.ts";
15
+ import { getWebToolPromptSurface } from "./tool/guidance.ts";
16
+ import { limitModelVisibleOutput } from "./tool/output.ts";
17
+ import { renderCollapsibleTextResult, renderToolCall } from "./tool/render.ts";
11
18
  import {
12
- promptGuidelines as fetchPromptGuidelines,
13
- promptSnippet as fetchPromptSnippet,
14
- toolDescription as fetchToolDescription,
15
- } from "./tool/web-docs-fetch-guidance.ts";
16
- import {
17
- promptGuidelines as searchPromptGuidelines,
18
- promptSnippet as searchPromptSnippet,
19
- toolDescription as searchToolDescription,
20
- } from "./tool/web-docs-search-guidance.ts";
19
+ getWebToolSpec,
20
+ WEB_DOCS_FETCH_TOOL_NAME,
21
+ WEB_DOCS_SEARCH_TOOL_NAME,
22
+ type WebDocsFetchInput,
23
+ type WebDocsSearchInput,
24
+ } from "./tool/tool-specs.ts";
25
+
26
+ interface SearchDetails extends Record<string, unknown> {
27
+ count: number;
28
+ libraryName: string;
29
+ truncation?: TruncationResult;
30
+ fullOutputPath?: string;
31
+ }
32
+
33
+ interface FetchDetails extends Record<string, unknown> {
34
+ libraryId: string;
35
+ raw: boolean;
36
+ chars: number;
37
+ lines: number;
38
+ truncation?: TruncationResult;
39
+ fullOutputPath?: string;
40
+ }
41
+
42
+ type SearchLibraryResult = Awaited<ReturnType<typeof searchLibrary>>[number];
21
43
 
22
- const SEARCH_TOOL_NAME = "web_docs_search";
23
- const SEARCH_TOOL_LABEL = "Web Docs Search";
24
- const FETCH_TOOL_NAME = "web_docs_fetch";
25
- const FETCH_TOOL_LABEL = "Web Docs Fetch";
44
+ const MAX_SEARCH_RESULTS = 10;
45
+ const MAX_DESCRIPTION_CHARS = 120;
46
+ const MAX_VERSION_COUNT = 5;
26
47
 
27
48
  export default function docsExtension(pi: ExtensionAPI): void {
49
+ const searchSpec = getWebToolSpec(WEB_DOCS_SEARCH_TOOL_NAME);
50
+ const searchSurface = getWebToolPromptSurface(WEB_DOCS_SEARCH_TOOL_NAME);
28
51
  pi.registerTool({
29
- name: SEARCH_TOOL_NAME,
30
- label: SEARCH_TOOL_LABEL,
31
- description: searchToolDescription,
32
- promptSnippet: searchPromptSnippet,
33
- promptGuidelines: searchPromptGuidelines,
34
- parameters: Type.Object({
35
- library_name: Type.String({
36
- description: "Library name to search for (e.g. react, next.js, fastapi)",
37
- }),
38
- query: Type.String({
39
- description: "What you're trying to do — used for relevance ranking of results",
40
- }),
41
- }),
52
+ name: searchSpec.name,
53
+ label: searchSpec.label,
54
+ description: searchSurface.description,
55
+ promptSnippet: searchSurface.promptSnippet,
56
+ promptGuidelines: searchSurface.promptGuidelines,
57
+ parameters: searchSpec.parameters,
42
58
  execute: runSearch,
59
+ renderCall(args, theme) {
60
+ const input = (args ?? {}) as WebDocsSearchInput;
61
+ const libraryName = typeof input.library_name === "string" ? input.library_name : "";
62
+ const query = typeof input.query === "string" ? truncatePreview(input.query) : undefined;
63
+ return renderToolCall(searchSpec.name, libraryName, theme, query);
64
+ },
65
+ renderResult(result, { expanded, isPartial }, theme) {
66
+ if (isPartial) {
67
+ return renderCollapsibleTextResult({
68
+ summary: theme.fg("warning", "Searching Context7..."),
69
+ expanded,
70
+ theme,
71
+ });
72
+ }
73
+
74
+ const details = result.details as SearchDetails | undefined;
75
+ const summary = buildSearchSummary(details, theme);
76
+ const content = result.content.find((item) => item.type === "text");
77
+ const body =
78
+ details?.count === 0 ? undefined : content?.type === "text" ? content.text : undefined;
79
+
80
+ return renderCollapsibleTextResult({
81
+ summary,
82
+ body,
83
+ expanded,
84
+ theme,
85
+ fullOutputPath: details?.fullOutputPath,
86
+ });
87
+ },
43
88
  });
44
89
 
90
+ const fetchSpec = getWebToolSpec(WEB_DOCS_FETCH_TOOL_NAME);
91
+ const fetchSurface = getWebToolPromptSurface(WEB_DOCS_FETCH_TOOL_NAME);
45
92
  pi.registerTool({
46
- name: FETCH_TOOL_NAME,
47
- label: FETCH_TOOL_LABEL,
48
- description: fetchToolDescription,
49
- promptSnippet: fetchPromptSnippet,
50
- promptGuidelines: fetchPromptGuidelines,
51
- parameters: Type.Object({
52
- library_id: Type.String({
53
- description:
54
- "Context7 library ID (e.g. /facebook/react, /vercel/next.js). Find it via web_docs_search.",
55
- }),
56
- query: Type.String({
57
- description: "Specific question about the library",
58
- }),
59
- raw: Type.Optional(
60
- Type.Boolean({
61
- description:
62
- "When true, returns JSON-serialized snippet objects instead of plain text Markdown",
63
- default: false,
64
- }),
65
- ),
66
- }),
93
+ name: fetchSpec.name,
94
+ label: fetchSpec.label,
95
+ description: fetchSurface.description,
96
+ promptSnippet: fetchSurface.promptSnippet,
97
+ promptGuidelines: fetchSurface.promptGuidelines,
98
+ parameters: fetchSpec.parameters,
67
99
  execute: runFetch,
100
+ renderCall(args, theme) {
101
+ const input = (args ?? {}) as WebDocsFetchInput;
102
+ const libraryId = typeof input.library_id === "string" ? input.library_id : "";
103
+ const query = typeof input.query === "string" ? truncatePreview(input.query) : undefined;
104
+ return renderToolCall(fetchSpec.name, libraryId, theme, query);
105
+ },
106
+ renderResult(result, { expanded, isPartial }, theme) {
107
+ if (isPartial) {
108
+ return renderCollapsibleTextResult({
109
+ summary: theme.fg("warning", "Fetching Context7 docs..."),
110
+ expanded,
111
+ theme,
112
+ });
113
+ }
114
+
115
+ const details = result.details as FetchDetails | undefined;
116
+ const summary = buildFetchSummary(details, theme);
117
+ const content = result.content.find((item) => item.type === "text");
118
+ const body = content?.type === "text" ? content.text : undefined;
119
+
120
+ return renderCollapsibleTextResult({
121
+ summary,
122
+ body,
123
+ expanded,
124
+ theme,
125
+ fullOutputPath: details?.fullOutputPath,
126
+ });
127
+ },
68
128
  });
69
129
  }
70
130
 
71
131
  // biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
72
132
  async function runSearch(
73
133
  _toolCallId: string,
74
- params: Record<string, unknown>,
75
- _signal: AbortSignal | undefined,
76
- _onUpdate: unknown,
77
- _ctx: unknown,
78
- ): Promise<{
79
- content: { type: "text"; text: string }[];
80
- details: Record<string, unknown>;
81
- isError?: boolean;
82
- }> {
83
- const libraryName = (params.library_name as string | undefined)?.trim();
84
- const query = (params.query as string | undefined)?.trim();
85
-
86
- if (!libraryName) {
87
- return {
88
- content: [{ type: "text", text: "Error: 'library_name' parameter is required" }],
89
- isError: true,
90
- details: {},
91
- };
92
- }
134
+ params: unknown,
135
+ signal: AbortSignal | undefined,
136
+ onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined,
137
+ _ctx: ExtensionContext,
138
+ ): Promise<AgentToolResult<SearchDetails>> {
139
+ const input = (params ?? {}) as WebDocsSearchInput;
140
+ const libraryName = input.library_name?.trim();
141
+ const query = input.query?.trim();
93
142
 
94
- if (!query) {
95
- return {
96
- content: [{ type: "text", text: "Error: 'query' parameter is required" }],
97
- isError: true,
98
- details: {},
99
- };
100
- }
143
+ if (!libraryName) throw new Error("'library_name' parameter is required");
144
+ if (!query) throw new Error("'query' parameter is required");
101
145
 
102
- try {
103
- const results = await searchLibrary(query, libraryName);
104
-
105
- if (results.length === 0) {
106
- return {
107
- content: [
108
- {
109
- type: "text",
110
- text: `No libraries found for "${libraryName}". Try a different search term.`,
111
- },
112
- ],
113
- details: { count: 0, libraryName },
114
- };
115
- }
116
-
117
- const rows = results.map(
118
- (lib) =>
119
- `| **${escapeMd(lib.name)}** | \`${escapeMd(lib.id)}\` | ${escapeMd(lib.description ?? "")} | ${lib.trustScore ?? ""} | ${lib.benchmarkScore ?? ""} | ${lib.totalSnippets ?? ""} | ${lib.versions ? lib.versions.join(", ") : ""} |`,
120
- );
121
-
122
- const markdown = [
123
- `Found **${results.length}** library/libraries matching "${libraryName}":`,
124
- "",
125
- "| Name | ID | Description | Trust | Benchmark | Snippets | Versions |",
126
- "|---|---|---|---|---|---|---|",
127
- ...rows,
128
- "",
129
- `> Use \`web_docs_fetch\` with the library ID to retrieve documentation.`,
130
- ].join("\n");
146
+ onUpdate?.({
147
+ content: [{ type: "text", text: `Searching Context7 for ${libraryName}...` }],
148
+ details: { libraryName },
149
+ });
131
150
 
151
+ const requestOptions = signal ? { signal } : undefined;
152
+ const results = await searchLibrary(query, libraryName, requestOptions);
153
+
154
+ if (results.length === 0) {
132
155
  return {
133
- content: [{ type: "text", text: markdown }],
134
- details: { count: results.length, libraryName },
135
- };
136
- } catch (err) {
137
- const message = err instanceof Context7Error ? err.message : `Unexpected error: ${String(err)}`;
138
- return {
139
- content: [{ type: "text", text: `Error: ${message}` }],
140
- isError: true,
141
- details: { libraryName, error: String(err) },
156
+ content: [
157
+ {
158
+ type: "text",
159
+ text: `No libraries found for "${libraryName}". Try a different search term.`,
160
+ },
161
+ ],
162
+ details: { count: 0, libraryName },
142
163
  };
143
164
  }
165
+
166
+ const markdown = formatSearchResults(libraryName, results);
167
+ const output = await limitModelVisibleOutput(markdown, {
168
+ tempPrefix: "web-docs-search",
169
+ suffix: ".md",
170
+ });
171
+
172
+ return {
173
+ content: [{ type: "text", text: output.text }],
174
+ details: {
175
+ count: results.length,
176
+ libraryName,
177
+ truncation: output.truncation,
178
+ fullOutputPath: output.fullOutputPath,
179
+ },
180
+ };
144
181
  }
145
182
 
146
183
  // biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
147
184
  async function runFetch(
148
185
  _toolCallId: string,
149
- params: Record<string, unknown>,
150
- _signal: AbortSignal | undefined,
151
- _onUpdate: unknown,
152
- _ctx: unknown,
153
- ): Promise<{
154
- content: { type: "text"; text: string }[];
155
- details: Record<string, unknown>;
156
- isError?: boolean;
157
- }> {
158
- const libraryId = (params.library_id as string | undefined)?.trim();
159
- const query = (params.query as string | undefined)?.trim();
160
- const raw = Boolean(params.raw);
161
-
162
- if (!libraryId) {
163
- return {
164
- content: [{ type: "text", text: "Error: 'library_id' parameter is required" }],
165
- isError: true,
166
- details: {},
167
- };
186
+ params: unknown,
187
+ signal: AbortSignal | undefined,
188
+ onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined,
189
+ _ctx: ExtensionContext,
190
+ ): Promise<AgentToolResult<FetchDetails>> {
191
+ const input = (params ?? {}) as WebDocsFetchInput;
192
+ const libraryId = input.library_id?.trim();
193
+ const query = input.query?.trim();
194
+ const raw = Boolean(input.raw);
195
+
196
+ if (!libraryId) throw new Error("'library_id' parameter is required");
197
+ if (!query) throw new Error("'query' parameter is required");
198
+
199
+ onUpdate?.({
200
+ content: [{ type: "text", text: `Fetching Context7 docs for ${libraryId}...` }],
201
+ details: { libraryId, raw },
202
+ });
203
+
204
+ const requestOptions = signal ? { signal } : undefined;
205
+ const content = await getContext(query, libraryId, raw, requestOptions);
206
+ const textContent = typeof content === "string" ? content : JSON.stringify(content, null, 2);
207
+ const output = await limitModelVisibleOutput(textContent, {
208
+ tempPrefix: "web-docs-fetch",
209
+ suffix: raw ? ".json" : ".md",
210
+ });
211
+
212
+ return {
213
+ content: [{ type: "text", text: output.text }],
214
+ details: {
215
+ libraryId,
216
+ raw,
217
+ chars: textContent.length,
218
+ lines: textContent.split("\n").length,
219
+ truncation: output.truncation,
220
+ fullOutputPath: output.fullOutputPath,
221
+ },
222
+ };
223
+ }
224
+
225
+ function formatSearchResults(
226
+ libraryName: string,
227
+ results: Awaited<ReturnType<typeof searchLibrary>>,
228
+ ): string {
229
+ const visibleResults = results.slice(0, MAX_SEARCH_RESULTS);
230
+ const hiddenCount = results.length - visibleResults.length;
231
+ const rows = visibleResults.map(formatSearchRow);
232
+ const noun = results.length === 1 ? "library" : "libraries";
233
+ const hiddenNote =
234
+ hiddenCount > 0
235
+ ? [`_${hiddenCount} more omitted; refine \`library_name\` or \`query\` if needed._`, ""]
236
+ : [];
237
+
238
+ return [
239
+ `Found ${results.length} Context7 ${noun} for "${libraryName}"${hiddenCount > 0 ? `; showing top ${visibleResults.length}` : ""}:`,
240
+ "",
241
+ "| ID | Name | Trust | Bench | Snips | Versions | Description |",
242
+ "|---|---|---|---|---|---|---|",
243
+ ...rows,
244
+ "",
245
+ ...hiddenNote,
246
+ "> Use `web_docs_fetch` with the chosen ID.",
247
+ ].join("\n");
248
+ }
249
+
250
+ function formatSearchRow(lib: SearchLibraryResult): string {
251
+ const cells = [
252
+ `\`${escapeMd(lib.id)}\``,
253
+ escapeMd(lib.name),
254
+ String(lib.trustScore ?? ""),
255
+ String(lib.benchmarkScore ?? ""),
256
+ String(lib.totalSnippets ?? ""),
257
+ escapeMd(formatVersions(lib.versions)),
258
+ escapeMd(truncateCell(lib.description ?? "", MAX_DESCRIPTION_CHARS)),
259
+ ];
260
+
261
+ return `| ${cells.join(" | ")} |`;
262
+ }
263
+
264
+ function formatVersions(versions?: string[]): string {
265
+ if (!versions?.length) return "";
266
+ const visibleVersions = versions.slice(0, MAX_VERSION_COUNT);
267
+ const hiddenCount = versions.length - visibleVersions.length;
268
+ return `${visibleVersions.join(", ")}${hiddenCount > 0 ? `, +${hiddenCount}` : ""}`;
269
+ }
270
+
271
+ function truncateCell(text: string, maxChars: number): string {
272
+ const compact = text.replace(/\s+/g, " ").trim();
273
+ if (compact.length <= maxChars) return compact;
274
+ return `${compact.slice(0, maxChars - 1).trimEnd()}…`;
275
+ }
276
+
277
+ function escapeMd(text: string): string {
278
+ return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
279
+ }
280
+
281
+ function truncatePreview(text: string, maxChars = 48): string {
282
+ const compact = text.replace(/\s+/g, " ").trim();
283
+ if (compact.length <= maxChars) return compact;
284
+ return `${compact.slice(0, maxChars - 1).trimEnd()}…`;
285
+ }
286
+
287
+ function buildSearchSummary(
288
+ details: SearchDetails | undefined,
289
+ theme: { fg: (color: "success" | "warning" | "dim", text: string) => string },
290
+ ): string {
291
+ if (!details) {
292
+ return theme.fg("success", "Context7 search finished");
168
293
  }
169
294
 
170
- if (!query) {
171
- return {
172
- content: [{ type: "text", text: "Error: 'query' parameter is required" }],
173
- isError: true,
174
- details: {},
175
- };
295
+ if (details.count === 0) {
296
+ return [
297
+ theme.fg("warning", "No libraries found"),
298
+ theme.fg("dim", ` for ${JSON.stringify(details.libraryName)}`),
299
+ ].join("");
176
300
  }
177
301
 
178
- try {
179
- const content = await getContext(query, libraryId, raw);
180
- const textContent = typeof content === "string" ? content : JSON.stringify(content, null, 2);
302
+ const noun = details.count === 1 ? "library" : "libraries";
303
+ let summary = [
304
+ theme.fg("success", `Found ${details.count} ${noun}`),
305
+ theme.fg("dim", ` for ${JSON.stringify(details.libraryName)}`),
306
+ ].join("");
181
307
 
182
- return {
183
- content: [{ type: "text", text: textContent }],
184
- details: { libraryId, raw },
185
- };
186
- } catch (err) {
187
- const message = err instanceof Context7Error ? err.message : `Unexpected error: ${String(err)}`;
188
- return {
189
- content: [{ type: "text", text: `Error: ${message}` }],
190
- isError: true,
191
- details: { libraryId, error: String(err) },
192
- };
308
+ if (details.truncation?.truncated) {
309
+ summary += theme.fg("warning", " [truncated]");
193
310
  }
311
+
312
+ return summary;
194
313
  }
195
314
 
196
- function escapeMd(text: string): string {
197
- return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
315
+ function buildFetchSummary(
316
+ details: FetchDetails | undefined,
317
+ theme: { fg: (color: "success" | "warning" | "dim", text: string) => string },
318
+ ): string {
319
+ if (!details) {
320
+ return theme.fg("success", "Fetched Context7 docs");
321
+ }
322
+
323
+ const format = details.raw ? "raw JSON" : "Markdown";
324
+ let summary = [
325
+ theme.fg("success", `Fetched ${format}`),
326
+ theme.fg(
327
+ "dim",
328
+ ` for ${details.libraryId} (${details.chars.toLocaleString()} chars, ${details.lines.toLocaleString()} lines)`,
329
+ ),
330
+ ].join("");
331
+
332
+ if (details.truncation?.truncated) {
333
+ summary += theme.fg("warning", " [truncated]");
334
+ }
335
+
336
+ return summary;
198
337
  }