@mrclrchtr/supi-web 0.1.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 ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * SuPi Web Context7 extension — registers web_docs_search and web_docs_fetch tools.
3
+ *
4
+ * Uses @upstash/context7-sdk for up-to-date library documentation lookups.
5
+ * API key is read automatically from the CONTEXT7_API_KEY environment variable.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { Type } from "typebox";
10
+ import { Context7Error, getContext, searchLibrary } from "./context7-client.ts";
11
+
12
+ const SEARCH_TOOL_NAME = "web_docs_search";
13
+ const SEARCH_TOOL_LABEL = "Web Docs Search";
14
+ const FETCH_TOOL_NAME = "web_docs_fetch";
15
+ const FETCH_TOOL_LABEL = "Web Docs Fetch";
16
+
17
+ const SEARCH_TOOL_DESCRIPTION = `Search for libraries via Context7. Returns a Markdown table of matching libraries with metadata (ID, name, description, trust score, benchmark score, snippet count, versions). Use the library ID from results with web_docs_fetch to retrieve documentation.`;
18
+
19
+ const FETCH_TOOL_DESCRIPTION = `Retrieve documentation context for a specific library via Context7. Returns up-to-date code snippets and documentation prose as Markdown, tailored to the query. Use web_docs_search first to find the library ID. Set raw=true to get JSON-serialized snippet objects instead of plain text. Requires a Context7 library ID (e.g. /facebook/react, /vercel/next.js).`;
20
+
21
+ const SEARCH_PROMPT_SNIPPET =
22
+ "web_docs_search — search Context7 for libraries matching a name, returns library IDs for use with web_docs_fetch";
23
+
24
+ const FETCH_PROMPT_SNIPPET =
25
+ "web_docs_fetch — retrieve up-to-date documentation context from Context7 for a specific library";
26
+
27
+ const SEARCH_PROMPT_GUIDELINES = [
28
+ "Use web_docs_search to find Context7 library IDs by name before calling web_docs_fetch.",
29
+ "Pass a descriptive query along with the library name for better relevance ranking.",
30
+ "Review the search results carefully — pick the library ID that best matches the user's need.",
31
+ ];
32
+
33
+ const FETCH_PROMPT_GUIDELINES = [
34
+ "Use web_docs_fetch to get up-to-date, version-specific documentation for any library.",
35
+ "The library_id must be a Context7 library ID like /facebook/react or /vercel/next.js.",
36
+ "Set raw=true only when you need structured JSON snippets instead of plain text Markdown.",
37
+ "If the library ID is unknown, call web_docs_search first to find it.",
38
+ "Prefer descriptive, specific queries over vague ones for better results.",
39
+ ];
40
+
41
+ export default function docsExtension(pi: ExtensionAPI): void {
42
+ pi.registerTool({
43
+ name: SEARCH_TOOL_NAME,
44
+ label: SEARCH_TOOL_LABEL,
45
+ description: SEARCH_TOOL_DESCRIPTION,
46
+ promptSnippet: SEARCH_PROMPT_SNIPPET,
47
+ promptGuidelines: SEARCH_PROMPT_GUIDELINES,
48
+ parameters: Type.Object({
49
+ library_name: Type.String({
50
+ description: "Library name to search for (e.g. react, next.js, fastapi)",
51
+ }),
52
+ query: Type.String({
53
+ description: "What you're trying to do — used for relevance ranking of results",
54
+ }),
55
+ }),
56
+ execute: runSearch,
57
+ });
58
+
59
+ pi.registerTool({
60
+ name: FETCH_TOOL_NAME,
61
+ label: FETCH_TOOL_LABEL,
62
+ description: FETCH_TOOL_DESCRIPTION,
63
+ promptSnippet: FETCH_PROMPT_SNIPPET,
64
+ promptGuidelines: FETCH_PROMPT_GUIDELINES,
65
+ parameters: Type.Object({
66
+ library_id: Type.String({
67
+ description:
68
+ "Context7 library ID (e.g. /facebook/react, /vercel/next.js). Find it via web_docs_search.",
69
+ }),
70
+ query: Type.String({
71
+ description: "Specific question about the library",
72
+ }),
73
+ raw: Type.Optional(
74
+ Type.Boolean({
75
+ description:
76
+ "When true, returns JSON-serialized snippet objects instead of plain text Markdown",
77
+ default: false,
78
+ }),
79
+ ),
80
+ }),
81
+ execute: runFetch,
82
+ });
83
+ }
84
+
85
+ // biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
86
+ async function runSearch(
87
+ _toolCallId: string,
88
+ params: Record<string, unknown>,
89
+ _signal: AbortSignal | undefined,
90
+ _onUpdate: unknown,
91
+ _ctx: unknown,
92
+ ): Promise<{
93
+ content: { type: "text"; text: string }[];
94
+ details: Record<string, unknown>;
95
+ isError?: boolean;
96
+ }> {
97
+ const libraryName = (params.library_name as string | undefined)?.trim();
98
+ const query = (params.query as string | undefined)?.trim();
99
+
100
+ if (!libraryName) {
101
+ return {
102
+ content: [{ type: "text", text: "Error: 'library_name' parameter is required" }],
103
+ isError: true,
104
+ details: {},
105
+ };
106
+ }
107
+
108
+ if (!query) {
109
+ return {
110
+ content: [{ type: "text", text: "Error: 'query' parameter is required" }],
111
+ isError: true,
112
+ details: {},
113
+ };
114
+ }
115
+
116
+ try {
117
+ const results = await searchLibrary(query, libraryName);
118
+
119
+ if (results.length === 0) {
120
+ return {
121
+ content: [
122
+ {
123
+ type: "text",
124
+ text: `No libraries found for "${libraryName}". Try a different search term.`,
125
+ },
126
+ ],
127
+ details: { count: 0, libraryName },
128
+ };
129
+ }
130
+
131
+ const rows = results.map(
132
+ (lib) =>
133
+ `| **${escapeMd(lib.name)}** | \`${escapeMd(lib.id)}\` | ${escapeMd(lib.description ?? "")} | ${lib.trustScore ?? ""} | ${lib.benchmarkScore ?? ""} | ${lib.totalSnippets ?? ""} | ${lib.versions ? lib.versions.join(", ") : ""} |`,
134
+ );
135
+
136
+ const markdown = [
137
+ `Found **${results.length}** library/libraries matching "${libraryName}":`,
138
+ "",
139
+ "| Name | ID | Description | Trust | Benchmark | Snippets | Versions |",
140
+ "|---|---|---|---|---|---|---|",
141
+ ...rows,
142
+ "",
143
+ `> Use \`web_docs_fetch\` with the library ID to retrieve documentation.`,
144
+ ].join("\n");
145
+
146
+ return {
147
+ content: [{ type: "text", text: markdown }],
148
+ details: { count: results.length, libraryName },
149
+ };
150
+ } catch (err) {
151
+ const message = err instanceof Context7Error ? err.message : `Unexpected error: ${String(err)}`;
152
+ return {
153
+ content: [{ type: "text", text: `Error: ${message}` }],
154
+ isError: true,
155
+ details: { libraryName, error: String(err) },
156
+ };
157
+ }
158
+ }
159
+
160
+ // biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
161
+ async function runFetch(
162
+ _toolCallId: string,
163
+ params: Record<string, unknown>,
164
+ _signal: AbortSignal | undefined,
165
+ _onUpdate: unknown,
166
+ _ctx: unknown,
167
+ ): Promise<{
168
+ content: { type: "text"; text: string }[];
169
+ details: Record<string, unknown>;
170
+ isError?: boolean;
171
+ }> {
172
+ const libraryId = (params.library_id as string | undefined)?.trim();
173
+ const query = (params.query as string | undefined)?.trim();
174
+ const raw = Boolean(params.raw);
175
+
176
+ if (!libraryId) {
177
+ return {
178
+ content: [{ type: "text", text: "Error: 'library_id' parameter is required" }],
179
+ isError: true,
180
+ details: {},
181
+ };
182
+ }
183
+
184
+ if (!query) {
185
+ return {
186
+ content: [{ type: "text", text: "Error: 'query' parameter is required" }],
187
+ isError: true,
188
+ details: {},
189
+ };
190
+ }
191
+
192
+ try {
193
+ const content = await getContext(query, libraryId, raw);
194
+ const textContent = typeof content === "string" ? content : JSON.stringify(content, null, 2);
195
+
196
+ return {
197
+ content: [{ type: "text", text: textContent }],
198
+ details: { libraryId, raw },
199
+ };
200
+ } catch (err) {
201
+ const message = err instanceof Context7Error ? err.message : `Unexpected error: ${String(err)}`;
202
+ return {
203
+ content: [{ type: "text", text: `Error: ${message}` }],
204
+ isError: true,
205
+ details: { libraryId, error: String(err) },
206
+ };
207
+ }
208
+ }
209
+
210
+ function escapeMd(text: string): string {
211
+ return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
212
+ }
package/src/fetch.ts ADDED
@@ -0,0 +1,416 @@
1
+ /**
2
+ * HTTP fetching with content negotiation and Markdown sniffing.
3
+ */
4
+
5
+ // biome-ignore lint/nursery/noExcessiveLinesPerFile: expanded guessLanguage map pushes past the threshold; nursery rule, not stable
6
+ const USER_AGENT = "Mozilla/5.0 (compatible; supi-web/1.0; +https://github.com/mrclrchtr/supi)";
7
+ const ACCEPT_SIBLING = "text/markdown,text/plain;q=0.9,*/*;q=0.1";
8
+ const DEFAULT_TIMEOUT_MS = 30_000;
9
+ const SNIFF_BYTES = 8192;
10
+
11
+ /** Validated URL result. */
12
+ export interface FetchResult {
13
+ /** Final URL after redirects. */
14
+ url: string;
15
+ /** Response body text. */
16
+ text: string;
17
+ /** Detected content type (lowercased). */
18
+ contentType: string;
19
+ /** Whether the body is raw Markdown (no HTML conversion needed). */
20
+ isMarkdown: boolean;
21
+ /** Whether the body is plain text that should be fenced as code. */
22
+ isPlainText: boolean;
23
+ }
24
+
25
+ /** Fetch options. */
26
+ export interface FetchOptions {
27
+ timeoutMs?: number;
28
+ }
29
+
30
+ /** Validate that a string is a real http(s) URL. */
31
+ export function isValidHttpUrl(url: string): boolean {
32
+ try {
33
+ const parsed = new URL(url);
34
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /** Fetch a URL with full content negotiation and sniffing. */
41
+ export async function fetchWithNegotiation(
42
+ url: string,
43
+ options: FetchOptions = {},
44
+ ): Promise<FetchResult> {
45
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
46
+
47
+ // 1. Try HEAD negotiation for Markdown
48
+ const headResult = await tryHeadNegotiation(url, timeoutMs);
49
+ if (headResult) return headResult;
50
+
51
+ // 2. Range GET to sniff content type
52
+ const sniffResult = await trySniffNegotiation(url, timeoutMs);
53
+ if (sniffResult) return sniffResult;
54
+
55
+ // 3. Try sibling .md URLs
56
+ const siblingResult = await trySiblingNegotiation(url, timeoutMs);
57
+ if (siblingResult) return siblingResult;
58
+
59
+ // 4. Full GET as HTML → convert to Markdown
60
+ return fetchAsHtml(url, timeoutMs);
61
+ }
62
+
63
+ async function tryHeadNegotiation(url: string, timeoutMs: number): Promise<FetchResult | null> {
64
+ try {
65
+ const headRes = await timedFetch(
66
+ url,
67
+ { method: "HEAD", redirect: "follow", headers: { "User-Agent": USER_AGENT } },
68
+ timeoutMs,
69
+ );
70
+ if (!headRes.ok) return null;
71
+ const ct = headRes.headers.get("content-type") || "";
72
+ if (!isMarkdownContentType(ct)) return null;
73
+
74
+ const getRes = await timedFetch(
75
+ url,
76
+ { method: "GET", redirect: "follow", headers: { "User-Agent": USER_AGENT } },
77
+ timeoutMs,
78
+ );
79
+ if (!getRes.ok)
80
+ throw new FetchError(`Fetch failed: ${getRes.status} ${getRes.statusText}`, getRes.status);
81
+ return {
82
+ url: getRes.url || url,
83
+ text: await getRes.text(),
84
+ contentType: ct,
85
+ isMarkdown: true,
86
+ isPlainText: false,
87
+ };
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ async function trySniffNegotiation(url: string, timeoutMs: number): Promise<FetchResult | null> {
94
+ try {
95
+ const sniffRes = await timedFetch(
96
+ url,
97
+ {
98
+ method: "GET",
99
+ redirect: "follow",
100
+ headers: { "User-Agent": USER_AGENT, Range: `bytes=0-${SNIFF_BYTES - 1}` },
101
+ },
102
+ timeoutMs,
103
+ );
104
+ const sniffText = await readPartialText(sniffRes, SNIFF_BYTES);
105
+ const ct = sniffRes.headers.get("content-type") || "";
106
+ const finalUrl = sniffRes.url || url;
107
+
108
+ 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(
116
+ url,
117
+ { method: "GET", redirect: "follow", headers: { "User-Agent": USER_AGENT } },
118
+ 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(),
128
+ contentType: ct,
129
+ isMarkdown: true,
130
+ isPlainText: false,
131
+ };
132
+ }
133
+
134
+ if (
135
+ isPlainTextContentType(ct) &&
136
+ !looksLikeMarkdownUrl(finalUrl) &&
137
+ !looksLikeMarkdown(sniffText)
138
+ ) {
139
+ const fullRes = await timedFetch(
140
+ url,
141
+ { method: "GET", redirect: "follow", headers: { "User-Agent": USER_AGENT } },
142
+ 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(),
152
+ contentType: ct,
153
+ isMarkdown: false,
154
+ isPlainText: true,
155
+ };
156
+ }
157
+
158
+ return null;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ async function trySiblingNegotiation(url: string, timeoutMs: number): Promise<FetchResult | null> {
165
+ for (const sibling of generateSiblingUrls(url)) {
166
+ 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 {
199
+ // Try next sibling
200
+ }
201
+ }
202
+ return null;
203
+ }
204
+
205
+ async function fetchAsHtml(url: string, timeoutMs: number): Promise<FetchResult> {
206
+ const res = await timedFetch(
207
+ url,
208
+ {
209
+ method: "GET",
210
+ redirect: "follow",
211
+ headers: { "User-Agent": USER_AGENT, Accept: "text/html,*/*;q=0.1" },
212
+ },
213
+ timeoutMs,
214
+ );
215
+ if (!res.ok) throw new FetchError(`Fetch failed: ${res.status} ${res.statusText}`, res.status);
216
+ return {
217
+ url: res.url || url,
218
+ text: await res.text(),
219
+ contentType: res.headers.get("content-type") || "",
220
+ isMarkdown: false,
221
+ isPlainText: false,
222
+ };
223
+ }
224
+
225
+ /** Error thrown on fetch failures. */
226
+ export class FetchError extends Error {
227
+ constructor(
228
+ message: string,
229
+ readonly status?: number,
230
+ ) {
231
+ super(message);
232
+ this.name = "FetchError";
233
+ }
234
+ }
235
+
236
+ async function timedFetch(url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
237
+ const controller = new AbortController();
238
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
239
+ try {
240
+ return await fetch(url, { ...init, signal: controller.signal });
241
+ } finally {
242
+ clearTimeout(timer);
243
+ }
244
+ }
245
+
246
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: stream reading with early-exit logic
247
+ async function readPartialText(res: Response, maxBytes: number): Promise<string> {
248
+ const body = res.body;
249
+ if (body && typeof (body as unknown as { getReader: () => unknown }).getReader === "function") {
250
+ const reader = (body as unknown as ReadableStream<Uint8Array>).getReader();
251
+ const decoder = new TextDecoder("utf-8");
252
+ let text = "";
253
+ let bytes = 0;
254
+ try {
255
+ while (bytes < maxBytes) {
256
+ const { value, done } = await reader.read();
257
+ if (done) break;
258
+ if (value) {
259
+ bytes += value.byteLength;
260
+ text += decoder.decode(value, { stream: true });
261
+ }
262
+ if (bytes >= maxBytes) break;
263
+ }
264
+ } finally {
265
+ try {
266
+ await reader.cancel();
267
+ } catch {
268
+ /* ignore */
269
+ }
270
+ }
271
+ return (text + decoder.decode()).slice(0, Math.max(0, maxBytes));
272
+ }
273
+ return (await res.text()).slice(0, Math.max(0, maxBytes));
274
+ }
275
+
276
+ function isMarkdownContentType(ct: string): boolean {
277
+ const lower = ct.toLowerCase();
278
+ return (
279
+ lower.includes("text/markdown") ||
280
+ lower.includes("text/x-markdown") ||
281
+ lower.includes("application/markdown") ||
282
+ lower.includes("application/x-markdown")
283
+ );
284
+ }
285
+
286
+ function isHtmlContentType(ct: string): boolean {
287
+ const lower = ct.toLowerCase();
288
+ return lower.includes("text/html") || lower.includes("application/xhtml+xml");
289
+ }
290
+
291
+ export function isPlainTextContentType(ct: string): boolean {
292
+ const lower = ct.toLowerCase();
293
+ if (isHtmlContentType(ct)) return false;
294
+ return lower.startsWith("text/") || lower.includes("application/xml");
295
+ }
296
+
297
+ export function isHtml(text: string): boolean {
298
+ const trimmed = (text || "").trimStart().slice(0, 2000).toLowerCase();
299
+ return !!(
300
+ trimmed.startsWith("<!doctype html") ||
301
+ trimmed.startsWith("<html") ||
302
+ trimmed.startsWith("<?xml") ||
303
+ /<(head|body)\b/.test(trimmed) ||
304
+ (trimmed.startsWith("<") && /<\/(html|head|body)>/.test(trimmed))
305
+ );
306
+ }
307
+
308
+ export function looksLikeMarkdown(text: string): boolean {
309
+ const sample = (text || "").slice(0, 4000);
310
+ return !!(
311
+ /^\s*#\s+\S+/m.test(sample) ||
312
+ /^\s*---\s*$/m.test(sample) ||
313
+ /```/.test(sample) ||
314
+ /^\s*[-*+]\s+\S+/m.test(sample) ||
315
+ /^\s*\d+\.\s+\S+/m.test(sample) ||
316
+ /\[[^\]]+\]\([^)]+\)/.test(sample)
317
+ );
318
+ }
319
+
320
+ function looksLikeMarkdownUrl(url: string): boolean {
321
+ try {
322
+ const path = new URL(url).pathname.toLowerCase();
323
+ return path.endsWith(".md") || path.endsWith(".markdown");
324
+ } catch {
325
+ return false;
326
+ }
327
+ }
328
+
329
+ function generateSiblingUrls(url: string): string[] {
330
+ const parsed = new URL(url);
331
+ parsed.hash = "";
332
+ parsed.search = "";
333
+ const path = parsed.pathname;
334
+ const siblings: string[] = [];
335
+
336
+ if (path.endsWith("/")) {
337
+ siblings.push(new URL("index.md", parsed).toString());
338
+ siblings.push(new URL("README.md", parsed).toString());
339
+ } else if (!path.toLowerCase().endsWith(".md")) {
340
+ const withMd = new URL(parsed.toString());
341
+ withMd.pathname = `${path}.md`;
342
+ siblings.push(withMd.toString());
343
+ }
344
+
345
+ const withMarkdown = new URL(parsed.toString());
346
+ if (!path.toLowerCase().endsWith(".markdown")) {
347
+ withMarkdown.pathname = path.endsWith("/") ? `${path}index.markdown` : `${path}.markdown`;
348
+ siblings.push(withMarkdown.toString());
349
+ }
350
+
351
+ return siblings;
352
+ }
353
+
354
+ /** Guess a language identifier from a URL pathname extension. */
355
+ export function guessLanguage(url: string): string {
356
+ try {
357
+ const ext = new URL(url).pathname.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || "";
358
+ const map: Record<string, string> = {
359
+ bash: "bash",
360
+ c: "c",
361
+ cc: "cpp",
362
+ conf: "conf",
363
+ cpp: "cpp",
364
+ css: "css",
365
+ cxx: "cpp",
366
+ dart: "dart",
367
+ dockerfile: "dockerfile",
368
+ elixir: "elixir",
369
+ ex: "elixir",
370
+ exs: "elixir",
371
+ go: "go",
372
+ graphql: "graphql",
373
+ gql: "graphql",
374
+ h: "c",
375
+ hpp: "cpp",
376
+ html: "html",
377
+ htm: "html",
378
+ ini: "ini",
379
+ java: "java",
380
+ js: "javascript",
381
+ json: "json",
382
+ jsx: "jsx",
383
+ kt: "kotlin",
384
+ kts: "kotlin",
385
+ less: "less",
386
+ lua: "lua",
387
+ mjs: "javascript",
388
+ cjs: "javascript",
389
+ md: "markdown",
390
+ php: "php",
391
+ pl: "perl",
392
+ ps: "powershell",
393
+ ps1: "powershell",
394
+ py: "python",
395
+ r: "r",
396
+ rb: "ruby",
397
+ rs: "rust",
398
+ scss: "scss",
399
+ sh: "sh",
400
+ sql: "sql",
401
+ svelte: "svelte",
402
+ swift: "swift",
403
+ toml: "toml",
404
+ ts: "ts",
405
+ tsx: "tsx",
406
+ vue: "vue",
407
+ yaml: "yaml",
408
+ yml: "yaml",
409
+ xml: "xml",
410
+ zsh: "zsh",
411
+ };
412
+ return map[ext] || "";
413
+ } catch {
414
+ return "";
415
+ }
416
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * SuPi Web extension — public API exports.
3
+ */
4
+
5
+ export { htmlToMarkdown, wrapAsCodeBlock } from "./convert.ts";
6
+ export { default as docsExtension } from "./docs.ts";
7
+ export {
8
+ FetchError,
9
+ type FetchOptions,
10
+ type FetchResult,
11
+ fetchWithNegotiation,
12
+ isValidHttpUrl,
13
+ } from "./fetch.ts";
14
+ export { default } from "./web.ts";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Temporary file helpers for the web_fetch_md tool.
3
+ */
4
+
5
+ import { randomBytes } from "node:crypto";
6
+ import { mkdtempSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ /** Write content to a temporary file and return the absolute path. */
11
+ export async function writeTempFile(
12
+ content: string,
13
+ prefix: string,
14
+ suffix: string,
15
+ ): Promise<string> {
16
+ const dir = mkdtempSync(join(tmpdir(), `${prefix}-`));
17
+ const hash = randomBytes(4).toString("hex");
18
+ const filePath = join(dir, `${hash}${suffix}`);
19
+ writeFileSync(filePath, content, "utf8");
20
+ return filePath;
21
+ }