@kolisachint/hoocode-agent 0.4.80 → 0.4.81
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/CHANGELOG.md +15 -0
- package/dist/cli/args.d.ts +2 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +7 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/core/agent-frontmatter.d.ts +6 -5
- package/dist/core/agent-frontmatter.d.ts.map +1 -1
- package/dist/core/agent-frontmatter.js +19 -6
- package/dist/core/agent-frontmatter.js.map +1 -1
- package/dist/core/sdk.d.ts +7 -0
- package/dist/core/sdk.d.ts.map +1 -1
- package/dist/core/sdk.js +5 -1
- package/dist/core/sdk.js.map +1 -1
- package/dist/core/settings-defaults.d.ts +1 -0
- package/dist/core/settings-defaults.d.ts.map +1 -1
- package/dist/core/settings-defaults.js +1 -0
- package/dist/core/settings-defaults.js.map +1 -1
- package/dist/core/settings-manager.d.ts +3 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +8 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/tools/index.d.ts +7 -1
- package/dist/core/tools/index.d.ts.map +1 -1
- package/dist/core/tools/index.js +28 -1
- package/dist/core/tools/index.js.map +1 -1
- package/dist/core/tools/webfetch.d.ts +25 -0
- package/dist/core/tools/webfetch.d.ts.map +1 -0
- package/dist/core/tools/webfetch.js +102 -0
- package/dist/core/tools/webfetch.js.map +1 -0
- package/dist/core/tools/websearch.d.ts +23 -0
- package/dist/core/tools/websearch.d.ts.map +1 -0
- package/dist/core/tools/websearch.js +126 -0
- package/dist/core/tools/websearch.js.map +1 -0
- package/dist/core/tools/webtools-shared.d.ts +93 -0
- package/dist/core/tools/webtools-shared.d.ts.map +1 -0
- package/dist/core/tools/webtools-shared.js +144 -0
- package/dist/core/tools/webtools-shared.js.map +1 -0
- package/dist/extensions/core/hoo-core.d.ts.map +1 -1
- package/dist/extensions/core/hoo-core.js +23 -1
- package/dist/extensions/core/hoo-core.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +6 -0
- package/dist/main.js.map +1 -1
- package/dist/utils/tools-manager.d.ts +4 -2
- package/dist/utils/tools-manager.d.ts.map +1 -1
- package/dist/utils/tools-manager.js +23 -0
- package/dist/utils/tools-manager.js.map +1 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AgentTool } from "@kolisachint/hoocode-agent-core";
|
|
2
|
+
import { type Static, Type } from "typebox";
|
|
3
|
+
import type { ToolDefinition } from "../extensions/types.js";
|
|
4
|
+
import { type WebFetchResult, WebToolsCache } from "./webtools-shared.js";
|
|
5
|
+
declare const webfetchSchema: Type.TObject<{
|
|
6
|
+
url: Type.TString;
|
|
7
|
+
maxTokens: Type.TOptional<Type.TNumber>;
|
|
8
|
+
output: Type.TOptional<Type.TUnion<[Type.TLiteral<"text">, Type.TLiteral<"markdown">]>>;
|
|
9
|
+
}>;
|
|
10
|
+
export type WebFetchToolInput = Static<typeof webfetchSchema>;
|
|
11
|
+
export interface WebFetchToolDetails {
|
|
12
|
+
finalUrl?: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
tokenEstimate?: number;
|
|
15
|
+
contentType?: string;
|
|
16
|
+
media?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface WebFetchToolOptions {
|
|
19
|
+
/** Override the result cache (mainly for tests). */
|
|
20
|
+
cache?: WebToolsCache<WebFetchResult>;
|
|
21
|
+
}
|
|
22
|
+
export declare function createWebFetchToolDefinition(cwd: string, options?: WebFetchToolOptions): ToolDefinition<typeof webfetchSchema, WebFetchToolDetails | undefined>;
|
|
23
|
+
export declare function createWebFetchTool(cwd: string, options?: WebFetchToolOptions): AgentTool<typeof webfetchSchema>;
|
|
24
|
+
export {};
|
|
25
|
+
//# sourceMappingURL=webfetch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webfetch.d.ts","sourceRoot":"","sources":["../../../src/core/tools/webfetch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iCAAiC,CAAC;AAEjE,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAG5C,OAAO,KAAK,EAAE,cAAc,EAA2B,MAAM,wBAAwB,CAAC;AAGtF,OAAO,EAIN,KAAK,cAAc,EACnB,aAAa,EACb,MAAM,sBAAsB,CAAC;AAI9B,QAAA,MAAM,cAAc;;;;EAalB,CAAC;AAEH,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC,OAAO,cAAc,CAAC,CAAC;AAE9D,MAAM,WAAW,mBAAmB;IACnC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,mBAAmB;IACnC,oDAAoD;IACpD,KAAK,CAAC,EAAE,aAAa,CAAC,cAAc,CAAC,CAAC;CACtC;AAiCD,wBAAgB,4BAA4B,CAC3C,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,mBAAmB,GAC3B,cAAc,CAAC,OAAO,cAAc,EAAE,mBAAmB,GAAG,SAAS,CAAC,CA2DxE;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,SAAS,CAAC,OAAO,cAAc,CAAC,CAE/G","sourcesContent":["import type { AgentTool } from \"@kolisachint/hoocode-agent-core\";\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { keyHint } from \"../../modes/interactive/components/keybinding-hints.js\";\nimport { theme as appTheme } from \"../../modes/interactive/theme/theme.js\";\nimport type { ToolDefinition, ToolRenderResultOptions } from \"../extensions/types.js\";\nimport { getTextOutput, invalidArgText, str } from \"./render-utils.js\";\nimport { wrapToolDefinition } from \"./tool-definition-wrapper.js\";\nimport {\n\tblockedHostForUrl,\n\trunWebtools,\n\tWEBTOOLS_DEFAULT_TIMEOUT_SECS,\n\ttype WebFetchResult,\n\tWebToolsCache,\n} from \"./webtools-shared.js\";\n\nconst DEFAULT_MAX_TOKENS = 4000;\n\nconst webfetchSchema = Type.Object({\n\turl: Type.String({ description: \"The URL to fetch (http or https)\" }),\n\tmaxTokens: Type.Optional(\n\t\tType.Number({\n\t\t\tdescription: `Soft cap on returned output size in estimated tokens (default: ${DEFAULT_MAX_TOKENS})`,\n\t\t}),\n\t),\n\toutput: Type.Optional(\n\t\tType.Union([Type.Literal(\"text\"), Type.Literal(\"markdown\")], {\n\t\t\tdescription:\n\t\t\t\t\"Output format: 'text' (default, most token-efficient, links as [N] with a trailing reference block) or 'markdown' (inline links).\",\n\t\t}),\n\t),\n});\n\nexport type WebFetchToolInput = Static<typeof webfetchSchema>;\n\nexport interface WebFetchToolDetails {\n\tfinalUrl?: string;\n\ttitle?: string;\n\ttokenEstimate?: number;\n\tcontentType?: string;\n\tmedia?: string;\n}\n\nexport interface WebFetchToolOptions {\n\t/** Override the result cache (mainly for tests). */\n\tcache?: WebToolsCache<WebFetchResult>;\n}\n\nfunction formatWebfetchCall(args: { url?: string; output?: string } | undefined): string {\n\tconst url = str(args?.url);\n\tconst urlDisplay = url === null ? invalidArgText(appTheme) : url ? url : appTheme.fg(\"toolOutput\", \"...\");\n\tconst format = args?.output === \"markdown\" ? appTheme.fg(\"muted\", \" (markdown)\") : \"\";\n\treturn appTheme.fg(\"toolTitle\", appTheme.bold(\"webfetch \")) + appTheme.fg(\"accent\", urlDisplay) + format;\n}\n\nfunction formatWebfetchResult(\n\tresult: { content: Array<{ type: string; text?: string }>; details?: WebFetchToolDetails },\n\toptions: ToolRenderResultOptions,\n\tshowImages: boolean,\n): string {\n\tconst output = getTextOutput(result as any, showImages).trim();\n\tlet text = \"\";\n\tif (output) {\n\t\tconst lines = output.split(\"\\n\");\n\t\tconst maxLines = options.expanded ? lines.length : 15;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n${displayLines.map((line) => appTheme.fg(\"toolOutput\", line)).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${appTheme.fg(\"muted\", `\\n... (${remaining} more lines,`)} ${keyHint(\"app.tools.expand\", \"to expand\")})`;\n\t\t}\n\t}\n\tconst tokenEstimate = result.details?.tokenEstimate;\n\tif (tokenEstimate !== undefined) {\n\t\ttext += `\\n${appTheme.fg(\"muted\", `~${tokenEstimate} tokens`)}`;\n\t}\n\treturn text;\n}\n\nexport function createWebFetchToolDefinition(\n\tcwd: string,\n\toptions?: WebFetchToolOptions,\n): ToolDefinition<typeof webfetchSchema, WebFetchToolDetails | undefined> {\n\tconst cache = options?.cache ?? new WebToolsCache<WebFetchResult>();\n\treturn {\n\t\tname: \"webfetch\",\n\t\tlabel: \"webfetch\",\n\t\tdescription:\n\t\t\t\"Fetch a web page (or JSON/text resource) and return token-efficient, reference-style content. HTML is extracted to clean text; links become inline [N] markers with full URLs in a trailing reference block. Returns title, final URL (after redirects), and an estimated token count. Off by default; enabled with --enable-webtools.\",\n\t\tpromptSnippet: \"Fetch a URL and return clean, token-efficient page content\",\n\t\tpromptGuidelines: [\n\t\t\t\"Use webfetch to read a known URL instead of bash curl/wget; it returns clean extracted text with reference-style [N] links, not raw HTML.\",\n\t\t],\n\t\tparameters: webfetchSchema,\n\t\t// External network call with variable latency: run non-blocking so the\n\t\t// agent keeps reasoning while the page is fetched.\n\t\tbackground: true,\n\t\tasync execute(_toolCallId, { url, maxTokens, output }: WebFetchToolInput, signal?: AbortSignal) {\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\t// Policy gate (.webtoolsignore). SSRF/private-address blocking lives in\n\t\t\t// the binary; this is host-level allow/deny policy only.\n\t\t\tconst blockedHost = blockedHostForUrl(cwd, url);\n\t\t\tif (blockedHost) {\n\t\t\t\tthrow new Error(`Blocked by .webtoolsignore policy: ${blockedHost}`);\n\t\t\t}\n\n\t\t\tconst effectiveMaxTokens = maxTokens && maxTokens > 0 ? maxTokens : DEFAULT_MAX_TOKENS;\n\t\t\tconst format = output ?? \"text\";\n\t\t\tconst cacheKey = `${format}:${effectiveMaxTokens}:${url}`;\n\n\t\t\tlet result = cache.get(cacheKey);\n\t\t\tif (!result) {\n\t\t\t\tconst args = [\"--url\", url, \"--max-tokens\", String(effectiveMaxTokens), \"--output\", format];\n\t\t\t\tresult = await runWebtools<WebFetchResult>(\"fetch\", args, cwd, signal, WEBTOOLS_DEFAULT_TIMEOUT_SECS);\n\t\t\t\tcache.set(cacheKey, result);\n\t\t\t}\n\n\t\t\tconst header = result.title ? `${result.title}\\n${result.final_url}\\n\\n` : `${result.final_url}\\n\\n`;\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text: header + result.content }],\n\t\t\t\tdetails: {\n\t\t\t\t\tfinalUrl: result.final_url,\n\t\t\t\t\ttitle: result.title,\n\t\t\t\t\ttokenEstimate: result.token_estimate,\n\t\t\t\t\tcontentType: result.content_type,\n\t\t\t\t\tmedia: result.media,\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t\trenderCall(args, _theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatWebfetchCall(args));\n\t\t\treturn text;\n\t\t},\n\t\trenderResult(result, options, _theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatWebfetchResult(result as any, options, context.showImages));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n\nexport function createWebFetchTool(cwd: string, options?: WebFetchToolOptions): AgentTool<typeof webfetchSchema> {\n\treturn wrapToolDefinition(createWebFetchToolDefinition(cwd, options));\n}\n"]}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Text } from "@kolisachint/hoocode-tui";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
|
|
4
|
+
import { theme as appTheme } from "../../modes/interactive/theme/theme.js";
|
|
5
|
+
import { getTextOutput, invalidArgText, str } from "./render-utils.js";
|
|
6
|
+
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
|
|
7
|
+
import { blockedHostForUrl, runWebtools, WEBTOOLS_DEFAULT_TIMEOUT_SECS, WebToolsCache, } from "./webtools-shared.js";
|
|
8
|
+
const DEFAULT_MAX_TOKENS = 4000;
|
|
9
|
+
const webfetchSchema = Type.Object({
|
|
10
|
+
url: Type.String({ description: "The URL to fetch (http or https)" }),
|
|
11
|
+
maxTokens: Type.Optional(Type.Number({
|
|
12
|
+
description: `Soft cap on returned output size in estimated tokens (default: ${DEFAULT_MAX_TOKENS})`,
|
|
13
|
+
})),
|
|
14
|
+
output: Type.Optional(Type.Union([Type.Literal("text"), Type.Literal("markdown")], {
|
|
15
|
+
description: "Output format: 'text' (default, most token-efficient, links as [N] with a trailing reference block) or 'markdown' (inline links).",
|
|
16
|
+
})),
|
|
17
|
+
});
|
|
18
|
+
function formatWebfetchCall(args) {
|
|
19
|
+
const url = str(args?.url);
|
|
20
|
+
const urlDisplay = url === null ? invalidArgText(appTheme) : url ? url : appTheme.fg("toolOutput", "...");
|
|
21
|
+
const format = args?.output === "markdown" ? appTheme.fg("muted", " (markdown)") : "";
|
|
22
|
+
return appTheme.fg("toolTitle", appTheme.bold("webfetch ")) + appTheme.fg("accent", urlDisplay) + format;
|
|
23
|
+
}
|
|
24
|
+
function formatWebfetchResult(result, options, showImages) {
|
|
25
|
+
const output = getTextOutput(result, showImages).trim();
|
|
26
|
+
let text = "";
|
|
27
|
+
if (output) {
|
|
28
|
+
const lines = output.split("\n");
|
|
29
|
+
const maxLines = options.expanded ? lines.length : 15;
|
|
30
|
+
const displayLines = lines.slice(0, maxLines);
|
|
31
|
+
const remaining = lines.length - maxLines;
|
|
32
|
+
text += `\n${displayLines.map((line) => appTheme.fg("toolOutput", line)).join("\n")}`;
|
|
33
|
+
if (remaining > 0) {
|
|
34
|
+
text += `${appTheme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const tokenEstimate = result.details?.tokenEstimate;
|
|
38
|
+
if (tokenEstimate !== undefined) {
|
|
39
|
+
text += `\n${appTheme.fg("muted", `~${tokenEstimate} tokens`)}`;
|
|
40
|
+
}
|
|
41
|
+
return text;
|
|
42
|
+
}
|
|
43
|
+
export function createWebFetchToolDefinition(cwd, options) {
|
|
44
|
+
const cache = options?.cache ?? new WebToolsCache();
|
|
45
|
+
return {
|
|
46
|
+
name: "webfetch",
|
|
47
|
+
label: "webfetch",
|
|
48
|
+
description: "Fetch a web page (or JSON/text resource) and return token-efficient, reference-style content. HTML is extracted to clean text; links become inline [N] markers with full URLs in a trailing reference block. Returns title, final URL (after redirects), and an estimated token count. Off by default; enabled with --enable-webtools.",
|
|
49
|
+
promptSnippet: "Fetch a URL and return clean, token-efficient page content",
|
|
50
|
+
promptGuidelines: [
|
|
51
|
+
"Use webfetch to read a known URL instead of bash curl/wget; it returns clean extracted text with reference-style [N] links, not raw HTML.",
|
|
52
|
+
],
|
|
53
|
+
parameters: webfetchSchema,
|
|
54
|
+
// External network call with variable latency: run non-blocking so the
|
|
55
|
+
// agent keeps reasoning while the page is fetched.
|
|
56
|
+
background: true,
|
|
57
|
+
async execute(_toolCallId, { url, maxTokens, output }, signal) {
|
|
58
|
+
if (signal?.aborted)
|
|
59
|
+
throw new Error("Operation aborted");
|
|
60
|
+
// Policy gate (.webtoolsignore). SSRF/private-address blocking lives in
|
|
61
|
+
// the binary; this is host-level allow/deny policy only.
|
|
62
|
+
const blockedHost = blockedHostForUrl(cwd, url);
|
|
63
|
+
if (blockedHost) {
|
|
64
|
+
throw new Error(`Blocked by .webtoolsignore policy: ${blockedHost}`);
|
|
65
|
+
}
|
|
66
|
+
const effectiveMaxTokens = maxTokens && maxTokens > 0 ? maxTokens : DEFAULT_MAX_TOKENS;
|
|
67
|
+
const format = output ?? "text";
|
|
68
|
+
const cacheKey = `${format}:${effectiveMaxTokens}:${url}`;
|
|
69
|
+
let result = cache.get(cacheKey);
|
|
70
|
+
if (!result) {
|
|
71
|
+
const args = ["--url", url, "--max-tokens", String(effectiveMaxTokens), "--output", format];
|
|
72
|
+
result = await runWebtools("fetch", args, cwd, signal, WEBTOOLS_DEFAULT_TIMEOUT_SECS);
|
|
73
|
+
cache.set(cacheKey, result);
|
|
74
|
+
}
|
|
75
|
+
const header = result.title ? `${result.title}\n${result.final_url}\n\n` : `${result.final_url}\n\n`;
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: "text", text: header + result.content }],
|
|
78
|
+
details: {
|
|
79
|
+
finalUrl: result.final_url,
|
|
80
|
+
title: result.title,
|
|
81
|
+
tokenEstimate: result.token_estimate,
|
|
82
|
+
contentType: result.content_type,
|
|
83
|
+
media: result.media,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
renderCall(args, _theme, context) {
|
|
88
|
+
const text = context.lastComponent ?? new Text("", 0, 0);
|
|
89
|
+
text.setText(formatWebfetchCall(args));
|
|
90
|
+
return text;
|
|
91
|
+
},
|
|
92
|
+
renderResult(result, options, _theme, context) {
|
|
93
|
+
const text = context.lastComponent ?? new Text("", 0, 0);
|
|
94
|
+
text.setText(formatWebfetchResult(result, options, context.showImages));
|
|
95
|
+
return text;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export function createWebFetchTool(cwd, options) {
|
|
100
|
+
return wrapToolDefinition(createWebFetchToolDefinition(cwd, options));
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=webfetch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webfetch.js","sourceRoot":"","sources":["../../../src/core/tools/webfetch.ts"],"names":[],"mappings":"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","sourcesContent":["import type { AgentTool } from \"@kolisachint/hoocode-agent-core\";\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { keyHint } from \"../../modes/interactive/components/keybinding-hints.js\";\nimport { theme as appTheme } from \"../../modes/interactive/theme/theme.js\";\nimport type { ToolDefinition, ToolRenderResultOptions } from \"../extensions/types.js\";\nimport { getTextOutput, invalidArgText, str } from \"./render-utils.js\";\nimport { wrapToolDefinition } from \"./tool-definition-wrapper.js\";\nimport {\n\tblockedHostForUrl,\n\trunWebtools,\n\tWEBTOOLS_DEFAULT_TIMEOUT_SECS,\n\ttype WebFetchResult,\n\tWebToolsCache,\n} from \"./webtools-shared.js\";\n\nconst DEFAULT_MAX_TOKENS = 4000;\n\nconst webfetchSchema = Type.Object({\n\turl: Type.String({ description: \"The URL to fetch (http or https)\" }),\n\tmaxTokens: Type.Optional(\n\t\tType.Number({\n\t\t\tdescription: `Soft cap on returned output size in estimated tokens (default: ${DEFAULT_MAX_TOKENS})`,\n\t\t}),\n\t),\n\toutput: Type.Optional(\n\t\tType.Union([Type.Literal(\"text\"), Type.Literal(\"markdown\")], {\n\t\t\tdescription:\n\t\t\t\t\"Output format: 'text' (default, most token-efficient, links as [N] with a trailing reference block) or 'markdown' (inline links).\",\n\t\t}),\n\t),\n});\n\nexport type WebFetchToolInput = Static<typeof webfetchSchema>;\n\nexport interface WebFetchToolDetails {\n\tfinalUrl?: string;\n\ttitle?: string;\n\ttokenEstimate?: number;\n\tcontentType?: string;\n\tmedia?: string;\n}\n\nexport interface WebFetchToolOptions {\n\t/** Override the result cache (mainly for tests). */\n\tcache?: WebToolsCache<WebFetchResult>;\n}\n\nfunction formatWebfetchCall(args: { url?: string; output?: string } | undefined): string {\n\tconst url = str(args?.url);\n\tconst urlDisplay = url === null ? invalidArgText(appTheme) : url ? url : appTheme.fg(\"toolOutput\", \"...\");\n\tconst format = args?.output === \"markdown\" ? appTheme.fg(\"muted\", \" (markdown)\") : \"\";\n\treturn appTheme.fg(\"toolTitle\", appTheme.bold(\"webfetch \")) + appTheme.fg(\"accent\", urlDisplay) + format;\n}\n\nfunction formatWebfetchResult(\n\tresult: { content: Array<{ type: string; text?: string }>; details?: WebFetchToolDetails },\n\toptions: ToolRenderResultOptions,\n\tshowImages: boolean,\n): string {\n\tconst output = getTextOutput(result as any, showImages).trim();\n\tlet text = \"\";\n\tif (output) {\n\t\tconst lines = output.split(\"\\n\");\n\t\tconst maxLines = options.expanded ? lines.length : 15;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n${displayLines.map((line) => appTheme.fg(\"toolOutput\", line)).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${appTheme.fg(\"muted\", `\\n... (${remaining} more lines,`)} ${keyHint(\"app.tools.expand\", \"to expand\")})`;\n\t\t}\n\t}\n\tconst tokenEstimate = result.details?.tokenEstimate;\n\tif (tokenEstimate !== undefined) {\n\t\ttext += `\\n${appTheme.fg(\"muted\", `~${tokenEstimate} tokens`)}`;\n\t}\n\treturn text;\n}\n\nexport function createWebFetchToolDefinition(\n\tcwd: string,\n\toptions?: WebFetchToolOptions,\n): ToolDefinition<typeof webfetchSchema, WebFetchToolDetails | undefined> {\n\tconst cache = options?.cache ?? new WebToolsCache<WebFetchResult>();\n\treturn {\n\t\tname: \"webfetch\",\n\t\tlabel: \"webfetch\",\n\t\tdescription:\n\t\t\t\"Fetch a web page (or JSON/text resource) and return token-efficient, reference-style content. HTML is extracted to clean text; links become inline [N] markers with full URLs in a trailing reference block. Returns title, final URL (after redirects), and an estimated token count. Off by default; enabled with --enable-webtools.\",\n\t\tpromptSnippet: \"Fetch a URL and return clean, token-efficient page content\",\n\t\tpromptGuidelines: [\n\t\t\t\"Use webfetch to read a known URL instead of bash curl/wget; it returns clean extracted text with reference-style [N] links, not raw HTML.\",\n\t\t],\n\t\tparameters: webfetchSchema,\n\t\t// External network call with variable latency: run non-blocking so the\n\t\t// agent keeps reasoning while the page is fetched.\n\t\tbackground: true,\n\t\tasync execute(_toolCallId, { url, maxTokens, output }: WebFetchToolInput, signal?: AbortSignal) {\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\t// Policy gate (.webtoolsignore). SSRF/private-address blocking lives in\n\t\t\t// the binary; this is host-level allow/deny policy only.\n\t\t\tconst blockedHost = blockedHostForUrl(cwd, url);\n\t\t\tif (blockedHost) {\n\t\t\t\tthrow new Error(`Blocked by .webtoolsignore policy: ${blockedHost}`);\n\t\t\t}\n\n\t\t\tconst effectiveMaxTokens = maxTokens && maxTokens > 0 ? maxTokens : DEFAULT_MAX_TOKENS;\n\t\t\tconst format = output ?? \"text\";\n\t\t\tconst cacheKey = `${format}:${effectiveMaxTokens}:${url}`;\n\n\t\t\tlet result = cache.get(cacheKey);\n\t\t\tif (!result) {\n\t\t\t\tconst args = [\"--url\", url, \"--max-tokens\", String(effectiveMaxTokens), \"--output\", format];\n\t\t\t\tresult = await runWebtools<WebFetchResult>(\"fetch\", args, cwd, signal, WEBTOOLS_DEFAULT_TIMEOUT_SECS);\n\t\t\t\tcache.set(cacheKey, result);\n\t\t\t}\n\n\t\t\tconst header = result.title ? `${result.title}\\n${result.final_url}\\n\\n` : `${result.final_url}\\n\\n`;\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text: header + result.content }],\n\t\t\t\tdetails: {\n\t\t\t\t\tfinalUrl: result.final_url,\n\t\t\t\t\ttitle: result.title,\n\t\t\t\t\ttokenEstimate: result.token_estimate,\n\t\t\t\t\tcontentType: result.content_type,\n\t\t\t\t\tmedia: result.media,\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t\trenderCall(args, _theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatWebfetchCall(args));\n\t\t\treturn text;\n\t\t},\n\t\trenderResult(result, options, _theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatWebfetchResult(result as any, options, context.showImages));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n\nexport function createWebFetchTool(cwd: string, options?: WebFetchToolOptions): AgentTool<typeof webfetchSchema> {\n\treturn wrapToolDefinition(createWebFetchToolDefinition(cwd, options));\n}\n"]}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AgentTool } from "@kolisachint/hoocode-agent-core";
|
|
2
|
+
import { type Static, Type } from "typebox";
|
|
3
|
+
import type { ToolDefinition } from "../extensions/types.js";
|
|
4
|
+
import { type WebSearchOutput, WebToolsCache } from "./webtools-shared.js";
|
|
5
|
+
declare const websearchSchema: Type.TObject<{
|
|
6
|
+
query: Type.TString;
|
|
7
|
+
maxResults: Type.TOptional<Type.TNumber>;
|
|
8
|
+
safeSearch: Type.TOptional<Type.TUnion<[Type.TLiteral<"on">, Type.TLiteral<"off">]>>;
|
|
9
|
+
}>;
|
|
10
|
+
export type WebSearchToolInput = Static<typeof websearchSchema>;
|
|
11
|
+
export interface WebSearchToolDetails {
|
|
12
|
+
resultCount?: number;
|
|
13
|
+
hiddenCount?: number;
|
|
14
|
+
tokenEstimate?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface WebSearchToolOptions {
|
|
17
|
+
/** Override the result cache (mainly for tests). */
|
|
18
|
+
cache?: WebToolsCache<WebSearchOutput>;
|
|
19
|
+
}
|
|
20
|
+
export declare function createWebSearchToolDefinition(cwd: string, options?: WebSearchToolOptions): ToolDefinition<typeof websearchSchema, WebSearchToolDetails | undefined>;
|
|
21
|
+
export declare function createWebSearchTool(cwd: string, options?: WebSearchToolOptions): AgentTool<typeof websearchSchema>;
|
|
22
|
+
export {};
|
|
23
|
+
//# sourceMappingURL=websearch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"websearch.d.ts","sourceRoot":"","sources":["../../../src/core/tools/websearch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iCAAiC,CAAC;AAEjE,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAG5C,OAAO,KAAK,EAAE,cAAc,EAA2B,MAAM,wBAAwB,CAAC;AAGtF,OAAO,EAMN,KAAK,eAAe,EAEpB,aAAa,EACb,MAAM,sBAAsB,CAAC;AAK9B,QAAA,MAAM,eAAe;;;;EAYnB,CAAC;AAEH,MAAM,MAAM,kBAAkB,GAAG,MAAM,CAAC,OAAO,eAAe,CAAC,CAAC;AAEhE,MAAM,WAAW,oBAAoB;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,oBAAoB;IACpC,oDAAoD;IACpD,KAAK,CAAC,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;CACvC;AAwDD,wBAAgB,6BAA6B,CAC5C,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,oBAAoB,GAC5B,cAAc,CAAC,OAAO,eAAe,EAAE,oBAAoB,GAAG,SAAS,CAAC,CA2D1E;AAED,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,oBAAoB,GAAG,SAAS,CAAC,OAAO,eAAe,CAAC,CAElH","sourcesContent":["import type { AgentTool } from \"@kolisachint/hoocode-agent-core\";\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { keyHint } from \"../../modes/interactive/components/keybinding-hints.js\";\nimport { theme as appTheme } from \"../../modes/interactive/theme/theme.js\";\nimport type { ToolDefinition, ToolRenderResultOptions } from \"../extensions/types.js\";\nimport { getTextOutput, invalidArgText, str } from \"./render-utils.js\";\nimport { wrapToolDefinition } from \"./tool-definition-wrapper.js\";\nimport {\n\thostnameOf,\n\tisHostBlocked,\n\tloadWebtoolsIgnore,\n\trunWebtools,\n\tWEBTOOLS_DEFAULT_TIMEOUT_SECS,\n\ttype WebSearchOutput,\n\ttype WebSearchResultItem,\n\tWebToolsCache,\n} from \"./webtools-shared.js\";\n\nconst DEFAULT_MAX_RESULTS = 5;\nconst MAX_RESULTS_CAP = 10;\n\nconst websearchSchema = Type.Object({\n\tquery: Type.String({ description: \"The search query\" }),\n\tmaxResults: Type.Optional(\n\t\tType.Number({\n\t\t\tdescription: `Maximum number of results to return (default: ${DEFAULT_MAX_RESULTS}, max: ${MAX_RESULTS_CAP})`,\n\t\t}),\n\t),\n\tsafeSearch: Type.Optional(\n\t\tType.Union([Type.Literal(\"on\"), Type.Literal(\"off\")], {\n\t\t\tdescription: \"Safe search filter. Omit to use the search engine's default.\",\n\t\t}),\n\t),\n});\n\nexport type WebSearchToolInput = Static<typeof websearchSchema>;\n\nexport interface WebSearchToolDetails {\n\tresultCount?: number;\n\thiddenCount?: number;\n\ttokenEstimate?: number;\n}\n\nexport interface WebSearchToolOptions {\n\t/** Override the result cache (mainly for tests). */\n\tcache?: WebToolsCache<WebSearchOutput>;\n}\n\n/**\n * Render the kept results in reference style: title + snippet with a trailing\n * [N] marker, then a `References:` block. Indices are renumbered after filtering\n * so the visible list stays contiguous.\n */\nfunction renderSearchText(query: string, kept: WebSearchResultItem[], hiddenCount: number): string {\n\tif (kept.length === 0) {\n\t\tconst suffix = hiddenCount > 0 ? ` (${hiddenCount} blocked by .webtoolsignore policy)` : \"\";\n\t\treturn `No results for \"${query}\"${suffix}`;\n\t}\n\tconst blocks: string[] = [];\n\tconst refs: string[] = [];\n\tkept.forEach((item, i) => {\n\t\tconst n = i + 1;\n\t\tblocks.push(`${item.title} [${n}]\\n${item.snippet}`);\n\t\trefs.push(`[${n}] ${item.url}`);\n\t});\n\tlet text = `${blocks.join(\"\\n\\n\")}\\n\\nReferences:\\n${refs.join(\"\\n\")}`;\n\tif (hiddenCount > 0) {\n\t\ttext += `\\n\\n[${hiddenCount} result${hiddenCount === 1 ? \"\" : \"s\"} hidden by .webtoolsignore policy]`;\n\t}\n\treturn text;\n}\n\nfunction formatWebsearchCall(args: { query?: string; maxResults?: number } | undefined): string {\n\tconst query = str(args?.query);\n\tconst queryDisplay =\n\t\tquery === null ? invalidArgText(appTheme) : query ? `\"${query}\"` : appTheme.fg(\"toolOutput\", \"...\");\n\tconst limit = args?.maxResults;\n\tlet text = appTheme.fg(\"toolTitle\", appTheme.bold(\"websearch \")) + appTheme.fg(\"accent\", queryDisplay);\n\tif (limit !== undefined) text += appTheme.fg(\"muted\", ` (${limit})`);\n\treturn text;\n}\n\nfunction formatWebsearchResult(\n\tresult: { content: Array<{ type: string; text?: string }>; details?: WebSearchToolDetails },\n\toptions: ToolRenderResultOptions,\n\tshowImages: boolean,\n): string {\n\tconst output = getTextOutput(result as any, showImages).trim();\n\tlet text = \"\";\n\tif (output) {\n\t\tconst lines = output.split(\"\\n\");\n\t\tconst maxLines = options.expanded ? lines.length : 15;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n${displayLines.map((line) => appTheme.fg(\"toolOutput\", line)).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${appTheme.fg(\"muted\", `\\n... (${remaining} more lines,`)} ${keyHint(\"app.tools.expand\", \"to expand\")})`;\n\t\t}\n\t}\n\treturn text;\n}\n\nexport function createWebSearchToolDefinition(\n\tcwd: string,\n\toptions?: WebSearchToolOptions,\n): ToolDefinition<typeof websearchSchema, WebSearchToolDetails | undefined> {\n\tconst cache = options?.cache ?? new WebToolsCache<WebSearchOutput>();\n\treturn {\n\t\tname: \"websearch\",\n\t\tlabel: \"websearch\",\n\t\tdescription:\n\t\t\t\"Search the web (DuckDuckGo, no API key) and return ranked results as titles + snippets with reference-style [N] links. Use to discover URLs, then webfetch to read a result in full. Off by default; enabled with --enable-webtools.\",\n\t\tpromptSnippet: \"Search the web and return ranked results with links\",\n\t\tpromptGuidelines: [\n\t\t\t\"Use websearch to discover URLs when you do not already have one, then webfetch the most relevant result to read it in full.\",\n\t\t],\n\t\tparameters: websearchSchema,\n\t\t// External network call with variable latency: run non-blocking.\n\t\tbackground: true,\n\t\tasync execute(_toolCallId, { query, maxResults, safeSearch }: WebSearchToolInput, signal?: AbortSignal) {\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst effectiveMax = Math.min(MAX_RESULTS_CAP, Math.max(1, maxResults ?? DEFAULT_MAX_RESULTS));\n\t\t\tconst cacheKey = `${effectiveMax}:${safeSearch ?? \"default\"}:${query}`;\n\n\t\t\tlet output = cache.get(cacheKey);\n\t\t\tif (!output) {\n\t\t\t\tconst args = [\"--query\", query, \"--max-results\", String(effectiveMax)];\n\t\t\t\tif (safeSearch) args.push(\"--safe-search\", safeSearch);\n\t\t\t\toutput = await runWebtools<WebSearchOutput>(\"search\", args, cwd, signal, WEBTOOLS_DEFAULT_TIMEOUT_SECS);\n\t\t\t\tcache.set(cacheKey, output);\n\t\t\t}\n\n\t\t\t// Filter result links through .webtoolsignore policy.\n\t\t\tconst matcher = loadWebtoolsIgnore(cwd);\n\t\t\tconst allResults = output.results ?? [];\n\t\t\tconst kept = matcher\n\t\t\t\t? allResults.filter((item) => {\n\t\t\t\t\t\tconst host = hostnameOf(item.url);\n\t\t\t\t\t\treturn host ? !isHostBlocked(matcher, host) : true;\n\t\t\t\t\t})\n\t\t\t\t: allResults;\n\t\t\tconst hiddenCount = allResults.length - kept.length;\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text: renderSearchText(query, kept, hiddenCount) }],\n\t\t\t\tdetails: {\n\t\t\t\t\tresultCount: kept.length,\n\t\t\t\t\thiddenCount,\n\t\t\t\t\ttokenEstimate: output.token_estimate,\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t\trenderCall(args, _theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatWebsearchCall(args));\n\t\t\treturn text;\n\t\t},\n\t\trenderResult(result, options, _theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatWebsearchResult(result as any, options, context.showImages));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n\nexport function createWebSearchTool(cwd: string, options?: WebSearchToolOptions): AgentTool<typeof websearchSchema> {\n\treturn wrapToolDefinition(createWebSearchToolDefinition(cwd, options));\n}\n"]}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Text } from "@kolisachint/hoocode-tui";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
|
|
4
|
+
import { theme as appTheme } from "../../modes/interactive/theme/theme.js";
|
|
5
|
+
import { getTextOutput, invalidArgText, str } from "./render-utils.js";
|
|
6
|
+
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
|
|
7
|
+
import { hostnameOf, isHostBlocked, loadWebtoolsIgnore, runWebtools, WEBTOOLS_DEFAULT_TIMEOUT_SECS, WebToolsCache, } from "./webtools-shared.js";
|
|
8
|
+
const DEFAULT_MAX_RESULTS = 5;
|
|
9
|
+
const MAX_RESULTS_CAP = 10;
|
|
10
|
+
const websearchSchema = Type.Object({
|
|
11
|
+
query: Type.String({ description: "The search query" }),
|
|
12
|
+
maxResults: Type.Optional(Type.Number({
|
|
13
|
+
description: `Maximum number of results to return (default: ${DEFAULT_MAX_RESULTS}, max: ${MAX_RESULTS_CAP})`,
|
|
14
|
+
})),
|
|
15
|
+
safeSearch: Type.Optional(Type.Union([Type.Literal("on"), Type.Literal("off")], {
|
|
16
|
+
description: "Safe search filter. Omit to use the search engine's default.",
|
|
17
|
+
})),
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* Render the kept results in reference style: title + snippet with a trailing
|
|
21
|
+
* [N] marker, then a `References:` block. Indices are renumbered after filtering
|
|
22
|
+
* so the visible list stays contiguous.
|
|
23
|
+
*/
|
|
24
|
+
function renderSearchText(query, kept, hiddenCount) {
|
|
25
|
+
if (kept.length === 0) {
|
|
26
|
+
const suffix = hiddenCount > 0 ? ` (${hiddenCount} blocked by .webtoolsignore policy)` : "";
|
|
27
|
+
return `No results for "${query}"${suffix}`;
|
|
28
|
+
}
|
|
29
|
+
const blocks = [];
|
|
30
|
+
const refs = [];
|
|
31
|
+
kept.forEach((item, i) => {
|
|
32
|
+
const n = i + 1;
|
|
33
|
+
blocks.push(`${item.title} [${n}]\n${item.snippet}`);
|
|
34
|
+
refs.push(`[${n}] ${item.url}`);
|
|
35
|
+
});
|
|
36
|
+
let text = `${blocks.join("\n\n")}\n\nReferences:\n${refs.join("\n")}`;
|
|
37
|
+
if (hiddenCount > 0) {
|
|
38
|
+
text += `\n\n[${hiddenCount} result${hiddenCount === 1 ? "" : "s"} hidden by .webtoolsignore policy]`;
|
|
39
|
+
}
|
|
40
|
+
return text;
|
|
41
|
+
}
|
|
42
|
+
function formatWebsearchCall(args) {
|
|
43
|
+
const query = str(args?.query);
|
|
44
|
+
const queryDisplay = query === null ? invalidArgText(appTheme) : query ? `"${query}"` : appTheme.fg("toolOutput", "...");
|
|
45
|
+
const limit = args?.maxResults;
|
|
46
|
+
let text = appTheme.fg("toolTitle", appTheme.bold("websearch ")) + appTheme.fg("accent", queryDisplay);
|
|
47
|
+
if (limit !== undefined)
|
|
48
|
+
text += appTheme.fg("muted", ` (${limit})`);
|
|
49
|
+
return text;
|
|
50
|
+
}
|
|
51
|
+
function formatWebsearchResult(result, options, showImages) {
|
|
52
|
+
const output = getTextOutput(result, showImages).trim();
|
|
53
|
+
let text = "";
|
|
54
|
+
if (output) {
|
|
55
|
+
const lines = output.split("\n");
|
|
56
|
+
const maxLines = options.expanded ? lines.length : 15;
|
|
57
|
+
const displayLines = lines.slice(0, maxLines);
|
|
58
|
+
const remaining = lines.length - maxLines;
|
|
59
|
+
text += `\n${displayLines.map((line) => appTheme.fg("toolOutput", line)).join("\n")}`;
|
|
60
|
+
if (remaining > 0) {
|
|
61
|
+
text += `${appTheme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return text;
|
|
65
|
+
}
|
|
66
|
+
export function createWebSearchToolDefinition(cwd, options) {
|
|
67
|
+
const cache = options?.cache ?? new WebToolsCache();
|
|
68
|
+
return {
|
|
69
|
+
name: "websearch",
|
|
70
|
+
label: "websearch",
|
|
71
|
+
description: "Search the web (DuckDuckGo, no API key) and return ranked results as titles + snippets with reference-style [N] links. Use to discover URLs, then webfetch to read a result in full. Off by default; enabled with --enable-webtools.",
|
|
72
|
+
promptSnippet: "Search the web and return ranked results with links",
|
|
73
|
+
promptGuidelines: [
|
|
74
|
+
"Use websearch to discover URLs when you do not already have one, then webfetch the most relevant result to read it in full.",
|
|
75
|
+
],
|
|
76
|
+
parameters: websearchSchema,
|
|
77
|
+
// External network call with variable latency: run non-blocking.
|
|
78
|
+
background: true,
|
|
79
|
+
async execute(_toolCallId, { query, maxResults, safeSearch }, signal) {
|
|
80
|
+
if (signal?.aborted)
|
|
81
|
+
throw new Error("Operation aborted");
|
|
82
|
+
const effectiveMax = Math.min(MAX_RESULTS_CAP, Math.max(1, maxResults ?? DEFAULT_MAX_RESULTS));
|
|
83
|
+
const cacheKey = `${effectiveMax}:${safeSearch ?? "default"}:${query}`;
|
|
84
|
+
let output = cache.get(cacheKey);
|
|
85
|
+
if (!output) {
|
|
86
|
+
const args = ["--query", query, "--max-results", String(effectiveMax)];
|
|
87
|
+
if (safeSearch)
|
|
88
|
+
args.push("--safe-search", safeSearch);
|
|
89
|
+
output = await runWebtools("search", args, cwd, signal, WEBTOOLS_DEFAULT_TIMEOUT_SECS);
|
|
90
|
+
cache.set(cacheKey, output);
|
|
91
|
+
}
|
|
92
|
+
// Filter result links through .webtoolsignore policy.
|
|
93
|
+
const matcher = loadWebtoolsIgnore(cwd);
|
|
94
|
+
const allResults = output.results ?? [];
|
|
95
|
+
const kept = matcher
|
|
96
|
+
? allResults.filter((item) => {
|
|
97
|
+
const host = hostnameOf(item.url);
|
|
98
|
+
return host ? !isHostBlocked(matcher, host) : true;
|
|
99
|
+
})
|
|
100
|
+
: allResults;
|
|
101
|
+
const hiddenCount = allResults.length - kept.length;
|
|
102
|
+
return {
|
|
103
|
+
content: [{ type: "text", text: renderSearchText(query, kept, hiddenCount) }],
|
|
104
|
+
details: {
|
|
105
|
+
resultCount: kept.length,
|
|
106
|
+
hiddenCount,
|
|
107
|
+
tokenEstimate: output.token_estimate,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
renderCall(args, _theme, context) {
|
|
112
|
+
const text = context.lastComponent ?? new Text("", 0, 0);
|
|
113
|
+
text.setText(formatWebsearchCall(args));
|
|
114
|
+
return text;
|
|
115
|
+
},
|
|
116
|
+
renderResult(result, options, _theme, context) {
|
|
117
|
+
const text = context.lastComponent ?? new Text("", 0, 0);
|
|
118
|
+
text.setText(formatWebsearchResult(result, options, context.showImages));
|
|
119
|
+
return text;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export function createWebSearchTool(cwd, options) {
|
|
124
|
+
return wrapToolDefinition(createWebSearchToolDefinition(cwd, options));
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=websearch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"websearch.js","sourceRoot":"","sources":["../../../src/core/tools/websearch.ts"],"names":[],"mappings":"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","sourcesContent":["import type { AgentTool } from \"@kolisachint/hoocode-agent-core\";\nimport { Text } from \"@kolisachint/hoocode-tui\";\nimport { type Static, Type } from \"typebox\";\nimport { keyHint } from \"../../modes/interactive/components/keybinding-hints.js\";\nimport { theme as appTheme } from \"../../modes/interactive/theme/theme.js\";\nimport type { ToolDefinition, ToolRenderResultOptions } from \"../extensions/types.js\";\nimport { getTextOutput, invalidArgText, str } from \"./render-utils.js\";\nimport { wrapToolDefinition } from \"./tool-definition-wrapper.js\";\nimport {\n\thostnameOf,\n\tisHostBlocked,\n\tloadWebtoolsIgnore,\n\trunWebtools,\n\tWEBTOOLS_DEFAULT_TIMEOUT_SECS,\n\ttype WebSearchOutput,\n\ttype WebSearchResultItem,\n\tWebToolsCache,\n} from \"./webtools-shared.js\";\n\nconst DEFAULT_MAX_RESULTS = 5;\nconst MAX_RESULTS_CAP = 10;\n\nconst websearchSchema = Type.Object({\n\tquery: Type.String({ description: \"The search query\" }),\n\tmaxResults: Type.Optional(\n\t\tType.Number({\n\t\t\tdescription: `Maximum number of results to return (default: ${DEFAULT_MAX_RESULTS}, max: ${MAX_RESULTS_CAP})`,\n\t\t}),\n\t),\n\tsafeSearch: Type.Optional(\n\t\tType.Union([Type.Literal(\"on\"), Type.Literal(\"off\")], {\n\t\t\tdescription: \"Safe search filter. Omit to use the search engine's default.\",\n\t\t}),\n\t),\n});\n\nexport type WebSearchToolInput = Static<typeof websearchSchema>;\n\nexport interface WebSearchToolDetails {\n\tresultCount?: number;\n\thiddenCount?: number;\n\ttokenEstimate?: number;\n}\n\nexport interface WebSearchToolOptions {\n\t/** Override the result cache (mainly for tests). */\n\tcache?: WebToolsCache<WebSearchOutput>;\n}\n\n/**\n * Render the kept results in reference style: title + snippet with a trailing\n * [N] marker, then a `References:` block. Indices are renumbered after filtering\n * so the visible list stays contiguous.\n */\nfunction renderSearchText(query: string, kept: WebSearchResultItem[], hiddenCount: number): string {\n\tif (kept.length === 0) {\n\t\tconst suffix = hiddenCount > 0 ? ` (${hiddenCount} blocked by .webtoolsignore policy)` : \"\";\n\t\treturn `No results for \"${query}\"${suffix}`;\n\t}\n\tconst blocks: string[] = [];\n\tconst refs: string[] = [];\n\tkept.forEach((item, i) => {\n\t\tconst n = i + 1;\n\t\tblocks.push(`${item.title} [${n}]\\n${item.snippet}`);\n\t\trefs.push(`[${n}] ${item.url}`);\n\t});\n\tlet text = `${blocks.join(\"\\n\\n\")}\\n\\nReferences:\\n${refs.join(\"\\n\")}`;\n\tif (hiddenCount > 0) {\n\t\ttext += `\\n\\n[${hiddenCount} result${hiddenCount === 1 ? \"\" : \"s\"} hidden by .webtoolsignore policy]`;\n\t}\n\treturn text;\n}\n\nfunction formatWebsearchCall(args: { query?: string; maxResults?: number } | undefined): string {\n\tconst query = str(args?.query);\n\tconst queryDisplay =\n\t\tquery === null ? invalidArgText(appTheme) : query ? `\"${query}\"` : appTheme.fg(\"toolOutput\", \"...\");\n\tconst limit = args?.maxResults;\n\tlet text = appTheme.fg(\"toolTitle\", appTheme.bold(\"websearch \")) + appTheme.fg(\"accent\", queryDisplay);\n\tif (limit !== undefined) text += appTheme.fg(\"muted\", ` (${limit})`);\n\treturn text;\n}\n\nfunction formatWebsearchResult(\n\tresult: { content: Array<{ type: string; text?: string }>; details?: WebSearchToolDetails },\n\toptions: ToolRenderResultOptions,\n\tshowImages: boolean,\n): string {\n\tconst output = getTextOutput(result as any, showImages).trim();\n\tlet text = \"\";\n\tif (output) {\n\t\tconst lines = output.split(\"\\n\");\n\t\tconst maxLines = options.expanded ? lines.length : 15;\n\t\tconst displayLines = lines.slice(0, maxLines);\n\t\tconst remaining = lines.length - maxLines;\n\t\ttext += `\\n${displayLines.map((line) => appTheme.fg(\"toolOutput\", line)).join(\"\\n\")}`;\n\t\tif (remaining > 0) {\n\t\t\ttext += `${appTheme.fg(\"muted\", `\\n... (${remaining} more lines,`)} ${keyHint(\"app.tools.expand\", \"to expand\")})`;\n\t\t}\n\t}\n\treturn text;\n}\n\nexport function createWebSearchToolDefinition(\n\tcwd: string,\n\toptions?: WebSearchToolOptions,\n): ToolDefinition<typeof websearchSchema, WebSearchToolDetails | undefined> {\n\tconst cache = options?.cache ?? new WebToolsCache<WebSearchOutput>();\n\treturn {\n\t\tname: \"websearch\",\n\t\tlabel: \"websearch\",\n\t\tdescription:\n\t\t\t\"Search the web (DuckDuckGo, no API key) and return ranked results as titles + snippets with reference-style [N] links. Use to discover URLs, then webfetch to read a result in full. Off by default; enabled with --enable-webtools.\",\n\t\tpromptSnippet: \"Search the web and return ranked results with links\",\n\t\tpromptGuidelines: [\n\t\t\t\"Use websearch to discover URLs when you do not already have one, then webfetch the most relevant result to read it in full.\",\n\t\t],\n\t\tparameters: websearchSchema,\n\t\t// External network call with variable latency: run non-blocking.\n\t\tbackground: true,\n\t\tasync execute(_toolCallId, { query, maxResults, safeSearch }: WebSearchToolInput, signal?: AbortSignal) {\n\t\t\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\t\t\tconst effectiveMax = Math.min(MAX_RESULTS_CAP, Math.max(1, maxResults ?? DEFAULT_MAX_RESULTS));\n\t\t\tconst cacheKey = `${effectiveMax}:${safeSearch ?? \"default\"}:${query}`;\n\n\t\t\tlet output = cache.get(cacheKey);\n\t\t\tif (!output) {\n\t\t\t\tconst args = [\"--query\", query, \"--max-results\", String(effectiveMax)];\n\t\t\t\tif (safeSearch) args.push(\"--safe-search\", safeSearch);\n\t\t\t\toutput = await runWebtools<WebSearchOutput>(\"search\", args, cwd, signal, WEBTOOLS_DEFAULT_TIMEOUT_SECS);\n\t\t\t\tcache.set(cacheKey, output);\n\t\t\t}\n\n\t\t\t// Filter result links through .webtoolsignore policy.\n\t\t\tconst matcher = loadWebtoolsIgnore(cwd);\n\t\t\tconst allResults = output.results ?? [];\n\t\t\tconst kept = matcher\n\t\t\t\t? allResults.filter((item) => {\n\t\t\t\t\t\tconst host = hostnameOf(item.url);\n\t\t\t\t\t\treturn host ? !isHostBlocked(matcher, host) : true;\n\t\t\t\t\t})\n\t\t\t\t: allResults;\n\t\t\tconst hiddenCount = allResults.length - kept.length;\n\n\t\t\treturn {\n\t\t\t\tcontent: [{ type: \"text\" as const, text: renderSearchText(query, kept, hiddenCount) }],\n\t\t\t\tdetails: {\n\t\t\t\t\tresultCount: kept.length,\n\t\t\t\t\thiddenCount,\n\t\t\t\t\ttokenEstimate: output.token_estimate,\n\t\t\t\t},\n\t\t\t};\n\t\t},\n\t\trenderCall(args, _theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatWebsearchCall(args));\n\t\t\treturn text;\n\t\t},\n\t\trenderResult(result, options, _theme, context) {\n\t\t\tconst text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n\t\t\ttext.setText(formatWebsearchResult(result as any, options, context.showImages));\n\t\t\treturn text;\n\t\t},\n\t};\n}\n\nexport function createWebSearchTool(cwd: string, options?: WebSearchToolOptions): AgentTool<typeof websearchSchema> {\n\treturn wrapToolDefinition(createWebSearchToolDefinition(cwd, options));\n}\n"]}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plumbing for the `webfetch` and `websearch` tools.
|
|
3
|
+
*
|
|
4
|
+
* Both tools shell out to the `webtools` binary (fetch / search subcommands,
|
|
5
|
+
* resolved/downloaded via {@link ensureTool}) and parse its `--json` output.
|
|
6
|
+
* This module owns:
|
|
7
|
+
* - the spawn-and-parse runner,
|
|
8
|
+
* - the locked JSON result types,
|
|
9
|
+
* - a short-lived in-process result cache, and
|
|
10
|
+
* - the `.webtoolsignore` policy matcher (gitignore semantics) used to block
|
|
11
|
+
* hosts both before a fetch and when filtering search result links.
|
|
12
|
+
*/
|
|
13
|
+
import ignore from "ignore";
|
|
14
|
+
type IgnoreMatcher = ReturnType<typeof ignore>;
|
|
15
|
+
/** Default request timeout (seconds) passed to the binary. */
|
|
16
|
+
export declare const WEBTOOLS_DEFAULT_TIMEOUT_SECS = 15;
|
|
17
|
+
export interface WebFetchReference {
|
|
18
|
+
index: number;
|
|
19
|
+
url: string;
|
|
20
|
+
text?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface WebFetchMetadata {
|
|
23
|
+
description?: string;
|
|
24
|
+
author?: string;
|
|
25
|
+
published?: string;
|
|
26
|
+
lang?: string;
|
|
27
|
+
site_name?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface WebFetchResult {
|
|
30
|
+
title?: string;
|
|
31
|
+
final_url: string;
|
|
32
|
+
content: string;
|
|
33
|
+
content_type: string;
|
|
34
|
+
media: string;
|
|
35
|
+
token_estimate: number;
|
|
36
|
+
references: WebFetchReference[];
|
|
37
|
+
metadata?: WebFetchMetadata;
|
|
38
|
+
source: string;
|
|
39
|
+
}
|
|
40
|
+
export interface WebSearchResultItem {
|
|
41
|
+
title: string;
|
|
42
|
+
snippet: string;
|
|
43
|
+
url: string;
|
|
44
|
+
ref_index: number;
|
|
45
|
+
}
|
|
46
|
+
export interface WebSearchReference {
|
|
47
|
+
index: number;
|
|
48
|
+
url: string;
|
|
49
|
+
}
|
|
50
|
+
export interface WebSearchOutput {
|
|
51
|
+
query: string;
|
|
52
|
+
results: WebSearchResultItem[];
|
|
53
|
+
references: WebSearchReference[];
|
|
54
|
+
token_estimate: number;
|
|
55
|
+
result_count: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Run a `webtools` subcommand with `--json` and return parsed stdout.
|
|
59
|
+
*
|
|
60
|
+
* Throws on missing binary, non-zero exit (surfacing the binary's stderr), or
|
|
61
|
+
* unparseable output. Callers convert thrown errors into tool error results.
|
|
62
|
+
*/
|
|
63
|
+
export declare function runWebtools<T>(subcommand: "fetch" | "search", args: string[], cwd: string, signal?: AbortSignal, timeoutSecs?: number): Promise<T>;
|
|
64
|
+
export declare class WebToolsCache<T> {
|
|
65
|
+
private readonly entries;
|
|
66
|
+
get(key: string): T | undefined;
|
|
67
|
+
set(key: string, value: T): void;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Build an {@link Ignore} matcher from `.webtoolsignore` policy files.
|
|
71
|
+
*
|
|
72
|
+
* Precedence is project-after-user so a project file can re-allow (`!host`)
|
|
73
|
+
* something the user blocked, matching gitignore layering. Returns undefined
|
|
74
|
+
* when no policy files exist (the common case: everything allowed).
|
|
75
|
+
*
|
|
76
|
+
* Hosts are matched as single path components, so subdomains need an explicit
|
|
77
|
+
* wildcard (`*.example.com`), exactly like gitignore directory matching.
|
|
78
|
+
*/
|
|
79
|
+
export declare function loadWebtoolsIgnore(cwd: string): IgnoreMatcher | undefined;
|
|
80
|
+
/** Extract the lowercased hostname from a URL, or undefined if it cannot be parsed. */
|
|
81
|
+
export declare function hostnameOf(url: string): string | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* Whether a host is blocked by policy. A matcher is required; with no policy
|
|
84
|
+
* files present callers treat every host as allowed.
|
|
85
|
+
*/
|
|
86
|
+
export declare function isHostBlocked(matcher: IgnoreMatcher, host: string): boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Convenience used by the permission gate: returns the blocked host for a URL,
|
|
89
|
+
* or undefined when the URL is allowed (or there is no policy / unparseable URL).
|
|
90
|
+
*/
|
|
91
|
+
export declare function blockedHostForUrl(cwd: string, url: string): string | undefined;
|
|
92
|
+
export {};
|
|
93
|
+
//# sourceMappingURL=webtools-shared.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webtools-shared.d.ts","sourceRoot":"","sources":["../../../src/core/tools/webtools-shared.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,MAAM,MAAM,QAAQ,CAAC;AAK5B,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,MAAM,CAAC,CAAC;AAE/C,8DAA8D;AAC9D,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAShD,MAAM,WAAW,iBAAiB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,gBAAgB;IAChC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,cAAc;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,iBAAiB,EAAE,CAAC;IAChC,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,MAAM,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,mBAAmB;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,kBAAkB;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,eAAe;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,mBAAmB,EAAE,CAAC;IAC/B,UAAU,EAAE,kBAAkB,EAAE,CAAC;IACjC,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;CACrB;AASD;;;;;GAKG;AACH,wBAAsB,WAAW,CAAC,CAAC,EAClC,UAAU,EAAE,OAAO,GAAG,QAAQ,EAC9B,IAAI,EAAE,MAAM,EAAE,EACd,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,WAAW,EACpB,WAAW,GAAE,MAAsC,GACjD,OAAO,CAAC,CAAC,CAAC,CA0BZ;AAWD,qBAAa,aAAa,CAAC,CAAC;IAC3B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAoC;IAE5D,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS,CAQ9B;IAED,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,IAAI,CAE/B;CACD;AAMD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS,CAkBzE;AAED,uFAAuF;AACvF,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAO1D;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAG3E;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAM9E","sourcesContent":["/**\n * Shared plumbing for the `webfetch` and `websearch` tools.\n *\n * Both tools shell out to the `webtools` binary (fetch / search subcommands,\n * resolved/downloaded via {@link ensureTool}) and parse its `--json` output.\n * This module owns:\n * - the spawn-and-parse runner,\n * - the locked JSON result types,\n * - a short-lived in-process result cache, and\n * - the `.webtoolsignore` policy matcher (gitignore semantics) used to block\n * hosts both before a fetch and when filtering search result links.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport ignore from \"ignore\";\nimport { getAgentDir } from \"../../config.js\";\nimport { ensureTool } from \"../../utils/tools-manager.js\";\nimport { execCommand } from \"../exec.js\";\n\ntype IgnoreMatcher = ReturnType<typeof ignore>;\n\n/** Default request timeout (seconds) passed to the binary. */\nexport const WEBTOOLS_DEFAULT_TIMEOUT_SECS = 15;\n\n/** How long a successful result stays cached, mirroring the documented 15-min TTL. */\nconst CACHE_TTL_MS = 15 * 60 * 1000;\n\n// ============================================================================\n// Result types (locked against `webtools <cmd> --json`)\n// ============================================================================\n\nexport interface WebFetchReference {\n\tindex: number;\n\turl: string;\n\ttext?: string;\n}\n\nexport interface WebFetchMetadata {\n\tdescription?: string;\n\tauthor?: string;\n\tpublished?: string;\n\tlang?: string;\n\tsite_name?: string;\n}\n\nexport interface WebFetchResult {\n\ttitle?: string;\n\tfinal_url: string;\n\tcontent: string;\n\tcontent_type: string;\n\tmedia: string;\n\ttoken_estimate: number;\n\treferences: WebFetchReference[];\n\tmetadata?: WebFetchMetadata;\n\tsource: string;\n}\n\nexport interface WebSearchResultItem {\n\ttitle: string;\n\tsnippet: string;\n\turl: string;\n\tref_index: number;\n}\n\nexport interface WebSearchReference {\n\tindex: number;\n\turl: string;\n}\n\nexport interface WebSearchOutput {\n\tquery: string;\n\tresults: WebSearchResultItem[];\n\treferences: WebSearchReference[];\n\ttoken_estimate: number;\n\tresult_count: number;\n}\n\n// ============================================================================\n// Binary runner\n// ============================================================================\n\nconst BINARY_MISSING_MESSAGE =\n\t\"webtools binary unavailable and could not be downloaded — web tools require the `webtools` CLI on PATH or a published release for this platform\";\n\n/**\n * Run a `webtools` subcommand with `--json` and return parsed stdout.\n *\n * Throws on missing binary, non-zero exit (surfacing the binary's stderr), or\n * unparseable output. Callers convert thrown errors into tool error results.\n */\nexport async function runWebtools<T>(\n\tsubcommand: \"fetch\" | \"search\",\n\targs: string[],\n\tcwd: string,\n\tsignal?: AbortSignal,\n\ttimeoutSecs: number = WEBTOOLS_DEFAULT_TIMEOUT_SECS,\n): Promise<T> {\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\n\tconst binaryPath = await ensureTool(\"webtools\", true);\n\tif (!binaryPath) throw new Error(BINARY_MISSING_MESSAGE);\n\n\t// Give the spawn a little headroom over the binary's own request timeout so\n\t// the binary reports the timeout itself rather than being killed mid-flight.\n\tconst spawnTimeoutMs = (timeoutSecs + 5) * 1000;\n\tconst result = await execCommand(binaryPath, [subcommand, ...args, \"--json\"], cwd, {\n\t\tsignal,\n\t\ttimeout: spawnTimeoutMs,\n\t});\n\n\tif (signal?.aborted) throw new Error(\"Operation aborted\");\n\tif (result.killed) throw new Error(`webtools ${subcommand} timed out after ${timeoutSecs}s`);\n\tif (result.code !== 0) {\n\t\tconst stderr = result.stderr.trim();\n\t\tthrow new Error(stderr || `webtools ${subcommand} exited with code ${result.code}`);\n\t}\n\n\ttry {\n\t\treturn JSON.parse(result.stdout) as T;\n\t} catch {\n\t\tthrow new Error(`webtools ${subcommand} returned malformed JSON`);\n\t}\n}\n\n// ============================================================================\n// Result cache (per-process, short TTL)\n// ============================================================================\n\ninterface CacheEntry<T> {\n\tvalue: T;\n\texpiresAt: number;\n}\n\nexport class WebToolsCache<T> {\n\tprivate readonly entries = new Map<string, CacheEntry<T>>();\n\n\tget(key: string): T | undefined {\n\t\tconst entry = this.entries.get(key);\n\t\tif (!entry) return undefined;\n\t\tif (Date.now() >= entry.expiresAt) {\n\t\t\tthis.entries.delete(key);\n\t\t\treturn undefined;\n\t\t}\n\t\treturn entry.value;\n\t}\n\n\tset(key: string, value: T): void {\n\t\tthis.entries.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });\n\t}\n}\n\n// ============================================================================\n// .webtoolsignore policy matcher\n// ============================================================================\n\n/**\n * Build an {@link Ignore} matcher from `.webtoolsignore` policy files.\n *\n * Precedence is project-after-user so a project file can re-allow (`!host`)\n * something the user blocked, matching gitignore layering. Returns undefined\n * when no policy files exist (the common case: everything allowed).\n *\n * Hosts are matched as single path components, so subdomains need an explicit\n * wildcard (`*.example.com`), exactly like gitignore directory matching.\n */\nexport function loadWebtoolsIgnore(cwd: string): IgnoreMatcher | undefined {\n\tconst files = [\n\t\tjoin(getAgentDir(), \"webtoolsignore\"),\n\t\tjoin(homedir(), \".webtoolsignore\"),\n\t\tjoin(cwd, \".webtoolsignore\"),\n\t];\n\tlet found = false;\n\tconst ig = ignore();\n\tfor (const file of files) {\n\t\tif (!existsSync(file)) continue;\n\t\ttry {\n\t\t\tig.add(readFileSync(file, \"utf8\"));\n\t\t\tfound = true;\n\t\t} catch {\n\t\t\t// Unreadable policy file: ignore it rather than failing the tool call.\n\t\t}\n\t}\n\treturn found ? ig : undefined;\n}\n\n/** Extract the lowercased hostname from a URL, or undefined if it cannot be parsed. */\nexport function hostnameOf(url: string): string | undefined {\n\ttry {\n\t\tconst host = new URL(url).hostname.toLowerCase();\n\t\treturn host || undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/**\n * Whether a host is blocked by policy. A matcher is required; with no policy\n * files present callers treat every host as allowed.\n */\nexport function isHostBlocked(matcher: IgnoreMatcher, host: string): boolean {\n\tif (!host) return false;\n\treturn matcher.ignores(host);\n}\n\n/**\n * Convenience used by the permission gate: returns the blocked host for a URL,\n * or undefined when the URL is allowed (or there is no policy / unparseable URL).\n */\nexport function blockedHostForUrl(cwd: string, url: string): string | undefined {\n\tconst matcher = loadWebtoolsIgnore(cwd);\n\tif (!matcher) return undefined;\n\tconst host = hostnameOf(url);\n\tif (!host) return undefined;\n\treturn isHostBlocked(matcher, host) ? host : undefined;\n}\n"]}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plumbing for the `webfetch` and `websearch` tools.
|
|
3
|
+
*
|
|
4
|
+
* Both tools shell out to the `webtools` binary (fetch / search subcommands,
|
|
5
|
+
* resolved/downloaded via {@link ensureTool}) and parse its `--json` output.
|
|
6
|
+
* This module owns:
|
|
7
|
+
* - the spawn-and-parse runner,
|
|
8
|
+
* - the locked JSON result types,
|
|
9
|
+
* - a short-lived in-process result cache, and
|
|
10
|
+
* - the `.webtoolsignore` policy matcher (gitignore semantics) used to block
|
|
11
|
+
* hosts both before a fetch and when filtering search result links.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import ignore from "ignore";
|
|
17
|
+
import { getAgentDir } from "../../config.js";
|
|
18
|
+
import { ensureTool } from "../../utils/tools-manager.js";
|
|
19
|
+
import { execCommand } from "../exec.js";
|
|
20
|
+
/** Default request timeout (seconds) passed to the binary. */
|
|
21
|
+
export const WEBTOOLS_DEFAULT_TIMEOUT_SECS = 15;
|
|
22
|
+
/** How long a successful result stays cached, mirroring the documented 15-min TTL. */
|
|
23
|
+
const CACHE_TTL_MS = 15 * 60 * 1000;
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Binary runner
|
|
26
|
+
// ============================================================================
|
|
27
|
+
const BINARY_MISSING_MESSAGE = "webtools binary unavailable and could not be downloaded — web tools require the `webtools` CLI on PATH or a published release for this platform";
|
|
28
|
+
/**
|
|
29
|
+
* Run a `webtools` subcommand with `--json` and return parsed stdout.
|
|
30
|
+
*
|
|
31
|
+
* Throws on missing binary, non-zero exit (surfacing the binary's stderr), or
|
|
32
|
+
* unparseable output. Callers convert thrown errors into tool error results.
|
|
33
|
+
*/
|
|
34
|
+
export async function runWebtools(subcommand, args, cwd, signal, timeoutSecs = WEBTOOLS_DEFAULT_TIMEOUT_SECS) {
|
|
35
|
+
if (signal?.aborted)
|
|
36
|
+
throw new Error("Operation aborted");
|
|
37
|
+
const binaryPath = await ensureTool("webtools", true);
|
|
38
|
+
if (!binaryPath)
|
|
39
|
+
throw new Error(BINARY_MISSING_MESSAGE);
|
|
40
|
+
// Give the spawn a little headroom over the binary's own request timeout so
|
|
41
|
+
// the binary reports the timeout itself rather than being killed mid-flight.
|
|
42
|
+
const spawnTimeoutMs = (timeoutSecs + 5) * 1000;
|
|
43
|
+
const result = await execCommand(binaryPath, [subcommand, ...args, "--json"], cwd, {
|
|
44
|
+
signal,
|
|
45
|
+
timeout: spawnTimeoutMs,
|
|
46
|
+
});
|
|
47
|
+
if (signal?.aborted)
|
|
48
|
+
throw new Error("Operation aborted");
|
|
49
|
+
if (result.killed)
|
|
50
|
+
throw new Error(`webtools ${subcommand} timed out after ${timeoutSecs}s`);
|
|
51
|
+
if (result.code !== 0) {
|
|
52
|
+
const stderr = result.stderr.trim();
|
|
53
|
+
throw new Error(stderr || `webtools ${subcommand} exited with code ${result.code}`);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(result.stdout);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new Error(`webtools ${subcommand} returned malformed JSON`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export class WebToolsCache {
|
|
63
|
+
entries = new Map();
|
|
64
|
+
get(key) {
|
|
65
|
+
const entry = this.entries.get(key);
|
|
66
|
+
if (!entry)
|
|
67
|
+
return undefined;
|
|
68
|
+
if (Date.now() >= entry.expiresAt) {
|
|
69
|
+
this.entries.delete(key);
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return entry.value;
|
|
73
|
+
}
|
|
74
|
+
set(key, value) {
|
|
75
|
+
this.entries.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// ============================================================================
|
|
79
|
+
// .webtoolsignore policy matcher
|
|
80
|
+
// ============================================================================
|
|
81
|
+
/**
|
|
82
|
+
* Build an {@link Ignore} matcher from `.webtoolsignore` policy files.
|
|
83
|
+
*
|
|
84
|
+
* Precedence is project-after-user so a project file can re-allow (`!host`)
|
|
85
|
+
* something the user blocked, matching gitignore layering. Returns undefined
|
|
86
|
+
* when no policy files exist (the common case: everything allowed).
|
|
87
|
+
*
|
|
88
|
+
* Hosts are matched as single path components, so subdomains need an explicit
|
|
89
|
+
* wildcard (`*.example.com`), exactly like gitignore directory matching.
|
|
90
|
+
*/
|
|
91
|
+
export function loadWebtoolsIgnore(cwd) {
|
|
92
|
+
const files = [
|
|
93
|
+
join(getAgentDir(), "webtoolsignore"),
|
|
94
|
+
join(homedir(), ".webtoolsignore"),
|
|
95
|
+
join(cwd, ".webtoolsignore"),
|
|
96
|
+
];
|
|
97
|
+
let found = false;
|
|
98
|
+
const ig = ignore();
|
|
99
|
+
for (const file of files) {
|
|
100
|
+
if (!existsSync(file))
|
|
101
|
+
continue;
|
|
102
|
+
try {
|
|
103
|
+
ig.add(readFileSync(file, "utf8"));
|
|
104
|
+
found = true;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Unreadable policy file: ignore it rather than failing the tool call.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return found ? ig : undefined;
|
|
111
|
+
}
|
|
112
|
+
/** Extract the lowercased hostname from a URL, or undefined if it cannot be parsed. */
|
|
113
|
+
export function hostnameOf(url) {
|
|
114
|
+
try {
|
|
115
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
116
|
+
return host || undefined;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Whether a host is blocked by policy. A matcher is required; with no policy
|
|
124
|
+
* files present callers treat every host as allowed.
|
|
125
|
+
*/
|
|
126
|
+
export function isHostBlocked(matcher, host) {
|
|
127
|
+
if (!host)
|
|
128
|
+
return false;
|
|
129
|
+
return matcher.ignores(host);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Convenience used by the permission gate: returns the blocked host for a URL,
|
|
133
|
+
* or undefined when the URL is allowed (or there is no policy / unparseable URL).
|
|
134
|
+
*/
|
|
135
|
+
export function blockedHostForUrl(cwd, url) {
|
|
136
|
+
const matcher = loadWebtoolsIgnore(cwd);
|
|
137
|
+
if (!matcher)
|
|
138
|
+
return undefined;
|
|
139
|
+
const host = hostnameOf(url);
|
|
140
|
+
if (!host)
|
|
141
|
+
return undefined;
|
|
142
|
+
return isHostBlocked(matcher, host) ? host : undefined;
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=webtools-shared.js.map
|