@mrclrchtr/supi-web 1.14.3 → 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/web.ts CHANGED
@@ -2,105 +2,143 @@
2
2
  * SuPi Web extension entry point — registers the `web_fetch_md` tool with pi.
3
3
  */
4
4
 
5
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
- import { Type } from "typebox";
5
+ import type {
6
+ AgentToolResult,
7
+ AgentToolUpdateCallback,
8
+ ExtensionAPI,
9
+ ExtensionContext,
10
+ TruncationResult,
11
+ } from "@earendil-works/pi-coding-agent";
7
12
  import { htmlToMarkdown, wrapAsCodeBlock } from "./convert.ts";
8
- import { FetchError, fetchWithNegotiation, isValidHttpUrl } from "./fetch.ts";
13
+ import { fetchWithNegotiation, isValidHttpUrl } from "./fetch.ts";
9
14
  import { writeTempFile } from "./temp-file.ts";
15
+ import { getWebToolPromptSurface } from "./tool/guidance.ts";
16
+ import { limitModelVisibleOutput } from "./tool/output.ts";
17
+ import { renderCollapsibleTextResult, renderToolCall } from "./tool/render.ts";
10
18
  import {
11
- buildPromptGuidelines,
12
- promptSnippet,
13
- toolDescription,
14
- } from "./tool/web-fetch-md-guidance.ts";
19
+ getWebToolSpec,
20
+ WEB_FETCH_INLINE_MAX_CHARS,
21
+ WEB_FETCH_MD_TOOL_NAME,
22
+ type WebFetchMdInput,
23
+ type WebFetchOutputMode,
24
+ } from "./tool/tool-specs.ts";
15
25
 
16
- const TOOL_NAME = "web_fetch_md";
17
- const TOOL_LABEL = "Web Fetch";
18
- const INLINE_MAX_CHARS = 15_000;
19
-
20
- const OutputModeEnum = Type.Union(
21
- [Type.Literal("auto"), Type.Literal("inline"), Type.Literal("file")],
22
- { default: "auto", description: "Output mode: auto, inline, or file" },
23
- );
26
+ interface WebFetchDetails extends Record<string, unknown> {
27
+ chars: number;
28
+ lines: number;
29
+ url: string;
30
+ outputMode: WebFetchOutputMode;
31
+ filePath?: string;
32
+ truncation?: TruncationResult;
33
+ fullOutputPath?: string;
34
+ }
24
35
 
25
36
  export default function webExtension(pi: ExtensionAPI): void {
37
+ const spec = getWebToolSpec(WEB_FETCH_MD_TOOL_NAME);
38
+ const surface = getWebToolPromptSurface(WEB_FETCH_MD_TOOL_NAME);
39
+
26
40
  pi.registerTool({
27
- name: TOOL_NAME,
28
- label: TOOL_LABEL,
29
- description: toolDescription,
30
- promptSnippet,
31
- promptGuidelines: buildPromptGuidelines(),
32
- parameters: Type.Object({
33
- url: Type.String({ description: "http(s) URL to fetch" }),
34
- output_mode: Type.Optional(OutputModeEnum),
35
- abs_links: Type.Optional(
36
- Type.Boolean({ description: "Absolutize relative links/images", default: true }),
37
- ),
38
- timeout_ms: Type.Optional(
39
- Type.Number({ description: "Fetch timeout in milliseconds", default: 30_000 }),
40
- ),
41
- }),
41
+ name: spec.name,
42
+ label: spec.label,
43
+ description: surface.description,
44
+ promptSnippet: surface.promptSnippet,
45
+ promptGuidelines: surface.promptGuidelines,
46
+ parameters: spec.parameters,
42
47
  execute: runWebFetch,
48
+ renderCall(args, theme) {
49
+ const input = (args ?? {}) as WebFetchMdInput;
50
+ const url = typeof input.url === "string" ? input.url : "";
51
+ const outputMode = typeof input.output_mode === "string" ? input.output_mode : undefined;
52
+ return renderToolCall(spec.name, url, theme, outputMode);
53
+ },
54
+ renderResult(result, { expanded, isPartial }, theme) {
55
+ if (isPartial) {
56
+ return renderCollapsibleTextResult({
57
+ summary: theme.fg("warning", "Fetching web content..."),
58
+ expanded,
59
+ theme,
60
+ });
61
+ }
62
+
63
+ const details = result.details as WebFetchDetails | undefined;
64
+ const summary = buildWebFetchSummary(details, theme);
65
+ const content = result.content.find((item) => item.type === "text");
66
+ const body = details?.filePath
67
+ ? undefined
68
+ : content?.type === "text"
69
+ ? content.text
70
+ : undefined;
71
+
72
+ return renderCollapsibleTextResult({
73
+ summary,
74
+ body,
75
+ expanded,
76
+ theme,
77
+ fullOutputPath: details?.fullOutputPath,
78
+ });
79
+ },
43
80
  });
44
81
  }
45
82
 
46
83
  // biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
47
84
  async function runWebFetch(
48
85
  _toolCallId: string,
49
- params: Record<string, unknown>,
50
- _signal: AbortSignal | undefined,
51
- _onUpdate: unknown,
52
- _ctx: unknown,
53
- ): Promise<{
54
- content: { type: "text"; text: string }[];
55
- details: Record<string, unknown>;
56
- isError?: boolean;
57
- }> {
58
- const url = String(params.url || "").trim();
86
+ params: unknown,
87
+ signal: AbortSignal | undefined,
88
+ onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined,
89
+ _ctx: ExtensionContext,
90
+ ): Promise<AgentToolResult<WebFetchDetails>> {
91
+ const input = (params ?? {}) as WebFetchMdInput;
92
+ const url = String(input.url || "").trim();
59
93
  if (!isValidHttpUrl(url)) {
60
- return {
61
- content: [{ type: "text", text: `Error: URL must be http(s): ${url}` }],
62
- isError: true,
63
- details: { invalidUrl: url },
64
- } as const;
94
+ throw new Error(`URL must be http(s): ${url}`);
65
95
  }
66
96
 
67
- const outputMode = (params.output_mode as "auto" | "inline" | "file" | undefined) ?? "auto";
68
- const absLinks = (params.abs_links as boolean | undefined) ?? true;
69
- const timeoutMs = typeof params.timeout_ms === "number" ? params.timeout_ms : 30_000;
70
-
71
- try {
72
- const result = await fetchWithNegotiation(url, { timeoutMs });
73
- const markdown = await resolveMarkdown(result, absLinks);
74
- const lines = markdown.split("\n").length;
75
- const chars = markdown.length;
76
-
77
- const useFile = outputMode === "file" || (outputMode === "auto" && chars > INLINE_MAX_CHARS);
78
-
79
- if (useFile) {
80
- const filePath = await writeTempFile(markdown, "web-fetch-md", ".md");
81
- return {
82
- content: [
83
- {
84
- type: "text",
85
- text: `Content written to ${filePath} (${chars.toLocaleString()} chars, ${lines.toLocaleString()} lines). Use the read tool to access it.`,
86
- },
87
- ],
88
- details: { filePath, chars, lines, url: result.url },
89
- };
90
- }
97
+ const outputMode = input.output_mode ?? "auto";
98
+ const absLinks = input.abs_links ?? true;
99
+ const timeoutMs = typeof input.timeout_ms === "number" ? input.timeout_ms : 30_000;
91
100
 
101
+ onUpdate?.({
102
+ content: [{ type: "text", text: `Fetching ${url}...` }],
103
+ details: { url, outputMode },
104
+ });
105
+
106
+ const result = await fetchWithNegotiation(url, { timeoutMs, signal });
107
+ const markdown = await resolveMarkdown(result, absLinks);
108
+ const lines = markdown.split("\n").length;
109
+ const chars = markdown.length;
110
+ const details: WebFetchDetails = { chars, lines, url: result.url, outputMode };
111
+
112
+ if (shouldReturnFile(outputMode, chars)) {
113
+ const filePath = await writeTempFile(markdown, "web-fetch-md", ".md");
92
114
  return {
93
- content: [{ type: "text", text: markdown }],
94
- details: { chars, lines, url: result.url },
95
- };
96
- } catch (err) {
97
- const message = err instanceof FetchError ? err.message : `Unexpected error: ${String(err)}`;
98
- return {
99
- content: [{ type: "text", text: `Error: ${message}` }],
100
- isError: true,
101
- details: { url, error: String(err) },
115
+ content: [
116
+ {
117
+ type: "text",
118
+ text: `Content written to ${filePath} (${chars.toLocaleString()} chars, ${lines.toLocaleString()} lines). Use the read tool to access it.`,
119
+ },
120
+ ],
121
+ details: { ...details, filePath },
102
122
  };
103
123
  }
124
+
125
+ const output = await limitModelVisibleOutput(markdown, {
126
+ tempPrefix: "web-fetch-md",
127
+ suffix: ".md",
128
+ });
129
+
130
+ return {
131
+ content: [{ type: "text", text: output.text }],
132
+ details: {
133
+ ...details,
134
+ truncation: output.truncation,
135
+ fullOutputPath: output.fullOutputPath,
136
+ },
137
+ };
138
+ }
139
+
140
+ function shouldReturnFile(outputMode: WebFetchOutputMode, chars: number): boolean {
141
+ return outputMode === "file" || (outputMode === "auto" && chars > WEB_FETCH_INLINE_MAX_CHARS);
104
142
  }
105
143
 
106
144
  async function resolveMarkdown(
@@ -111,3 +149,37 @@ async function resolveMarkdown(
111
149
  if (result.isPlainText) return wrapAsCodeBlock(result.text, result.url);
112
150
  return htmlToMarkdown(result.text, result.url, { absLinks });
113
151
  }
152
+
153
+ function buildWebFetchSummary(
154
+ details: WebFetchDetails | undefined,
155
+ theme: { fg: (color: "success" | "warning" | "dim", text: string) => string },
156
+ ): string {
157
+ if (!details) {
158
+ return theme.fg("success", "Fetched web content");
159
+ }
160
+
161
+ if (details.filePath) {
162
+ return [
163
+ theme.fg("success", "Saved Markdown to "),
164
+ theme.fg("dim", details.filePath),
165
+ theme.fg(
166
+ "dim",
167
+ ` (${details.chars.toLocaleString()} chars, ${details.lines.toLocaleString()} lines)`,
168
+ ),
169
+ ].join("");
170
+ }
171
+
172
+ let summary = [
173
+ theme.fg("success", "Fetched Markdown"),
174
+ theme.fg(
175
+ "dim",
176
+ ` (${details.chars.toLocaleString()} chars, ${details.lines.toLocaleString()} lines)`,
177
+ ),
178
+ ].join("");
179
+
180
+ if (details.truncation?.truncated) {
181
+ summary += theme.fg("warning", " [truncated]");
182
+ }
183
+
184
+ return summary;
185
+ }
@@ -1,11 +0,0 @@
1
- // Prompt guidance and tool description for the web_docs_fetch tool.
2
-
3
- export const toolDescription = `Retrieve focused Context7 docs for a known library ID. Returns Markdown by default, or JSON snippets when \`raw: true\`.`;
4
-
5
- export const promptSnippet = "web_docs_fetch — retrieve focused Context7 docs";
6
-
7
- export const promptGuidelines = [
8
- "Use web_docs_fetch once the Context7 `library_id` is known; otherwise call web_docs_search first.",
9
- "Use web_docs_fetch only with Context7 library IDs such as `/facebook/react`, and ask a specific `query`.",
10
- "Use web_docs_fetch with `raw: true` only when you need JSON snippet objects instead of Markdown.",
11
- ];
@@ -1,10 +0,0 @@
1
- // Prompt guidance and tool description for the web_docs_search tool.
2
-
3
- export const toolDescription = `Search Context7 for library IDs before fetching docs. Returns a Markdown table of matching libraries and metadata.`;
4
-
5
- export const promptSnippet = "web_docs_search — find Context7 library IDs before web_docs_fetch";
6
-
7
- export const promptGuidelines = [
8
- "Use web_docs_search before web_docs_fetch when the Context7 `library_id` is unknown.",
9
- "Use web_docs_search with both `library_name` and a descriptive `query`, then choose the best `library_id`.",
10
- ];
@@ -1,41 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
-
3
- // Prompt guidance and tool description for the web_fetch_md tool.
4
-
5
- const INLINE_MAX_CHARS = 15_000;
6
-
7
- export const toolDescription = `Fetch a web page and convert it to Markdown.
8
-
9
- Use web_fetch_md only for public \`http://\` or \`https://\` URLs. If a page is private or access-controlled, ask for another source.
10
-
11
- Output modes:
12
- - \`auto\` (default): inline up to ${INLINE_MAX_CHARS.toLocaleString()} chars, otherwise return a temp file path
13
- - \`inline\`: always inline
14
- - \`file\`: always return a temp file path
15
-
16
- Links and images default to absolute; use \`abs_links: false\` to keep relative paths.`;
17
-
18
- export const promptSnippet = "web_fetch_md — fetch a public URL as Markdown";
19
-
20
- function isGhAvailable(): boolean {
21
- try {
22
- const result = spawnSync("gh", ["--version"], { stdio: "ignore" });
23
- return result.status === 0;
24
- } catch {
25
- return false;
26
- }
27
- }
28
-
29
- export function buildPromptGuidelines(): string[] {
30
- const guidelines = [
31
- "Use web_fetch_md only for public `http://` or `https://` pages; if a page is private or access-controlled, ask for an allowed source.",
32
- "Use web_fetch_md with `output_mode: auto` by default; use `inline` for in-context Markdown and `file` for a temp path.",
33
- "Use web_fetch_md with `abs_links: false` only when you want relative links or images.",
34
- ];
35
- if (isGhAvailable()) {
36
- guidelines.push(
37
- "Use bash with the `gh` CLI instead of web_fetch_md for GitHub URLs when `gh` is available.",
38
- );
39
- }
40
- return guidelines;
41
- }