@mrclrchtr/supi-web 5.0.0 → 6.0.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/package.json +1 -1
- package/src/docs.ts +5 -328
- package/src/tool/tool-specs.ts +74 -81
- package/src/tool/web_docs_fetch/execute.ts +41 -0
- package/src/tool/web_docs_fetch/guidance.ts +9 -0
- package/src/tool/web_docs_fetch/register.ts +16 -0
- package/src/tool/web_docs_fetch/render.ts +70 -0
- package/src/tool/web_docs_fetch/result.ts +31 -0
- package/src/tool/web_docs_fetch/spec.ts +32 -0
- package/src/tool/web_docs_search/execute.ts +50 -0
- package/src/tool/web_docs_search/guidance.ts +7 -0
- package/src/tool/web_docs_search/register.ts +16 -0
- package/src/tool/web_docs_search/render.ts +75 -0
- package/src/tool/web_docs_search/result.ts +103 -0
- package/src/tool/web_docs_search/spec.ts +28 -0
- package/src/tool/web_fetch_md/execute.ts +69 -0
- package/src/tool/web_fetch_md/guidance.ts +32 -0
- package/src/tool/web_fetch_md/input.ts +39 -0
- package/src/tool/web_fetch_md/register.ts +17 -0
- package/src/tool/web_fetch_md/render.ts +74 -0
- package/src/tool/web_fetch_md/result.ts +52 -0
- package/src/tool/web_fetch_md/spec.ts +21 -0
- package/src/web.ts +3 -178
- package/src/tool/guidance.ts +0 -34
- /package/src/tool/{output.ts → result.ts} +0 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AgentToolResult, TruncationResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ModelVisibleOutput } from "../result.ts";
|
|
3
|
+
|
|
4
|
+
export interface FetchDetails extends Record<string, unknown> {
|
|
5
|
+
libraryId: string;
|
|
6
|
+
raw: boolean;
|
|
7
|
+
chars: number;
|
|
8
|
+
lines: number;
|
|
9
|
+
truncation?: TruncationResult;
|
|
10
|
+
fullOutputPath?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Assemble the model-facing result for one docs fetch. */
|
|
14
|
+
export function buildFetchResult(
|
|
15
|
+
libraryId: string,
|
|
16
|
+
raw: boolean,
|
|
17
|
+
textContent: string,
|
|
18
|
+
output: ModelVisibleOutput,
|
|
19
|
+
): AgentToolResult<FetchDetails> {
|
|
20
|
+
return {
|
|
21
|
+
content: [{ type: "text", text: output.text }],
|
|
22
|
+
details: {
|
|
23
|
+
libraryId,
|
|
24
|
+
raw,
|
|
25
|
+
chars: textContent.length,
|
|
26
|
+
lines: textContent.split("\n").length,
|
|
27
|
+
truncation: output.truncation,
|
|
28
|
+
fullOutputPath: output.fullOutputPath,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Static } from "typebox";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { runFetch } from "./execute.ts";
|
|
4
|
+
|
|
5
|
+
export const WEB_DOCS_FETCH_TOOL_NAME = "web_docs_fetch";
|
|
6
|
+
export const WEB_DOCS_FETCH_TOOL_LABEL = "Web Docs Fetch";
|
|
7
|
+
|
|
8
|
+
export const webDocsFetchParameters = Type.Object(
|
|
9
|
+
{
|
|
10
|
+
library_id: Type.String({
|
|
11
|
+
description: "Context7 ID (e.g. /facebook/react); search first if unknown",
|
|
12
|
+
}),
|
|
13
|
+
query: Type.String({ description: "Specific docs question" }),
|
|
14
|
+
raw: Type.Optional(
|
|
15
|
+
Type.Boolean({
|
|
16
|
+
description: "Return JSON snippets instead of Markdown",
|
|
17
|
+
default: false,
|
|
18
|
+
}),
|
|
19
|
+
),
|
|
20
|
+
},
|
|
21
|
+
{ additionalProperties: false },
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export type WebDocsFetchInput = Static<typeof webDocsFetchParameters>;
|
|
25
|
+
|
|
26
|
+
/** Canonical provider-facing metadata for the web_docs_fetch tool. */
|
|
27
|
+
export const webDocsFetchSpec = {
|
|
28
|
+
name: WEB_DOCS_FETCH_TOOL_NAME,
|
|
29
|
+
label: WEB_DOCS_FETCH_TOOL_LABEL,
|
|
30
|
+
parameters: webDocsFetchParameters,
|
|
31
|
+
execute: runFetch,
|
|
32
|
+
} as const;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentToolResult,
|
|
3
|
+
AgentToolUpdateCallback,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { searchLibrary } from "../../context7-client.ts";
|
|
7
|
+
import { limitModelVisibleOutput } from "../result.ts";
|
|
8
|
+
import {
|
|
9
|
+
buildNoResultsResult,
|
|
10
|
+
buildSearchResult,
|
|
11
|
+
formatSearchResults,
|
|
12
|
+
type SearchDetails,
|
|
13
|
+
} from "./result.ts";
|
|
14
|
+
import type { WebDocsSearchInput } from "./spec.ts";
|
|
15
|
+
|
|
16
|
+
// biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
|
|
17
|
+
export async function runSearch(
|
|
18
|
+
_toolCallId: string,
|
|
19
|
+
params: unknown,
|
|
20
|
+
signal: AbortSignal | undefined,
|
|
21
|
+
onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined,
|
|
22
|
+
_ctx: ExtensionContext,
|
|
23
|
+
): Promise<AgentToolResult<SearchDetails>> {
|
|
24
|
+
const input = (params ?? {}) as WebDocsSearchInput;
|
|
25
|
+
const libraryName = input.library_name?.trim();
|
|
26
|
+
const query = input.query?.trim();
|
|
27
|
+
|
|
28
|
+
if (!libraryName) throw new Error("'library_name' parameter is required");
|
|
29
|
+
if (!query) throw new Error("'query' parameter is required");
|
|
30
|
+
|
|
31
|
+
onUpdate?.({
|
|
32
|
+
content: [{ type: "text", text: `Searching Context7 for ${libraryName}...` }],
|
|
33
|
+
details: { libraryName },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const requestOptions = signal ? { signal } : undefined;
|
|
37
|
+
const results = await searchLibrary(query, libraryName, requestOptions);
|
|
38
|
+
|
|
39
|
+
if (results.length === 0) {
|
|
40
|
+
return buildNoResultsResult(libraryName);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const markdown = formatSearchResults(libraryName, results);
|
|
44
|
+
const output = await limitModelVisibleOutput(markdown, {
|
|
45
|
+
tempPrefix: "web-docs-search",
|
|
46
|
+
suffix: ".md",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return buildSearchResult(libraryName, results, output);
|
|
50
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { MODEL_OUTPUT_LIMIT_DESCRIPTION } from "../result.ts";
|
|
2
|
+
|
|
3
|
+
export const toolDescription = `Search Context7 for library IDs; returns compact Markdown. ${MODEL_OUTPUT_LIMIT_DESCRIPTION}`;
|
|
4
|
+
|
|
5
|
+
export const promptSnippet = "web_docs_search: Context7 library IDs";
|
|
6
|
+
|
|
7
|
+
export const promptGuidelines = ["Use web_docs_search before web_docs_fetch if ID unknown."];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { promptGuidelines, promptSnippet, toolDescription } from "./guidance.ts";
|
|
3
|
+
import { renderSearchCall, renderSearchResult } from "./render.ts";
|
|
4
|
+
import { webDocsSearchSpec } from "./spec.ts";
|
|
5
|
+
|
|
6
|
+
/** Register the web_docs_search tool. */
|
|
7
|
+
export function registerWebDocsSearchTool(pi: ExtensionAPI): void {
|
|
8
|
+
pi.registerTool({
|
|
9
|
+
...webDocsSearchSpec,
|
|
10
|
+
description: toolDescription,
|
|
11
|
+
promptSnippet,
|
|
12
|
+
promptGuidelines: [...promptGuidelines],
|
|
13
|
+
renderCall: renderSearchCall,
|
|
14
|
+
renderResult: renderSearchResult,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { renderCollapsibleTextResult, renderToolCall } from "../render.ts";
|
|
3
|
+
import type { SearchDetails } from "./result.ts";
|
|
4
|
+
import { WEB_DOCS_SEARCH_TOOL_NAME, type WebDocsSearchInput } from "./spec.ts";
|
|
5
|
+
|
|
6
|
+
/** Transcript renderer for web_docs_search tool calls. */
|
|
7
|
+
export function renderSearchCall(args: unknown, theme: Theme) {
|
|
8
|
+
const input = (args ?? {}) as WebDocsSearchInput;
|
|
9
|
+
const libraryName = typeof input.library_name === "string" ? input.library_name : "";
|
|
10
|
+
const query = typeof input.query === "string" ? truncatePreview(input.query) : undefined;
|
|
11
|
+
return renderToolCall(WEB_DOCS_SEARCH_TOOL_NAME, libraryName, theme, query);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Transcript renderer for web_docs_search tool results. */
|
|
15
|
+
export function renderSearchResult(
|
|
16
|
+
result: { content: Array<{ type: string; text?: string }>; details?: unknown },
|
|
17
|
+
{ expanded, isPartial }: { expanded: boolean; isPartial: boolean },
|
|
18
|
+
theme: Theme,
|
|
19
|
+
) {
|
|
20
|
+
if (isPartial) {
|
|
21
|
+
return renderCollapsibleTextResult({
|
|
22
|
+
summary: theme.fg("warning", "Searching Context7..."),
|
|
23
|
+
expanded,
|
|
24
|
+
theme,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const details = result.details as SearchDetails | undefined;
|
|
29
|
+
const summary = buildSearchSummary(details, theme);
|
|
30
|
+
const content = result.content.find((item) => item.type === "text");
|
|
31
|
+
const body =
|
|
32
|
+
details?.count === 0 ? undefined : content?.type === "text" ? content.text : undefined;
|
|
33
|
+
|
|
34
|
+
return renderCollapsibleTextResult({
|
|
35
|
+
summary,
|
|
36
|
+
body,
|
|
37
|
+
expanded,
|
|
38
|
+
theme,
|
|
39
|
+
fullOutputPath: details?.fullOutputPath,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function truncatePreview(text: string, maxChars = 48): string {
|
|
44
|
+
const compact = text.replace(/\s+/g, " ").trim();
|
|
45
|
+
if (compact.length <= maxChars) return compact;
|
|
46
|
+
return `${compact.slice(0, maxChars - 1).trimEnd()}…`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildSearchSummary(
|
|
50
|
+
details: SearchDetails | undefined,
|
|
51
|
+
theme: { fg: (color: "success" | "warning" | "dim", text: string) => string },
|
|
52
|
+
): string {
|
|
53
|
+
if (!details) {
|
|
54
|
+
return theme.fg("success", "Context7 search finished");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (details.count === 0) {
|
|
58
|
+
return [
|
|
59
|
+
theme.fg("warning", "No libraries found"),
|
|
60
|
+
theme.fg("dim", ` for ${JSON.stringify(details.libraryName)}`),
|
|
61
|
+
].join("");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const noun = details.count === 1 ? "library" : "libraries";
|
|
65
|
+
let summary = [
|
|
66
|
+
theme.fg("success", `Found ${details.count} ${noun}`),
|
|
67
|
+
theme.fg("dim", ` for ${JSON.stringify(details.libraryName)}`),
|
|
68
|
+
].join("");
|
|
69
|
+
|
|
70
|
+
if (details.truncation?.truncated) {
|
|
71
|
+
summary += theme.fg("warning", " [truncated]");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return summary;
|
|
75
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { AgentToolResult, TruncationResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { searchLibrary } from "../../context7-client.ts";
|
|
3
|
+
import type { ModelVisibleOutput } from "../result.ts";
|
|
4
|
+
|
|
5
|
+
export interface SearchDetails extends Record<string, unknown> {
|
|
6
|
+
count: number;
|
|
7
|
+
libraryName: string;
|
|
8
|
+
truncation?: TruncationResult;
|
|
9
|
+
fullOutputPath?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type SearchLibraryResult = Awaited<ReturnType<typeof searchLibrary>>[number];
|
|
13
|
+
|
|
14
|
+
const MAX_SEARCH_RESULTS = 10;
|
|
15
|
+
const MAX_DESCRIPTION_CHARS = 120;
|
|
16
|
+
const MAX_VERSION_COUNT = 5;
|
|
17
|
+
|
|
18
|
+
/** Assemble the empty-search result. */
|
|
19
|
+
export function buildNoResultsResult(libraryName: string): AgentToolResult<SearchDetails> {
|
|
20
|
+
return {
|
|
21
|
+
content: [
|
|
22
|
+
{
|
|
23
|
+
type: "text",
|
|
24
|
+
text: `No libraries found for "${libraryName}". Try a different search term.`,
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
details: { count: 0, libraryName },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Assemble the model-facing result for one successful search. */
|
|
32
|
+
export function buildSearchResult(
|
|
33
|
+
libraryName: string,
|
|
34
|
+
results: Awaited<ReturnType<typeof searchLibrary>>,
|
|
35
|
+
output: ModelVisibleOutput,
|
|
36
|
+
): AgentToolResult<SearchDetails> {
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: "text", text: output.text }],
|
|
39
|
+
details: {
|
|
40
|
+
count: results.length,
|
|
41
|
+
libraryName,
|
|
42
|
+
truncation: output.truncation,
|
|
43
|
+
fullOutputPath: output.fullOutputPath,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Format search results as compact Markdown for the model. */
|
|
49
|
+
export function formatSearchResults(
|
|
50
|
+
libraryName: string,
|
|
51
|
+
results: Awaited<ReturnType<typeof searchLibrary>>,
|
|
52
|
+
): string {
|
|
53
|
+
const visibleResults = results.slice(0, MAX_SEARCH_RESULTS);
|
|
54
|
+
const hiddenCount = results.length - visibleResults.length;
|
|
55
|
+
const rows = visibleResults.map(formatSearchRow);
|
|
56
|
+
const noun = results.length === 1 ? "library" : "libraries";
|
|
57
|
+
const hiddenNote =
|
|
58
|
+
hiddenCount > 0
|
|
59
|
+
? [`_${hiddenCount} more omitted; refine \`library_name\` or \`query\` if needed._`, ""]
|
|
60
|
+
: [];
|
|
61
|
+
|
|
62
|
+
return [
|
|
63
|
+
`Found ${results.length} Context7 ${noun} for "${libraryName}"${hiddenCount > 0 ? `; showing top ${visibleResults.length}` : ""}:`,
|
|
64
|
+
"",
|
|
65
|
+
"| ID | Name | Trust | Bench | Snips | Versions | Description |",
|
|
66
|
+
"|---|---|---|---|---|---|---|",
|
|
67
|
+
...rows,
|
|
68
|
+
"",
|
|
69
|
+
...hiddenNote,
|
|
70
|
+
"> Use `web_docs_fetch` with the chosen ID.",
|
|
71
|
+
].join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function formatSearchRow(lib: SearchLibraryResult): string {
|
|
75
|
+
const cells = [
|
|
76
|
+
`\`${escapeMd(lib.id)}\``,
|
|
77
|
+
escapeMd(lib.name),
|
|
78
|
+
String(lib.trustScore ?? ""),
|
|
79
|
+
String(lib.benchmarkScore ?? ""),
|
|
80
|
+
String(lib.totalSnippets ?? ""),
|
|
81
|
+
escapeMd(formatVersions(lib.versions)),
|
|
82
|
+
escapeMd(truncateCell(lib.description ?? "", MAX_DESCRIPTION_CHARS)),
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
return `| ${cells.join(" | ")} |`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatVersions(versions?: string[]): string {
|
|
89
|
+
if (!versions?.length) return "";
|
|
90
|
+
const visibleVersions = versions.slice(0, MAX_VERSION_COUNT);
|
|
91
|
+
const hiddenCount = versions.length - visibleVersions.length;
|
|
92
|
+
return `${visibleVersions.join(", ")}${hiddenCount > 0 ? `, +${hiddenCount}` : ""}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function truncateCell(text: string, maxChars: number): string {
|
|
96
|
+
const compact = text.replace(/\s+/g, " ").trim();
|
|
97
|
+
if (compact.length <= maxChars) return compact;
|
|
98
|
+
return `${compact.slice(0, maxChars - 1).trimEnd()}…`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function escapeMd(text: string): string {
|
|
102
|
+
return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
103
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Static } from "typebox";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { runSearch } from "./execute.ts";
|
|
4
|
+
|
|
5
|
+
export const WEB_DOCS_SEARCH_TOOL_NAME = "web_docs_search";
|
|
6
|
+
export const WEB_DOCS_SEARCH_TOOL_LABEL = "Web Docs Search";
|
|
7
|
+
|
|
8
|
+
export const webDocsSearchParameters = Type.Object(
|
|
9
|
+
{
|
|
10
|
+
library_name: Type.String({
|
|
11
|
+
description: "Library name (e.g. react, next.js, fastapi)",
|
|
12
|
+
}),
|
|
13
|
+
query: Type.String({
|
|
14
|
+
description: "Task/question for relevance ranking",
|
|
15
|
+
}),
|
|
16
|
+
},
|
|
17
|
+
{ additionalProperties: false },
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export type WebDocsSearchInput = Static<typeof webDocsSearchParameters>;
|
|
21
|
+
|
|
22
|
+
/** Canonical provider-facing metadata for the web_docs_search tool. */
|
|
23
|
+
export const webDocsSearchSpec = {
|
|
24
|
+
name: WEB_DOCS_SEARCH_TOOL_NAME,
|
|
25
|
+
label: WEB_DOCS_SEARCH_TOOL_LABEL,
|
|
26
|
+
parameters: webDocsSearchParameters,
|
|
27
|
+
execute: runSearch,
|
|
28
|
+
} as const;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentToolResult,
|
|
3
|
+
AgentToolUpdateCallback,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { htmlToMarkdown, wrapAsCodeBlock } from "../../convert.ts";
|
|
7
|
+
import { fetchWithNegotiation, isValidHttpUrl } from "../../fetch.ts";
|
|
8
|
+
import { writeTempFile } from "../../temp-file.ts";
|
|
9
|
+
import { limitModelVisibleOutput } from "../result.ts";
|
|
10
|
+
import {
|
|
11
|
+
WEB_FETCH_INLINE_MAX_CHARS,
|
|
12
|
+
type WebFetchMdInput,
|
|
13
|
+
type WebFetchOutputMode,
|
|
14
|
+
} from "./input.ts";
|
|
15
|
+
import { buildFileResult, buildInlineResult, type WebFetchDetails } from "./result.ts";
|
|
16
|
+
|
|
17
|
+
// biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
|
|
18
|
+
export async function runWebFetch(
|
|
19
|
+
_toolCallId: string,
|
|
20
|
+
params: unknown,
|
|
21
|
+
signal: AbortSignal | undefined,
|
|
22
|
+
onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined,
|
|
23
|
+
_ctx: ExtensionContext,
|
|
24
|
+
): Promise<AgentToolResult<WebFetchDetails>> {
|
|
25
|
+
const input = (params ?? {}) as WebFetchMdInput;
|
|
26
|
+
const url = String(input.url || "").trim();
|
|
27
|
+
if (!isValidHttpUrl(url)) {
|
|
28
|
+
throw new Error(`URL must be http(s): ${url}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const outputMode = input.output_mode ?? "auto";
|
|
32
|
+
const absLinks = input.abs_links ?? true;
|
|
33
|
+
const timeoutMs = typeof input.timeout_ms === "number" ? input.timeout_ms : 30_000;
|
|
34
|
+
|
|
35
|
+
onUpdate?.({
|
|
36
|
+
content: [{ type: "text", text: `Fetching ${url}...` }],
|
|
37
|
+
details: { url, outputMode },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const result = await fetchWithNegotiation(url, { timeoutMs, signal });
|
|
41
|
+
const markdown = await resolveMarkdown(result, absLinks);
|
|
42
|
+
const lines = markdown.split("\n").length;
|
|
43
|
+
const chars = markdown.length;
|
|
44
|
+
const base = { chars, lines, url: result.url, outputMode };
|
|
45
|
+
|
|
46
|
+
if (shouldReturnFile(outputMode, chars)) {
|
|
47
|
+
const filePath = await writeTempFile(markdown, "web-fetch-md", ".md");
|
|
48
|
+
return buildFileResult(base, filePath);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const output = await limitModelVisibleOutput(markdown, {
|
|
52
|
+
tempPrefix: "web-fetch-md",
|
|
53
|
+
suffix: ".md",
|
|
54
|
+
});
|
|
55
|
+
return buildInlineResult(base, output);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function shouldReturnFile(outputMode: WebFetchOutputMode, chars: number): boolean {
|
|
59
|
+
return outputMode === "file" || (outputMode === "auto" && chars > WEB_FETCH_INLINE_MAX_CHARS);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function resolveMarkdown(
|
|
63
|
+
result: { isMarkdown: boolean; isPlainText: boolean; text: string; url: string },
|
|
64
|
+
absLinks: boolean,
|
|
65
|
+
): Promise<string> {
|
|
66
|
+
if (result.isMarkdown) return result.text;
|
|
67
|
+
if (result.isPlainText) return wrapAsCodeBlock(result.text, result.url);
|
|
68
|
+
return htmlToMarkdown(result.text, result.url, { absLinks });
|
|
69
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { MODEL_OUTPUT_LIMIT_DESCRIPTION } from "../result.ts";
|
|
3
|
+
import type { WebToolPromptSurface } from "../tool-specs.ts";
|
|
4
|
+
import { WEB_FETCH_INLINE_MAX_CHARS } from "./spec.ts";
|
|
5
|
+
|
|
6
|
+
export const toolDescription = `Fetch public http(s) URL as Markdown. Not for login/private pages. output_mode auto inlines <=${WEB_FETCH_INLINE_MAX_CHARS.toLocaleString()} chars else temp; inline may truncate; file returns a temp path. Links are absolute by default. ${MODEL_OUTPUT_LIMIT_DESCRIPTION}`;
|
|
7
|
+
|
|
8
|
+
export const promptSnippet = "web_fetch_md: public URL to Markdown";
|
|
9
|
+
|
|
10
|
+
export const promptGuidelines = ["Use web_fetch_md only for public http(s); ask if login/private."];
|
|
11
|
+
|
|
12
|
+
/** Runtime prompt surface; adds gh guidance when the gh CLI is available. */
|
|
13
|
+
export function getWebFetchPromptSurface(): WebToolPromptSurface {
|
|
14
|
+
const guidelines = [...promptGuidelines];
|
|
15
|
+
if (isGhAvailable()) {
|
|
16
|
+
guidelines.push("Use `gh` CLI instead of web_fetch_md for GitHub URLs.");
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
description: toolDescription,
|
|
20
|
+
promptSnippet,
|
|
21
|
+
promptGuidelines: guidelines,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isGhAvailable(): boolean {
|
|
26
|
+
try {
|
|
27
|
+
const result = spawnSync("gh", ["--version"], { stdio: "ignore" });
|
|
28
|
+
return result.status === 0;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Leaf module for web_fetch_md input vocabulary.
|
|
2
|
+
//
|
|
3
|
+
// The parameter schema and its constants live here (not in spec.ts) so
|
|
4
|
+
// execute.ts can read them without importing spec.ts; spec.ts imports the
|
|
5
|
+
// execute binding, and a spec <-> execute cycle would risk undefined
|
|
6
|
+
// bindings depending on module evaluation order.
|
|
7
|
+
|
|
8
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
9
|
+
import type { Static } from "typebox";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import { FETCH_TIMEOUT_MAX_MS } from "../../fetch.ts";
|
|
12
|
+
|
|
13
|
+
export const WEB_FETCH_INLINE_MAX_CHARS = 15_000;
|
|
14
|
+
export const WEB_FETCH_OUTPUT_MODES = ["auto", "inline", "file"] as const;
|
|
15
|
+
export type WebFetchOutputMode = (typeof WEB_FETCH_OUTPUT_MODES)[number];
|
|
16
|
+
|
|
17
|
+
const OutputModeEnum = StringEnum(WEB_FETCH_OUTPUT_MODES, {
|
|
18
|
+
default: "auto",
|
|
19
|
+
description: "auto, inline, or file output",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const webFetchMdParameters = Type.Object(
|
|
23
|
+
{
|
|
24
|
+
url: Type.String({ description: "Public http(s) URL" }),
|
|
25
|
+
output_mode: Type.Optional(OutputModeEnum),
|
|
26
|
+
abs_links: Type.Optional(Type.Boolean({ description: "Absolute links/images", default: true })),
|
|
27
|
+
timeout_ms: Type.Optional(
|
|
28
|
+
Type.Integer({
|
|
29
|
+
description: "Fetch timeout (ms)",
|
|
30
|
+
default: 30_000,
|
|
31
|
+
minimum: 0,
|
|
32
|
+
maximum: FETCH_TIMEOUT_MAX_MS,
|
|
33
|
+
}),
|
|
34
|
+
),
|
|
35
|
+
},
|
|
36
|
+
{ additionalProperties: false },
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
export type WebFetchMdInput = Static<typeof webFetchMdParameters>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getWebFetchPromptSurface } from "./guidance.ts";
|
|
3
|
+
import { renderWebFetchCall, renderWebFetchResult } from "./render.ts";
|
|
4
|
+
import { webFetchMdSpec } from "./spec.ts";
|
|
5
|
+
|
|
6
|
+
/** Register the web_fetch_md tool. */
|
|
7
|
+
export function registerWebFetchMdTool(pi: ExtensionAPI): void {
|
|
8
|
+
const surface = getWebFetchPromptSurface();
|
|
9
|
+
pi.registerTool({
|
|
10
|
+
...webFetchMdSpec,
|
|
11
|
+
description: surface.description,
|
|
12
|
+
promptSnippet: surface.promptSnippet,
|
|
13
|
+
promptGuidelines: surface.promptGuidelines,
|
|
14
|
+
renderCall: renderWebFetchCall,
|
|
15
|
+
renderResult: renderWebFetchResult,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { renderCollapsibleTextResult, renderToolCall } from "../render.ts";
|
|
3
|
+
import type { WebFetchDetails } from "./result.ts";
|
|
4
|
+
import { WEB_FETCH_MD_TOOL_NAME, type WebFetchMdInput } from "./spec.ts";
|
|
5
|
+
|
|
6
|
+
/** Transcript renderer for web_fetch_md tool calls. */
|
|
7
|
+
export function renderWebFetchCall(args: unknown, theme: Theme) {
|
|
8
|
+
const input = (args ?? {}) as WebFetchMdInput;
|
|
9
|
+
const url = typeof input.url === "string" ? input.url : "";
|
|
10
|
+
const outputMode = typeof input.output_mode === "string" ? input.output_mode : undefined;
|
|
11
|
+
return renderToolCall(WEB_FETCH_MD_TOOL_NAME, url, theme, outputMode);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Transcript renderer for web_fetch_md tool results. */
|
|
15
|
+
export function renderWebFetchResult(
|
|
16
|
+
result: { content: Array<{ type: string; text?: string }>; details?: unknown },
|
|
17
|
+
{ expanded, isPartial }: { expanded: boolean; isPartial: boolean },
|
|
18
|
+
theme: Theme,
|
|
19
|
+
) {
|
|
20
|
+
if (isPartial) {
|
|
21
|
+
return renderCollapsibleTextResult({
|
|
22
|
+
summary: theme.fg("warning", "Fetching web content..."),
|
|
23
|
+
expanded,
|
|
24
|
+
theme,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const details = result.details as WebFetchDetails | undefined;
|
|
29
|
+
const summary = buildWebFetchSummary(details, theme);
|
|
30
|
+
const content = result.content.find((item) => item.type === "text");
|
|
31
|
+
const body = details?.filePath ? undefined : content?.type === "text" ? content.text : undefined;
|
|
32
|
+
|
|
33
|
+
return renderCollapsibleTextResult({
|
|
34
|
+
summary,
|
|
35
|
+
body,
|
|
36
|
+
expanded,
|
|
37
|
+
theme,
|
|
38
|
+
fullOutputPath: details?.fullOutputPath,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildWebFetchSummary(
|
|
43
|
+
details: WebFetchDetails | undefined,
|
|
44
|
+
theme: { fg: (color: "success" | "warning" | "dim", text: string) => string },
|
|
45
|
+
): string {
|
|
46
|
+
if (!details) {
|
|
47
|
+
return theme.fg("success", "Fetched web content");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (details.filePath) {
|
|
51
|
+
return [
|
|
52
|
+
theme.fg("success", "Saved Markdown to "),
|
|
53
|
+
theme.fg("dim", details.filePath),
|
|
54
|
+
theme.fg(
|
|
55
|
+
"dim",
|
|
56
|
+
` (${details.chars.toLocaleString()} chars, ${details.lines.toLocaleString()} lines)`,
|
|
57
|
+
),
|
|
58
|
+
].join("");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let summary = [
|
|
62
|
+
theme.fg("success", "Fetched Markdown"),
|
|
63
|
+
theme.fg(
|
|
64
|
+
"dim",
|
|
65
|
+
` (${details.chars.toLocaleString()} chars, ${details.lines.toLocaleString()} lines)`,
|
|
66
|
+
),
|
|
67
|
+
].join("");
|
|
68
|
+
|
|
69
|
+
if (details.truncation?.truncated) {
|
|
70
|
+
summary += theme.fg("warning", " [truncated]");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return summary;
|
|
74
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { AgentToolResult, TruncationResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ModelVisibleOutput } from "../result.ts";
|
|
3
|
+
import type { WebFetchOutputMode } from "./spec.ts";
|
|
4
|
+
|
|
5
|
+
export interface WebFetchDetails extends Record<string, unknown> {
|
|
6
|
+
chars: number;
|
|
7
|
+
lines: number;
|
|
8
|
+
url: string;
|
|
9
|
+
outputMode: WebFetchOutputMode;
|
|
10
|
+
filePath?: string;
|
|
11
|
+
truncation?: TruncationResult;
|
|
12
|
+
fullOutputPath?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Common result metrics shared by both fetch result modes. */
|
|
16
|
+
export interface WebFetchResultBase {
|
|
17
|
+
chars: number;
|
|
18
|
+
lines: number;
|
|
19
|
+
url: string;
|
|
20
|
+
outputMode: WebFetchOutputMode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Assemble the file-mode result for one fetch. */
|
|
24
|
+
export function buildFileResult(
|
|
25
|
+
base: WebFetchResultBase,
|
|
26
|
+
filePath: string,
|
|
27
|
+
): AgentToolResult<WebFetchDetails> {
|
|
28
|
+
return {
|
|
29
|
+
content: [
|
|
30
|
+
{
|
|
31
|
+
type: "text",
|
|
32
|
+
text: `Content written to ${filePath} (${base.chars.toLocaleString()} chars, ${base.lines.toLocaleString()} lines). Use the read tool to access it.`,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
details: { ...base, filePath },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Assemble the inline-mode result for one fetch. */
|
|
40
|
+
export function buildInlineResult(
|
|
41
|
+
base: WebFetchResultBase,
|
|
42
|
+
output: ModelVisibleOutput,
|
|
43
|
+
): AgentToolResult<WebFetchDetails> {
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: "text", text: output.text }],
|
|
46
|
+
details: {
|
|
47
|
+
...base,
|
|
48
|
+
truncation: output.truncation,
|
|
49
|
+
fullOutputPath: output.fullOutputPath,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { runWebFetch } from "./execute.ts";
|
|
2
|
+
import { webFetchMdParameters } from "./input.ts";
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
WEB_FETCH_INLINE_MAX_CHARS,
|
|
6
|
+
WEB_FETCH_OUTPUT_MODES,
|
|
7
|
+
type WebFetchMdInput,
|
|
8
|
+
type WebFetchOutputMode,
|
|
9
|
+
webFetchMdParameters,
|
|
10
|
+
} from "./input.ts";
|
|
11
|
+
|
|
12
|
+
export const WEB_FETCH_MD_TOOL_NAME = "web_fetch_md";
|
|
13
|
+
export const WEB_FETCH_MD_TOOL_LABEL = "Web Fetch";
|
|
14
|
+
|
|
15
|
+
/** Canonical provider-facing metadata for the web_fetch_md tool. */
|
|
16
|
+
export const webFetchMdSpec = {
|
|
17
|
+
name: WEB_FETCH_MD_TOOL_NAME,
|
|
18
|
+
label: WEB_FETCH_MD_TOOL_LABEL,
|
|
19
|
+
parameters: webFetchMdParameters,
|
|
20
|
+
execute: runWebFetch,
|
|
21
|
+
} as const;
|