@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/README.md +8 -4
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/context/context-provider-registry.ts +1 -1
- package/package.json +10 -2
- package/src/api.ts +10 -0
- package/src/context7-client.ts +13 -3
- package/src/docs.ts +295 -156
- package/src/fetch.ts +174 -92
- package/src/index.ts +10 -0
- package/src/temp-file.ts +6 -5
- package/src/tool/guidance.ts +34 -0
- package/src/tool/output.ts +50 -0
- package/src/tool/render.ts +63 -0
- package/src/tool/tool-specs.ts +107 -0
- package/src/web.ts +150 -78
- package/src/tool/web-docs-fetch-guidance.ts +0 -11
- package/src/tool/web-docs-search-guidance.ts +0 -10
- package/src/tool/web-fetch-md-guidance.ts +0 -41
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 {
|
|
6
|
-
|
|
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 {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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:
|
|
28
|
-
label:
|
|
29
|
-
description:
|
|
30
|
-
promptSnippet,
|
|
31
|
-
promptGuidelines:
|
|
32
|
-
parameters:
|
|
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:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
_ctx:
|
|
53
|
-
): Promise<{
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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 =
|
|
68
|
-
const absLinks =
|
|
69
|
-
const timeoutMs = typeof
|
|
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: [
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
}
|