@mrclrchtr/supi-web 1.14.3 → 1.16.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/fetch.ts CHANGED
@@ -25,6 +25,7 @@ export interface FetchResult {
25
25
  /** Fetch options. */
26
26
  export interface FetchOptions {
27
27
  timeoutMs?: number;
28
+ signal?: AbortSignal;
28
29
  }
29
30
 
30
31
  /** Validate that a string is a real http(s) URL. */
@@ -43,29 +44,35 @@ export async function fetchWithNegotiation(
43
44
  options: FetchOptions = {},
44
45
  ): Promise<FetchResult> {
45
46
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
47
+ const { signal } = options;
46
48
 
47
49
  // 1. Try HEAD negotiation for Markdown
48
- const headResult = await tryHeadNegotiation(url, timeoutMs);
50
+ const headResult = await tryHeadNegotiation(url, timeoutMs, signal);
49
51
  if (headResult) return headResult;
50
52
 
51
53
  // 2. Range GET to sniff content type
52
- const sniffResult = await trySniffNegotiation(url, timeoutMs);
54
+ const sniffResult = await trySniffNegotiation(url, timeoutMs, signal);
53
55
  if (sniffResult) return sniffResult;
54
56
 
55
57
  // 3. Try sibling .md URLs
56
- const siblingResult = await trySiblingNegotiation(url, timeoutMs);
58
+ const siblingResult = await trySiblingNegotiation(url, timeoutMs, signal);
57
59
  if (siblingResult) return siblingResult;
58
60
 
59
61
  // 4. Full GET as HTML → convert to Markdown
60
- return fetchAsHtml(url, timeoutMs);
62
+ return fetchAsHtml(url, timeoutMs, signal);
61
63
  }
62
64
 
63
- async function tryHeadNegotiation(url: string, timeoutMs: number): Promise<FetchResult | null> {
65
+ async function tryHeadNegotiation(
66
+ url: string,
67
+ timeoutMs: number,
68
+ signal: AbortSignal | undefined,
69
+ ): Promise<FetchResult | null> {
64
70
  try {
65
71
  const headRes = await timedFetch(
66
72
  url,
67
73
  { method: "HEAD", redirect: "follow", headers: { "User-Agent": USER_AGENT } },
68
74
  timeoutMs,
75
+ signal,
69
76
  );
70
77
  if (!headRes.ok) return null;
71
78
  const ct = headRes.headers.get("content-type") || "";
@@ -75,9 +82,12 @@ async function tryHeadNegotiation(url: string, timeoutMs: number): Promise<Fetch
75
82
  url,
76
83
  { method: "GET", redirect: "follow", headers: { "User-Agent": USER_AGENT } },
77
84
  timeoutMs,
85
+ signal,
78
86
  );
79
87
  if (!getRes.ok)
80
- throw new FetchError(`Fetch failed: ${getRes.status} ${getRes.statusText}`, getRes.status);
88
+ throw new FetchError(`Fetch failed: ${getRes.status} ${getRes.statusText}`, {
89
+ status: getRes.status,
90
+ });
81
91
  return {
82
92
  url: getRes.url || url,
83
93
  text: await getRes.text(),
@@ -85,12 +95,17 @@ async function tryHeadNegotiation(url: string, timeoutMs: number): Promise<Fetch
85
95
  isMarkdown: true,
86
96
  isPlainText: false,
87
97
  };
88
- } catch {
98
+ } catch (err) {
99
+ if (signal?.aborted) throw err;
89
100
  return null;
90
101
  }
91
102
  }
92
103
 
93
- async function trySniffNegotiation(url: string, timeoutMs: number): Promise<FetchResult | null> {
104
+ async function trySniffNegotiation(
105
+ url: string,
106
+ timeoutMs: number,
107
+ signal: AbortSignal | undefined,
108
+ ): Promise<FetchResult | null> {
94
109
  try {
95
110
  const sniffRes = await timedFetch(
96
111
  url,
@@ -100,109 +115,152 @@ async function trySniffNegotiation(url: string, timeoutMs: number): Promise<Fetc
100
115
  headers: { "User-Agent": USER_AGENT, Range: `bytes=0-${SNIFF_BYTES - 1}` },
101
116
  },
102
117
  timeoutMs,
118
+ signal,
103
119
  );
104
120
  const sniffText = await readPartialText(sniffRes, SNIFF_BYTES);
105
121
  const ct = sniffRes.headers.get("content-type") || "";
106
122
  const finalUrl = sniffRes.url || url;
107
123
 
108
124
  if (!sniffRes.ok || isHtml(sniffText)) return null;
109
-
110
- if (
111
- isMarkdownContentType(ct) ||
112
- looksLikeMarkdownUrl(finalUrl) ||
113
- looksLikeMarkdown(sniffText)
114
- ) {
115
- const fullRes = await timedFetch(
125
+ if (isMarkdownCandidate(ct, finalUrl, sniffText)) {
126
+ return fetchFullTextResult({
116
127
  url,
117
- { method: "GET", redirect: "follow", headers: { "User-Agent": USER_AGENT } },
118
128
  timeoutMs,
119
- );
120
- if (!fullRes.ok)
121
- throw new FetchError(
122
- `Fetch failed: ${fullRes.status} ${fullRes.statusText}`,
123
- fullRes.status,
124
- );
125
- return {
126
- url: fullRes.url || url,
127
- text: await fullRes.text(),
129
+ signal,
128
130
  contentType: ct,
129
- isMarkdown: true,
130
- isPlainText: false,
131
- };
131
+ kind: MARKDOWN_RESPONSE_KIND,
132
+ headers: { "User-Agent": USER_AGENT },
133
+ });
132
134
  }
133
-
134
- if (
135
- isPlainTextContentType(ct) &&
136
- !looksLikeMarkdownUrl(finalUrl) &&
137
- !looksLikeMarkdown(sniffText)
138
- ) {
139
- const fullRes = await timedFetch(
135
+ if (isPlainTextCandidate(ct, finalUrl, sniffText)) {
136
+ return fetchFullTextResult({
140
137
  url,
141
- { method: "GET", redirect: "follow", headers: { "User-Agent": USER_AGENT } },
142
138
  timeoutMs,
143
- );
144
- if (!fullRes.ok)
145
- throw new FetchError(
146
- `Fetch failed: ${fullRes.status} ${fullRes.statusText}`,
147
- fullRes.status,
148
- );
149
- return {
150
- url: fullRes.url || url,
151
- text: await fullRes.text(),
139
+ signal,
152
140
  contentType: ct,
153
- isMarkdown: false,
154
- isPlainText: true,
155
- };
141
+ kind: PLAIN_TEXT_RESPONSE_KIND,
142
+ headers: { "User-Agent": USER_AGENT },
143
+ });
156
144
  }
157
-
158
145
  return null;
159
- } catch {
146
+ } catch (err) {
147
+ if (signal?.aborted) throw err;
160
148
  return null;
161
149
  }
162
150
  }
163
151
 
164
- async function trySiblingNegotiation(url: string, timeoutMs: number): Promise<FetchResult | null> {
152
+ async function trySiblingNegotiation(
153
+ url: string,
154
+ timeoutMs: number,
155
+ signal: AbortSignal | undefined,
156
+ ): Promise<FetchResult | null> {
165
157
  for (const sibling of generateSiblingUrls(url)) {
166
158
  try {
167
- const sibRes = await timedFetch(
168
- sibling,
169
- {
170
- method: "GET",
171
- redirect: "follow",
172
- headers: { "User-Agent": USER_AGENT, Accept: ACCEPT_SIBLING },
173
- },
174
- timeoutMs,
175
- );
176
- const sibText = await readPartialText(sibRes, SNIFF_BYTES);
177
- const sibCt = sibRes.headers.get("content-type") || "";
178
- if (!sibRes.ok || isHtml(sibText) || isHtmlContentType(sibCt)) continue;
179
- if (!looksLikeMarkdown(sibText) && !isMarkdownContentType(sibCt)) continue;
180
-
181
- const fullRes = await timedFetch(
182
- sibling,
183
- {
184
- method: "GET",
185
- redirect: "follow",
186
- headers: { "User-Agent": USER_AGENT, Accept: ACCEPT_SIBLING },
187
- },
188
- timeoutMs,
189
- );
190
- if (!fullRes.ok) continue;
191
- return {
192
- url: fullRes.url || sibling,
193
- text: await fullRes.text(),
194
- contentType: sibCt,
195
- isMarkdown: true,
196
- isPlainText: false,
197
- };
198
- } catch {
159
+ const result = await fetchMarkdownSibling(sibling, timeoutMs, signal);
160
+ if (result) return result;
161
+ } catch (err) {
162
+ if (signal?.aborted) throw err;
199
163
  // Try next sibling
200
164
  }
201
165
  }
202
166
  return null;
203
167
  }
204
168
 
205
- async function fetchAsHtml(url: string, timeoutMs: number): Promise<FetchResult> {
169
+ async function fetchMarkdownSibling(
170
+ sibling: string,
171
+ timeoutMs: number,
172
+ signal: AbortSignal | undefined,
173
+ ): Promise<FetchResult | null> {
174
+ const headers = { "User-Agent": USER_AGENT, Accept: ACCEPT_SIBLING };
175
+ const sibRes = await timedFetch(
176
+ sibling,
177
+ { method: "GET", redirect: "follow", headers },
178
+ timeoutMs,
179
+ signal,
180
+ );
181
+ const sibText = await readPartialText(sibRes, SNIFF_BYTES);
182
+ const sibCt = sibRes.headers.get("content-type") || "";
183
+
184
+ if (!sibRes.ok || isHtml(sibText) || isHtmlContentType(sibCt)) return null;
185
+ if (!looksLikeMarkdown(sibText) && !isMarkdownContentType(sibCt)) return null;
186
+
187
+ const fullRes = await timedFetch(
188
+ sibling,
189
+ { method: "GET", redirect: "follow", headers },
190
+ timeoutMs,
191
+ signal,
192
+ );
193
+ if (!fullRes.ok) return null;
194
+ return buildFetchResult(fullRes, sibling, sibCt, MARKDOWN_RESPONSE_KIND);
195
+ }
196
+
197
+ interface ResponseKind {
198
+ isMarkdown: boolean;
199
+ isPlainText: boolean;
200
+ }
201
+
202
+ interface FetchFullTextOptions {
203
+ url: string;
204
+ timeoutMs: number;
205
+ signal: AbortSignal | undefined;
206
+ contentType: string;
207
+ kind: ResponseKind;
208
+ headers: Record<string, string>;
209
+ }
210
+
211
+ const MARKDOWN_RESPONSE_KIND = { isMarkdown: true, isPlainText: false } as const;
212
+ const PLAIN_TEXT_RESPONSE_KIND = { isMarkdown: false, isPlainText: true } as const;
213
+
214
+ async function fetchFullTextResult(options: FetchFullTextOptions): Promise<FetchResult> {
215
+ const fullRes = await timedFetch(
216
+ options.url,
217
+ { method: "GET", redirect: "follow", headers: options.headers },
218
+ options.timeoutMs,
219
+ options.signal,
220
+ );
221
+ if (!fullRes.ok)
222
+ throw new FetchError(`Fetch failed: ${fullRes.status} ${fullRes.statusText}`, {
223
+ status: fullRes.status,
224
+ });
225
+ return buildFetchResult(fullRes, options.url, options.contentType, options.kind);
226
+ }
227
+
228
+ async function buildFetchResult(
229
+ response: Response,
230
+ fallbackUrl: string,
231
+ contentType: string,
232
+ kind: ResponseKind,
233
+ ): Promise<FetchResult> {
234
+ return {
235
+ url: response.url || fallbackUrl,
236
+ text: await response.text(),
237
+ contentType,
238
+ isMarkdown: kind.isMarkdown,
239
+ isPlainText: kind.isPlainText,
240
+ };
241
+ }
242
+
243
+ function isMarkdownCandidate(contentType: string, finalUrl: string, sniffText: string): boolean {
244
+ return (
245
+ isMarkdownContentType(contentType) ||
246
+ looksLikeMarkdownUrl(finalUrl) ||
247
+ looksLikeMarkdown(sniffText)
248
+ );
249
+ }
250
+
251
+ function isPlainTextCandidate(contentType: string, finalUrl: string, sniffText: string): boolean {
252
+ return (
253
+ isPlainTextContentType(contentType) &&
254
+ !looksLikeMarkdownUrl(finalUrl) &&
255
+ !looksLikeMarkdown(sniffText)
256
+ );
257
+ }
258
+
259
+ async function fetchAsHtml(
260
+ url: string,
261
+ timeoutMs: number,
262
+ signal: AbortSignal | undefined,
263
+ ): Promise<FetchResult> {
206
264
  const res = await timedFetch(
207
265
  url,
208
266
  {
@@ -211,8 +269,10 @@ async function fetchAsHtml(url: string, timeoutMs: number): Promise<FetchResult>
211
269
  headers: { "User-Agent": USER_AGENT, Accept: "text/html,*/*;q=0.1" },
212
270
  },
213
271
  timeoutMs,
272
+ signal,
214
273
  );
215
- if (!res.ok) throw new FetchError(`Fetch failed: ${res.status} ${res.statusText}`, res.status);
274
+ if (!res.ok)
275
+ throw new FetchError(`Fetch failed: ${res.status} ${res.statusText}`, { status: res.status });
216
276
  return {
217
277
  url: res.url || url,
218
278
  text: await res.text(),
@@ -223,23 +283,45 @@ async function fetchAsHtml(url: string, timeoutMs: number): Promise<FetchResult>
223
283
  }
224
284
 
225
285
  /** Error thrown on fetch failures. */
286
+ export interface FetchErrorOptions extends ErrorOptions {
287
+ status?: number;
288
+ }
289
+
226
290
  export class FetchError extends Error {
227
- constructor(
228
- message: string,
229
- readonly status?: number,
230
- ) {
231
- super(message);
291
+ readonly status?: number;
292
+
293
+ constructor(message: string, options: FetchErrorOptions = {}) {
294
+ super(message, options);
232
295
  this.name = "FetchError";
296
+ this.status = options.status;
233
297
  }
234
298
  }
235
299
 
236
- async function timedFetch(url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
300
+ async function timedFetch(
301
+ url: string,
302
+ init: RequestInit,
303
+ timeoutMs: number,
304
+ signal?: AbortSignal,
305
+ ): Promise<Response> {
237
306
  const controller = new AbortController();
238
- const timer = setTimeout(() => controller.abort(), timeoutMs);
307
+ let timedOut = false;
308
+ const abortFromParent = () => controller.abort();
309
+ if (signal?.aborted) abortFromParent();
310
+ else signal?.addEventListener("abort", abortFromParent, { once: true });
311
+
312
+ const timer = setTimeout(() => {
313
+ timedOut = true;
314
+ controller.abort();
315
+ }, timeoutMs);
316
+
239
317
  try {
240
318
  return await fetch(url, { ...init, signal: controller.signal });
319
+ } catch (err) {
320
+ if (timedOut) throw new FetchError(`Fetch timed out after ${timeoutMs}ms`, { cause: err });
321
+ throw err;
241
322
  } finally {
242
323
  clearTimeout(timer);
324
+ signal?.removeEventListener("abort", abortFromParent);
243
325
  }
244
326
  }
245
327
 
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * SuPi Web extension — public API exports.
3
3
  */
4
4
 
5
+ export type { Context7RequestOptions } from "./context7-client.ts";
5
6
  export { htmlToMarkdown, wrapAsCodeBlock } from "./convert.ts";
6
7
  export { default as docsExtension } from "./docs.ts";
7
8
  export {
@@ -11,4 +12,13 @@ export {
11
12
  fetchWithNegotiation,
12
13
  isValidHttpUrl,
13
14
  } from "./fetch.ts";
15
+ export {
16
+ WEB_FETCH_INLINE_MAX_CHARS,
17
+ WEB_TOOL_NAMES,
18
+ WEB_TOOL_SPECS,
19
+ type WebDocsFetchInput,
20
+ type WebDocsSearchInput,
21
+ type WebFetchMdInput,
22
+ type WebToolName,
23
+ } from "./tool/tool-specs.ts";
14
24
  export { default } from "./web.ts";
package/src/temp-file.ts CHANGED
@@ -1,21 +1,22 @@
1
1
  /**
2
- * Temporary file helpers for the web_fetch_md tool.
2
+ * Temporary file helpers for the web tools.
3
3
  */
4
4
 
5
5
  import { randomBytes } from "node:crypto";
6
- import { mkdtempSync, writeFileSync } from "node:fs";
6
+ import { mkdtemp, writeFile } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
9
10
 
10
- /** Write content to a temporary file and return the absolute path. */
11
+ /** Write content to a unique temporary file and return the absolute path. */
11
12
  export async function writeTempFile(
12
13
  content: string,
13
14
  prefix: string,
14
15
  suffix: string,
15
16
  ): Promise<string> {
16
- const dir = mkdtempSync(join(tmpdir(), `${prefix}-`));
17
+ const dir = await mkdtemp(join(tmpdir(), `${prefix}-`));
17
18
  const hash = randomBytes(4).toString("hex");
18
19
  const filePath = join(dir, `${hash}${suffix}`);
19
- writeFileSync(filePath, content, "utf8");
20
+ await withFileMutationQueue(filePath, () => writeFile(filePath, content, "utf8"));
20
21
  return filePath;
21
22
  }
@@ -0,0 +1,34 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { getWebToolSpec, WEB_FETCH_MD_TOOL_NAME, type WebToolName } from "./tool-specs.ts";
3
+
4
+ /** Prompt metadata sent to pi for a single web tool. */
5
+ export interface WebToolPromptSurface {
6
+ description: string;
7
+ promptSnippet: string;
8
+ promptGuidelines: string[];
9
+ }
10
+
11
+ /** Build prompt metadata from the shared tool specs, adding runtime-specific guidance when useful. */
12
+ export function getWebToolPromptSurface(name: WebToolName): WebToolPromptSurface {
13
+ const spec = getWebToolSpec(name);
14
+ const promptGuidelines = [...spec.promptGuidelines];
15
+
16
+ if (name === WEB_FETCH_MD_TOOL_NAME && isGhAvailable()) {
17
+ promptGuidelines.push("Use `gh` CLI instead of web_fetch_md for GitHub URLs.");
18
+ }
19
+
20
+ return {
21
+ description: spec.description,
22
+ promptSnippet: spec.promptSnippet,
23
+ promptGuidelines,
24
+ };
25
+ }
26
+
27
+ function isGhAvailable(): boolean {
28
+ try {
29
+ const result = spawnSync("gh", ["--version"], { stdio: "ignore" });
30
+ return result.status === 0;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
@@ -0,0 +1,50 @@
1
+ import {
2
+ DEFAULT_MAX_BYTES,
3
+ DEFAULT_MAX_LINES,
4
+ formatSize,
5
+ type TruncationResult,
6
+ truncateHead,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { writeTempFile } from "../temp-file.ts";
9
+
10
+ /** Human-readable truncation contract shared by all model-visible web outputs. */
11
+ export const MODEL_OUTPUT_LIMIT_DESCRIPTION = `Inline truncates at ${DEFAULT_MAX_LINES.toLocaleString()} lines/${formatSize(DEFAULT_MAX_BYTES)}; full saved to temp.`;
12
+
13
+ /** Result of preparing content for a tool response visible to the model. */
14
+ export interface ModelVisibleOutput {
15
+ /** Text safe to return in a tool result. */
16
+ text: string;
17
+ /** Truncation metadata when truncation happened. */
18
+ truncation?: TruncationResult;
19
+ /** Temp file containing the full, untruncated output when truncation happened. */
20
+ fullOutputPath?: string;
21
+ }
22
+
23
+ /** Prepare content for model-visible output, preserving full output in a temp file if truncated. */
24
+ export async function limitModelVisibleOutput(
25
+ content: string,
26
+ options: { tempPrefix: string; suffix: string },
27
+ ): Promise<ModelVisibleOutput> {
28
+ const truncation = truncateHead(content, {
29
+ maxLines: DEFAULT_MAX_LINES,
30
+ maxBytes: DEFAULT_MAX_BYTES,
31
+ });
32
+
33
+ if (!truncation.truncated) {
34
+ return { text: content };
35
+ }
36
+
37
+ const fullOutputPath = await writeTempFile(content, options.tempPrefix, options.suffix);
38
+ const prefix = truncation.content.length > 0 ? `${truncation.content}\n\n` : "";
39
+ const notice = [
40
+ `[Output truncated: ${truncation.outputLines.toLocaleString()}/${truncation.totalLines.toLocaleString()} lines,`,
41
+ `${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}.`,
42
+ `Full: ${fullOutputPath}]`,
43
+ ].join(" ");
44
+
45
+ return {
46
+ text: `${prefix}${notice}`,
47
+ truncation,
48
+ fullOutputPath,
49
+ };
50
+ }
@@ -0,0 +1,63 @@
1
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+
4
+ interface RenderTheme {
5
+ fg: (color: ThemeColor, text: string) => string;
6
+ }
7
+
8
+ interface CollapsibleTextResultOptions {
9
+ summary: string;
10
+ body?: string;
11
+ expanded: boolean;
12
+ theme: RenderTheme;
13
+ fullOutputPath?: string;
14
+ bodyColor?: ThemeColor;
15
+ }
16
+
17
+ export function renderToolCall(
18
+ name: string,
19
+ primary: string,
20
+ theme: RenderTheme,
21
+ secondary?: string,
22
+ ): Text {
23
+ let content = theme.fg("toolTitle", name);
24
+ if (primary) {
25
+ content += ` ${theme.fg("accent", primary)}`;
26
+ }
27
+ if (secondary) {
28
+ content += theme.fg("dim", ` — ${secondary}`);
29
+ }
30
+ return new Text(content, 0, 0);
31
+ }
32
+
33
+ /** Render a compact tool summary that expands into the full text body on demand. */
34
+ export function renderCollapsibleTextResult({
35
+ summary,
36
+ body,
37
+ expanded,
38
+ theme,
39
+ fullOutputPath,
40
+ bodyColor = "toolOutput",
41
+ }: CollapsibleTextResultOptions): Text {
42
+ const lines = [summary];
43
+ const bodyText = body?.trimEnd();
44
+
45
+ if (expanded && bodyText) {
46
+ lines.push(formatMultiline(bodyText, theme, bodyColor));
47
+ } else if (bodyText) {
48
+ lines[0] += theme.fg("dim", " (expand for output)");
49
+ }
50
+
51
+ if (expanded && fullOutputPath) {
52
+ lines.push(theme.fg("dim", `Full output: ${fullOutputPath}`));
53
+ }
54
+
55
+ return new Text(lines.filter(Boolean).join("\n"), 0, 0);
56
+ }
57
+
58
+ function formatMultiline(text: string, theme: RenderTheme, color: ThemeColor): string {
59
+ return text
60
+ .split("\n")
61
+ .map((line) => (line.length > 0 ? theme.fg(color, line) : ""))
62
+ .join("\n");
63
+ }
@@ -0,0 +1,107 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { type Static, type TSchema, Type } from "typebox";
3
+ import { MODEL_OUTPUT_LIMIT_DESCRIPTION } from "./output.ts";
4
+
5
+ export const WEB_FETCH_MD_TOOL_NAME = "web_fetch_md";
6
+ export const WEB_DOCS_SEARCH_TOOL_NAME = "web_docs_search";
7
+ export const WEB_DOCS_FETCH_TOOL_NAME = "web_docs_fetch";
8
+
9
+ export const WEB_TOOL_NAMES = [
10
+ WEB_FETCH_MD_TOOL_NAME,
11
+ WEB_DOCS_SEARCH_TOOL_NAME,
12
+ WEB_DOCS_FETCH_TOOL_NAME,
13
+ ] as const;
14
+ export type WebToolName = (typeof WEB_TOOL_NAMES)[number];
15
+
16
+ export const WEB_FETCH_INLINE_MAX_CHARS = 15_000;
17
+ export const WEB_FETCH_OUTPUT_MODES = ["auto", "inline", "file"] as const;
18
+ export type WebFetchOutputMode = (typeof WEB_FETCH_OUTPUT_MODES)[number];
19
+
20
+ const OutputModeEnum = StringEnum(WEB_FETCH_OUTPUT_MODES, {
21
+ default: "auto",
22
+ description: "auto, inline, or file output",
23
+ });
24
+
25
+ const WebFetchMdParameters = Type.Object(
26
+ {
27
+ url: Type.String({ description: "Public http(s) URL" }),
28
+ output_mode: Type.Optional(OutputModeEnum),
29
+ abs_links: Type.Optional(Type.Boolean({ description: "Absolute links/images", default: true })),
30
+ timeout_ms: Type.Optional(Type.Number({ description: "Fetch timeout (ms)", default: 30_000 })),
31
+ },
32
+ { additionalProperties: false },
33
+ );
34
+
35
+ const WebDocsSearchParameters = Type.Object(
36
+ {
37
+ library_name: Type.String({
38
+ description: "Library name (e.g. react, next.js, fastapi)",
39
+ }),
40
+ query: Type.String({
41
+ description: "Task/question for relevance ranking",
42
+ }),
43
+ },
44
+ { additionalProperties: false },
45
+ );
46
+
47
+ const WebDocsFetchParameters = Type.Object(
48
+ {
49
+ library_id: Type.String({
50
+ description: "Context7 ID (e.g. /facebook/react); search first if unknown",
51
+ }),
52
+ query: Type.String({ description: "Specific docs question" }),
53
+ raw: Type.Optional(
54
+ Type.Boolean({
55
+ description: "Return JSON snippets instead of Markdown",
56
+ default: false,
57
+ }),
58
+ ),
59
+ },
60
+ { additionalProperties: false },
61
+ );
62
+
63
+ export type WebFetchMdInput = Static<typeof WebFetchMdParameters>;
64
+ export type WebDocsSearchInput = Static<typeof WebDocsSearchParameters>;
65
+ export type WebDocsFetchInput = Static<typeof WebDocsFetchParameters>;
66
+
67
+ export interface WebToolSpec {
68
+ name: WebToolName;
69
+ label: string;
70
+ description: string;
71
+ promptSnippet: string;
72
+ promptGuidelines: readonly string[];
73
+ parameters: TSchema;
74
+ }
75
+
76
+ export const WEB_TOOL_SPECS = [
77
+ {
78
+ name: WEB_FETCH_MD_TOOL_NAME,
79
+ label: "Web Fetch",
80
+ description: `Fetch public http(s) URL as Markdown. Not for login/private pages; ask for allowed source. auto: inline <=${WEB_FETCH_INLINE_MAX_CHARS.toLocaleString()} chars else temp; inline: may truncate; file: temp path. Links absolute by default. ${MODEL_OUTPUT_LIMIT_DESCRIPTION}`,
81
+ promptSnippet: "web_fetch_md: public URL to Markdown",
82
+ promptGuidelines: ["Use web_fetch_md only for public http(s); ask if login/private."],
83
+ parameters: WebFetchMdParameters,
84
+ },
85
+ {
86
+ name: WEB_DOCS_SEARCH_TOOL_NAME,
87
+ label: "Web Docs Search",
88
+ description: `Search Context7 for library IDs; returns compact Markdown. ${MODEL_OUTPUT_LIMIT_DESCRIPTION}`,
89
+ promptSnippet: "web_docs_search: Context7 library IDs",
90
+ promptGuidelines: ["Use web_docs_search before web_docs_fetch if ID unknown."],
91
+ parameters: WebDocsSearchParameters,
92
+ },
93
+ {
94
+ name: WEB_DOCS_FETCH_TOOL_NAME,
95
+ label: "Web Docs Fetch",
96
+ description: `Fetch focused Context7 docs for known library_id; Markdown default, raw=true JSON snippets. Search first if unknown. ${MODEL_OUTPUT_LIMIT_DESCRIPTION}`,
97
+ promptSnippet: "web_docs_fetch: focused Context7 docs",
98
+ promptGuidelines: ["Use web_docs_fetch with known ID and narrow query; raw only for JSON."],
99
+ parameters: WebDocsFetchParameters,
100
+ },
101
+ ] as const satisfies readonly WebToolSpec[];
102
+
103
+ export function getWebToolSpec(name: WebToolName): WebToolSpec {
104
+ const spec = WEB_TOOL_SPECS.find((candidate) => candidate.name === name);
105
+ if (!spec) throw new Error(`Unknown web tool: ${name}`);
106
+ return spec;
107
+ }