min-agent 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +242 -31
- package/dist/agent.js +1233 -485
- package/dist/assistant-stream.js +11 -7
- package/dist/cli/commands/chat.js +10 -0
- package/dist/cli/commands/exec.js +32 -0
- package/dist/cli/commands/history.js +58 -0
- package/dist/cli/commands/index.js +224 -0
- package/dist/cli/commands/init.js +18 -0
- package/dist/cli/commands/mcp.js +173 -0
- package/dist/cli/commands/memory.js +69 -0
- package/dist/cli/commands/models.js +21 -0
- package/dist/cli/commands/permission.js +12 -0
- package/dist/cli/commands/rules.js +33 -0
- package/dist/cli/commands/sandbox.js +13 -0
- package/dist/cli/commands/serve.js +9 -0
- package/dist/cli/commands/setup.js +4 -0
- package/dist/cli/commands/shared.js +16 -0
- package/dist/cli/commands/skills.js +119 -0
- package/dist/cli/commands/update.js +7 -0
- package/dist/cli/commands/write-config.js +30 -0
- package/dist/cli/errors.js +36 -0
- package/dist/cli/exec-prompt.js +26 -0
- package/dist/cli/option-helpers.js +53 -0
- package/dist/cli/program.js +180 -0
- package/dist/cli.js +7 -632
- package/dist/clipboard.js +59 -23
- package/dist/code-mode.js +35 -17
- package/dist/compaction.js +457 -169
- package/dist/config.js +298 -38
- package/dist/confirm.js +105 -9
- package/dist/context-window.js +156 -75
- package/dist/doom-loop.js +268 -26
- package/dist/fetch-timeout.js +152 -0
- package/dist/http-approvals.js +60 -0
- package/dist/http.js +119 -0
- package/dist/instructions.js +72 -33
- package/dist/logger.js +95 -0
- package/dist/markdown.js +35 -50
- package/dist/mcp.js +847 -102
- package/dist/memory.js +128 -45
- package/dist/output.js +42 -31
- package/dist/paste-handler.js +3 -3
- package/dist/permission-cli.js +43 -0
- package/dist/plugins.js +76 -11
- package/dist/pricing.js +119 -0
- package/dist/provider.js +34 -15
- package/dist/question-format.js +60 -0
- package/dist/sandbox-cli.js +82 -0
- package/dist/sandbox.js +403 -0
- package/dist/save-throttle.js +45 -0
- package/dist/serve/common.js +404 -0
- package/dist/serve/routes-chat.js +347 -0
- package/dist/serve/routes-mcp.js +212 -0
- package/dist/serve/routes-memory.js +66 -0
- package/dist/serve/routes-meta.js +205 -0
- package/dist/serve/routes-sessions.js +61 -0
- package/dist/serve/routes-skills.js +70 -0
- package/dist/serve.js +74 -635
- package/dist/sessions.js +197 -15
- package/dist/skills.js +531 -77
- package/dist/synthetic.js +7 -0
- package/dist/title-gen.js +9 -2
- package/dist/token-display.js +36 -0
- package/dist/tool-display.js +178 -0
- package/dist/tool-output.js +53 -46
- package/dist/tools/apply_patch.js +265 -0
- package/dist/tools/atomic-file.js +35 -0
- package/dist/tools/backend.js +61 -0
- package/dist/tools/bash.js +186 -71
- package/dist/tools/code_search.js +13 -6
- package/dist/tools/edit.js +26 -9
- package/dist/tools/explore.js +144 -16
- package/dist/tools/glob.js +7 -3
- package/dist/tools/grep.js +153 -14
- package/dist/tools/index.js +9 -24
- package/dist/tools/question.js +31 -30
- package/dist/tools/read.js +77 -15
- package/dist/tools/search-searxng.js +223 -0
- package/dist/tools/search-serper.js +189 -0
- package/dist/tools/task.js +100 -33
- package/dist/tools/todo.js +178 -67
- package/dist/tools/web_fetch.js +158 -46
- package/dist/tools/web_search.js +217 -29
- package/dist/tools/write.js +34 -11
- package/dist/tui/App.js +89 -6
- package/dist/tui/ConfirmBar.js +57 -4
- package/dist/tui/InputBar.js +504 -44
- package/dist/tui/MessageList.js +674 -20
- package/dist/tui/ModelPicker.js +113 -0
- package/dist/tui/QuestionBar.js +136 -0
- package/dist/tui/SessionPicker.js +79 -0
- package/dist/tui/StatusBar.js +14 -12
- package/dist/tui/agent-runner.js +223 -0
- package/dist/tui/caret-pos.js +177 -0
- package/dist/tui/caret.js +69 -0
- package/dist/tui/click-count.js +13 -0
- package/dist/tui/diff-view.js +61 -0
- package/dist/tui/drag-state.js +49 -0
- package/dist/tui/hydrate.js +129 -0
- package/dist/tui/index.js +189 -31
- package/dist/tui/input-history.js +125 -0
- package/dist/tui/layout.js +88 -0
- package/dist/tui/mouse.js +46 -0
- package/dist/tui/prompt-queue.js +24 -0
- package/dist/tui/selection.js +226 -0
- package/dist/tui/session-switch.js +28 -0
- package/dist/tui/slash-commands.js +106 -0
- package/dist/tui/slash-handler.js +545 -0
- package/dist/tui/text-width.js +113 -0
- package/dist/tui/theme.js +12 -0
- package/dist/tui/token-info.js +7 -0
- package/dist/tui/tool-children.js +19 -0
- package/dist/tui/undo-stack.js +14 -0
- package/dist/tui/use-sgr-mouse.js +29 -0
- package/dist/tui-chat.js +346 -330
- package/dist/updater.js +116 -0
- package/dist/xml-search.js +194 -0
- package/docs/API.md +410 -32
- package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
- package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
- package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
- package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
- package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
- package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
- package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
- package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
- package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
- package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
- package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
- package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
- package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
- package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
- package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
- package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
- package/package.json +12 -8
- package/skills/self-config/SKILL.md +90 -0
- package/skills/self-config/reference.md +149 -0
package/dist/tools/web_fetch.js
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import { tool, jsonSchema } from "ai";
|
|
2
2
|
import { truncateToolOutput } from "../tool-output.js";
|
|
3
|
-
|
|
3
|
+
import { assertSafeUrl, readBodyLimited } from "../http.js";
|
|
4
|
+
import { loadConfig } from "../config.js";
|
|
5
|
+
import { configHint, describeFetchError, resolveBackendBase } from "./backend.js";
|
|
6
|
+
import { getEffectiveSandboxPolicy, networkDeniedMessage } from "../sandbox.js";
|
|
7
|
+
export const FIRECRAWL_BACKEND = {
|
|
8
|
+
envName: "MIN_AGENT_FIRECRAWL_URL",
|
|
9
|
+
configKey: "webFetchURL",
|
|
10
|
+
fallback: "https://fireclawl.xc.lonae.com",
|
|
11
|
+
};
|
|
12
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
13
|
+
const DIRECT_TIMEOUT_MS = 8000;
|
|
14
|
+
const SCRAPE_TIMEOUT_MS = 35000;
|
|
15
|
+
const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
4
16
|
export const webFetchTool = tool({
|
|
5
|
-
description: "Fetch content from a URL. Use this to access web pages, APIs, or any HTTP resource. Returns the response body as text
|
|
17
|
+
description: "Fetch content from a URL. Use this to access web pages, APIs, or any HTTP resource. Returns the response body as text, or markdown for HTML pages (JavaScript-rendered pages go through a scrape backend). Do not re-fetch URLs you already have; extract what you need and produce the user's deliverable.",
|
|
6
18
|
inputSchema: jsonSchema({
|
|
7
19
|
type: "object",
|
|
8
20
|
properties: {
|
|
@@ -12,72 +24,172 @@ export const webFetchTool = tool({
|
|
|
12
24
|
required: ["url"],
|
|
13
25
|
}),
|
|
14
26
|
execute: async ({ url, method }) => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
return
|
|
27
|
+
if (getEffectiveSandboxPolicy().network === "deny")
|
|
28
|
+
return `Error: ${networkDeniedMessage()}`;
|
|
29
|
+
const target = (url ?? "").trim();
|
|
30
|
+
if (!target)
|
|
31
|
+
return 'Error: "url" must be a non-empty string.';
|
|
32
|
+
const blocked = await assertSafeUrl(target);
|
|
33
|
+
if (blocked)
|
|
34
|
+
return `Error: ${blocked}`;
|
|
35
|
+
const verb = (method ?? "GET").trim().toUpperCase() || "GET";
|
|
36
|
+
const direct = await directFetch(target, verb);
|
|
37
|
+
// Plain text / JSON responses are what the caller asked for; return them
|
|
38
|
+
// without paying for a scrape round trip.
|
|
39
|
+
if (direct.kind === "ok" && !direct.html && hasContent(direct.text)) {
|
|
40
|
+
return renderBody(direct.text, direct.bodyTruncated);
|
|
20
41
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
|
|
42
|
+
if (direct.kind === "binary") {
|
|
43
|
+
return `Error: the response is not text (Content-Type: ${direct.contentType}). Use a different tool to download binary content.`;
|
|
44
|
+
}
|
|
45
|
+
// A non-GET request cannot be replayed by the scrape backend (it always
|
|
46
|
+
// performs a GET), so report the direct outcome as-is.
|
|
47
|
+
if (verb !== "GET") {
|
|
48
|
+
if (direct.kind === "ok")
|
|
49
|
+
return renderBody(direct.text, direct.bodyTruncated);
|
|
50
|
+
return `Error: ${describeDirect(direct, target)}`;
|
|
51
|
+
}
|
|
52
|
+
// HTML (or an empty/JS-rendered shell): the scrape backend turns it into
|
|
53
|
+
// markdown, which is both cheaper and more readable than raw markup.
|
|
54
|
+
const scraped = await firecrawlFetch(target);
|
|
55
|
+
if (scraped.kind === "ok")
|
|
56
|
+
return renderBody(scraped.markdown, scraped.bodyTruncated);
|
|
57
|
+
if (direct.kind === "ok" && hasContent(direct.text)) {
|
|
58
|
+
return [
|
|
59
|
+
`Note: returning raw HTML — the scrape backend could not convert this page (${scraped.reason}).`,
|
|
60
|
+
renderBody(direct.text, direct.bodyTruncated),
|
|
61
|
+
].join("\n\n");
|
|
62
|
+
}
|
|
63
|
+
return [
|
|
64
|
+
`Failed to fetch content from ${target}.`,
|
|
65
|
+
`Direct request: ${describeDirect(direct, target)}`,
|
|
66
|
+
`Scrape backend: ${scraped.reason}`,
|
|
67
|
+
configHint(FIRECRAWL_BACKEND),
|
|
68
|
+
].join("\n");
|
|
27
69
|
},
|
|
28
70
|
});
|
|
71
|
+
function renderBody(text, bodyTruncated) {
|
|
72
|
+
const { content } = truncateToolOutput(text, { direction: "head" });
|
|
73
|
+
// readBodyLimited cutting at MAX_BODY_BYTES is a different truncation from
|
|
74
|
+
// truncateToolOutput's (which persists the full text), so disclose it.
|
|
75
|
+
if (!bodyTruncated)
|
|
76
|
+
return content;
|
|
77
|
+
return `${content}\n\n...(response body was cut off at ${MAX_BODY_BYTES / 1024 / 1024} MB)`;
|
|
78
|
+
}
|
|
79
|
+
function describeDirect(outcome, url) {
|
|
80
|
+
switch (outcome.kind) {
|
|
81
|
+
case "ok":
|
|
82
|
+
return `HTTP 200 but the body had almost no readable content (${url} may require JavaScript)`;
|
|
83
|
+
case "binary":
|
|
84
|
+
return `non-text response (${outcome.contentType})`;
|
|
85
|
+
case "http":
|
|
86
|
+
return `HTTP ${outcome.status}${outcome.statusText ? ` ${outcome.statusText}` : ""}`;
|
|
87
|
+
case "network":
|
|
88
|
+
return outcome.message;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function isHtml(contentType) {
|
|
92
|
+
return /html|xhtml/i.test(contentType);
|
|
93
|
+
}
|
|
94
|
+
/** Content types worth handing to the model as text. */
|
|
95
|
+
function isTextual(contentType) {
|
|
96
|
+
if (!contentType)
|
|
97
|
+
return true; // Many APIs omit it; the NUL sniff below catches binaries.
|
|
98
|
+
const ct = contentType.toLowerCase();
|
|
99
|
+
if (ct.startsWith("text/"))
|
|
100
|
+
return true;
|
|
101
|
+
return /(json|xml|javascript|ecmascript|yaml|csv|graphql|x-www-form-urlencoded|\+json)/.test(ct);
|
|
102
|
+
}
|
|
29
103
|
async function directFetch(url, method) {
|
|
104
|
+
let response;
|
|
30
105
|
try {
|
|
31
|
-
|
|
32
|
-
method
|
|
106
|
+
response = await fetch(url, {
|
|
107
|
+
method,
|
|
33
108
|
headers: {
|
|
34
|
-
"User-Agent":
|
|
35
|
-
|
|
109
|
+
"User-Agent": BROWSER_UA,
|
|
110
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,application/json;q=0.9,*/*;q=0.8",
|
|
36
111
|
},
|
|
37
|
-
signal: AbortSignal.timeout(
|
|
112
|
+
signal: AbortSignal.timeout(DIRECT_TIMEOUT_MS),
|
|
38
113
|
});
|
|
39
|
-
if (!response.ok)
|
|
40
|
-
return `HTTP ${response.status}`;
|
|
41
|
-
const text = await response.text();
|
|
42
|
-
return truncateToolOutput(text, { direction: "head" }).content;
|
|
43
114
|
}
|
|
44
115
|
catch (err) {
|
|
45
|
-
return
|
|
116
|
+
return { kind: "network", message: describeFetchError(err) };
|
|
117
|
+
}
|
|
118
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
119
|
+
if (!response.ok)
|
|
120
|
+
return { kind: "http", status: response.status, statusText: response.statusText };
|
|
121
|
+
if (!isTextual(contentType))
|
|
122
|
+
return { kind: "binary", contentType };
|
|
123
|
+
try {
|
|
124
|
+
const { text, truncated } = await readBodyLimited(response, MAX_BODY_BYTES);
|
|
125
|
+
if (text.includes("\u0000"))
|
|
126
|
+
return { kind: "binary", contentType: contentType || "unknown (binary)" };
|
|
127
|
+
return { kind: "ok", text, bodyTruncated: truncated, html: isHtml(contentType) || looksLikeHtml(text) };
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
return { kind: "network", message: describeFetchError(err) };
|
|
46
131
|
}
|
|
47
132
|
}
|
|
48
133
|
async function firecrawlFetch(url) {
|
|
134
|
+
const resolved = resolveBackendBase(FIRECRAWL_BACKEND, loadConfig().webFetchURL);
|
|
135
|
+
if (!resolved.ok)
|
|
136
|
+
return { kind: "unavailable", reason: resolved.error };
|
|
137
|
+
let response;
|
|
49
138
|
try {
|
|
50
|
-
|
|
139
|
+
response = await fetch(`${resolved.base}/v1/scrape`, {
|
|
51
140
|
method: "POST",
|
|
52
141
|
headers: { "Content-Type": "application/json" },
|
|
53
|
-
body: JSON.stringify({
|
|
54
|
-
|
|
55
|
-
formats: ["markdown"],
|
|
56
|
-
onlyMainContent: true,
|
|
57
|
-
waitFor: 3000,
|
|
58
|
-
timeout: 30000,
|
|
59
|
-
}),
|
|
60
|
-
signal: AbortSignal.timeout(35000),
|
|
142
|
+
body: JSON.stringify({ url, formats: ["markdown"], onlyMainContent: true, waitFor: 3000, timeout: 30000 }),
|
|
143
|
+
signal: AbortSignal.timeout(SCRAPE_TIMEOUT_MS),
|
|
61
144
|
});
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
return { kind: "unavailable", reason: `could not reach the scrape backend (${describeFetchError(err)})` };
|
|
148
|
+
}
|
|
149
|
+
let text;
|
|
150
|
+
let truncated;
|
|
151
|
+
try {
|
|
152
|
+
const body = await readBodyLimited(response, MAX_BODY_BYTES);
|
|
153
|
+
text = body.text;
|
|
154
|
+
truncated = body.truncated;
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
return { kind: "unavailable", reason: `could not read the scrape response (${describeFetchError(err)})` };
|
|
158
|
+
}
|
|
159
|
+
let data;
|
|
160
|
+
try {
|
|
161
|
+
data = JSON.parse(text);
|
|
69
162
|
}
|
|
70
163
|
catch {
|
|
71
|
-
return
|
|
164
|
+
return {
|
|
165
|
+
kind: "unavailable",
|
|
166
|
+
reason: response.ok
|
|
167
|
+
? "the scrape backend returned a non-JSON response"
|
|
168
|
+
: `HTTP ${response.status} from the scrape backend`,
|
|
169
|
+
};
|
|
72
170
|
}
|
|
171
|
+
const record = typeof data === "object" && data !== null ? data : {};
|
|
172
|
+
if (!response.ok) {
|
|
173
|
+
const message = typeof record.error === "string" ? record.error : response.statusText;
|
|
174
|
+
return {
|
|
175
|
+
kind: "unavailable",
|
|
176
|
+
reason: `HTTP ${response.status} from the scrape backend${message ? ` (${message})` : ""}`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const payload = typeof record.data === "object" && record.data !== null ? record.data : {};
|
|
180
|
+
const markdown = typeof payload.markdown === "string" ? payload.markdown : typeof payload.content === "string" ? payload.content : "";
|
|
181
|
+
if (!markdown.trim())
|
|
182
|
+
return { kind: "unavailable", reason: "the scrape backend returned no content for this URL" };
|
|
183
|
+
return { kind: "ok", markdown, bodyTruncated: truncated };
|
|
184
|
+
}
|
|
185
|
+
function looksLikeHtml(body) {
|
|
186
|
+
return /^\s*(<!doctype html|<html\b|<head\b)/i.test(body);
|
|
73
187
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
188
|
+
/** Whether a body carries enough readable text to be worth returning as-is. */
|
|
189
|
+
function hasContent(body) {
|
|
190
|
+
if (body.trim().length < 200)
|
|
77
191
|
return false;
|
|
78
|
-
|
|
79
|
-
const withoutScripts = html.replace(/<script[\s\S]*?<\/script>/gi, "");
|
|
192
|
+
const withoutScripts = body.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "");
|
|
80
193
|
const textContent = withoutScripts.replace(/<[^>]*>/g, "").trim();
|
|
81
|
-
// If after removing scripts and tags there's less than 100 chars, it's likely an empty shell
|
|
82
194
|
return textContent.length > 100;
|
|
83
195
|
}
|
package/dist/tools/web_search.js
CHANGED
|
@@ -1,40 +1,228 @@
|
|
|
1
1
|
import { tool, jsonSchema } from "ai";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import { readBodyLimited } from "../http.js";
|
|
3
|
+
import { describeFetchError } from "./backend.js";
|
|
4
|
+
import { getEffectiveSandboxPolicy, networkDeniedMessage } from "../sandbox.js";
|
|
5
|
+
import { runSerper } from "./search-serper.js";
|
|
6
|
+
import { runSearxng, searxngConfigured } from "./search-searxng.js";
|
|
7
|
+
export { SEARXNG_BACKEND } from "./search-searxng.js";
|
|
8
|
+
export const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
9
|
+
const DEFAULT_MAX_RESULTS = 10;
|
|
10
|
+
const MAX_RESULTS_LIMIT = 30;
|
|
11
|
+
const SNIPPET_MAX_CHARS = 300;
|
|
12
|
+
export const VALID_TIME_RANGES = new Set(["day", "week", "month", "year"]);
|
|
13
|
+
/** ISO 639-1/2 with an optional region subtag, or SearXNG's "all". */
|
|
14
|
+
export const LANGUAGE_RE = /^(all|[a-z]{2,3}([-_][a-zA-Z]{2,4})?)$/;
|
|
15
|
+
const EXACT_TRACKING_PARAMS = new Set([
|
|
16
|
+
"ref",
|
|
17
|
+
"ref_src",
|
|
18
|
+
"referrer",
|
|
19
|
+
"fbclid",
|
|
20
|
+
"gclid",
|
|
21
|
+
"msclkid",
|
|
22
|
+
"igshid",
|
|
23
|
+
"mc_cid",
|
|
24
|
+
"mc_eid",
|
|
25
|
+
"spm",
|
|
26
|
+
"source",
|
|
27
|
+
]);
|
|
28
|
+
export function isString(v) {
|
|
29
|
+
return typeof v === "string";
|
|
30
|
+
}
|
|
31
|
+
export function collapse(s) {
|
|
32
|
+
return s.replace(/\s+/g, " ").trim();
|
|
33
|
+
}
|
|
34
|
+
export function clamp(s, max) {
|
|
35
|
+
const chars = Array.from(s);
|
|
36
|
+
if (chars.length <= max)
|
|
37
|
+
return s;
|
|
38
|
+
return chars.slice(0, max - 1).join("") + "…";
|
|
39
|
+
}
|
|
40
|
+
export function hostOf(url) {
|
|
41
|
+
try {
|
|
42
|
+
return new URL(url).hostname.replace(/^www\./, "");
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return url;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Normalize a published date to YYYY-MM-DD; pass unparseable values through. */
|
|
49
|
+
function formatPublished(raw) {
|
|
50
|
+
if (!raw)
|
|
51
|
+
return "";
|
|
52
|
+
const ms = Date.parse(raw);
|
|
53
|
+
if (Number.isNaN(ms))
|
|
54
|
+
return collapse(raw);
|
|
55
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Identity key for deduplication: scheme, "www.", trailing slash, fragment and
|
|
59
|
+
* tracking parameters all describe the same page.
|
|
60
|
+
*/
|
|
61
|
+
export function dedupeKey(raw) {
|
|
62
|
+
try {
|
|
63
|
+
const u = new URL(raw);
|
|
64
|
+
const host = u.hostname.toLowerCase().replace(/^www\./, "");
|
|
65
|
+
const path = u.pathname.replace(/\/+$/, "");
|
|
66
|
+
const params = [...u.searchParams].filter(([k]) => !k.toLowerCase().startsWith("utm_") && !EXACT_TRACKING_PARAMS.has(k.toLowerCase()));
|
|
67
|
+
params.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
68
|
+
const query = params.map(([k, v]) => `${k}=${v}`).join("&");
|
|
69
|
+
return `${host}${path}${query ? `?${query}` : ""}`;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return raw.trim().toLowerCase();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function dedupeByUrl(results) {
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
return results.filter((r) => {
|
|
78
|
+
const key = dedupeKey(r.url);
|
|
79
|
+
if (seen.has(key))
|
|
80
|
+
return false;
|
|
81
|
+
seen.add(key);
|
|
82
|
+
return true;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* One block per result:
|
|
87
|
+
* 1. Title
|
|
88
|
+
* https://url
|
|
89
|
+
* [2026-08-18 · google]
|
|
90
|
+
* snippet on a single line
|
|
91
|
+
* Snippets are whitespace-collapsed and clamped so the indented block
|
|
92
|
+
* structure survives multi-line engine output.
|
|
93
|
+
*/
|
|
94
|
+
export function formatResults(results) {
|
|
95
|
+
return results
|
|
96
|
+
.map((r, i) => {
|
|
97
|
+
const lines = [`${i + 1}. ${collapse(r.title) || hostOf(r.url)}`, ` ${r.url}`];
|
|
98
|
+
const meta = [];
|
|
99
|
+
const date = formatPublished(r.publishedDate);
|
|
100
|
+
if (date)
|
|
101
|
+
meta.push(date);
|
|
102
|
+
if (r.engine)
|
|
103
|
+
meta.push(r.engine);
|
|
104
|
+
if (meta.length > 0)
|
|
105
|
+
lines.push(` [${meta.join(" · ")}]`);
|
|
106
|
+
const snippet = clamp(collapse(r.content), SNIPPET_MAX_CHARS);
|
|
107
|
+
if (snippet)
|
|
108
|
+
lines.push(` ${snippet}`);
|
|
109
|
+
return lines.join("\n");
|
|
110
|
+
})
|
|
111
|
+
.join("\n\n");
|
|
112
|
+
}
|
|
113
|
+
export function resolveMaxResults(raw, notes) {
|
|
114
|
+
if (raw === undefined)
|
|
115
|
+
return DEFAULT_MAX_RESULTS;
|
|
116
|
+
const n = Math.floor(Number(raw));
|
|
117
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
118
|
+
notes.push(`ignored invalid max_results "${raw}"; using ${DEFAULT_MAX_RESULTS}`);
|
|
119
|
+
return DEFAULT_MAX_RESULTS;
|
|
120
|
+
}
|
|
121
|
+
if (n > MAX_RESULTS_LIMIT) {
|
|
122
|
+
notes.push(`max_results capped at ${MAX_RESULTS_LIMIT}`);
|
|
123
|
+
return MAX_RESULTS_LIMIT;
|
|
124
|
+
}
|
|
125
|
+
return n;
|
|
126
|
+
}
|
|
127
|
+
export function emptyResponse() {
|
|
128
|
+
return {
|
|
129
|
+
results: [],
|
|
130
|
+
answers: [],
|
|
131
|
+
suggestions: [],
|
|
132
|
+
corrections: [],
|
|
133
|
+
infoboxes: [],
|
|
134
|
+
unresponsive_engines: [],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/** Backends report parameter problems as {"error": "..."} — sometimes with HTTP 200. */
|
|
138
|
+
function backendErrorMessage(data) {
|
|
139
|
+
if (typeof data !== "object" || data === null)
|
|
140
|
+
return "";
|
|
141
|
+
const o = data;
|
|
142
|
+
for (const key of ["error", "detail", "message"]) {
|
|
143
|
+
const v = o[key];
|
|
144
|
+
if (isString(v) && v.trim())
|
|
145
|
+
return collapse(v);
|
|
146
|
+
if (typeof v === "object" && v !== null) {
|
|
147
|
+
const nested = v.message;
|
|
148
|
+
if (isString(nested) && nested.trim())
|
|
149
|
+
return collapse(nested);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return "";
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Shared fetch scaffolding for both search backends: read the body with a
|
|
156
|
+
* size cap, then distinguish HTTP errors, non-JSON bodies and backend-reported
|
|
157
|
+
* errors before handing valid JSON to the backend-specific parser. Error
|
|
158
|
+
* bodies are valid JSON even on a 4xx, so parse before branching on the
|
|
159
|
+
* status; that way the backend's own message reaches the caller.
|
|
160
|
+
*/
|
|
161
|
+
export async function fetchJsonOutcome(url, init, parse) {
|
|
162
|
+
let response;
|
|
163
|
+
try {
|
|
164
|
+
response = await fetch(url, init);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
return { kind: "network", message: describeFetchError(err) };
|
|
168
|
+
}
|
|
169
|
+
let text;
|
|
170
|
+
let bodyTooLarge;
|
|
171
|
+
try {
|
|
172
|
+
const body = await readBodyLimited(response, MAX_BODY_BYTES);
|
|
173
|
+
text = body.text;
|
|
174
|
+
bodyTooLarge = body.truncated;
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
return { kind: "network", message: describeFetchError(err) };
|
|
178
|
+
}
|
|
179
|
+
let parsed;
|
|
180
|
+
let parseFailed = false;
|
|
181
|
+
try {
|
|
182
|
+
parsed = JSON.parse(text);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
parseFailed = true;
|
|
186
|
+
}
|
|
187
|
+
const backendMessage = parseFailed ? "" : backendErrorMessage(parsed);
|
|
188
|
+
if (!response.ok)
|
|
189
|
+
return { kind: "http", status: response.status, backendMessage };
|
|
190
|
+
if (parseFailed) {
|
|
191
|
+
return { kind: "invalid-json", bodyTooLarge, sample: clamp(collapse(text), 100) };
|
|
192
|
+
}
|
|
193
|
+
// A truncated body that still parsed would be missing results silently.
|
|
194
|
+
if (bodyTooLarge)
|
|
195
|
+
return { kind: "invalid-json", bodyTooLarge: true, sample: "" };
|
|
196
|
+
if (backendMessage)
|
|
197
|
+
return { kind: "http", status: response.status, backendMessage };
|
|
198
|
+
return { kind: "ok", data: parse(parsed) };
|
|
199
|
+
}
|
|
4
200
|
export const webSearchTool = tool({
|
|
5
|
-
description:
|
|
201
|
+
description: `Search the web via Google. Returns ranked results with title, URL, source and a snippet. Use this for current information, news, documentation, or anything needing up-to-date knowledge. Today's date: ${new Date().toISOString().slice(0, 10)}. When the user asks about the latest or newest state of something, use the current year in the query or set time_range (day, week, month, year) — do not append an older year from your training knowledge. A few targeted queries plus fetching the best sources is enough; then produce the user's deliverable instead of searching exhaustively.`,
|
|
6
202
|
inputSchema: jsonSchema({
|
|
7
203
|
type: "object",
|
|
8
204
|
properties: {
|
|
9
205
|
query: { type: "string", description: "The search query" },
|
|
10
|
-
|
|
206
|
+
language: { type: "string", description: "Search language code, e.g. zh, en, zh-CN (default: en)" },
|
|
207
|
+
time_range: { type: "string", description: "Time range filter: day, week, month, year" },
|
|
208
|
+
max_results: {
|
|
209
|
+
type: "number",
|
|
210
|
+
description: `Maximum number of results to return (default ${DEFAULT_MAX_RESULTS}, max ${MAX_RESULTS_LIMIT})`,
|
|
211
|
+
},
|
|
11
212
|
},
|
|
12
213
|
required: ["query"],
|
|
13
214
|
}),
|
|
14
|
-
execute: async (
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return `Search error: HTTP ${response.status}`;
|
|
27
|
-
const data = await response.json();
|
|
28
|
-
const results = (data.results ?? []).slice(0, 10);
|
|
29
|
-
if (results.length === 0)
|
|
30
|
-
return "No search results found. Try a different query.";
|
|
31
|
-
const text = results
|
|
32
|
-
.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}\n ${r.content ?? ""}`)
|
|
33
|
-
.join("\n\n");
|
|
34
|
-
return truncateToolOutput(text, { direction: "head" }).content;
|
|
35
|
-
}
|
|
36
|
-
catch (err) {
|
|
37
|
-
return `Search error: ${err.message}`;
|
|
38
|
-
}
|
|
215
|
+
execute: async (input) => {
|
|
216
|
+
if (getEffectiveSandboxPolicy().network === "deny")
|
|
217
|
+
return `Search error: ${networkDeniedMessage()}`;
|
|
218
|
+
const query = (input.query ?? "").trim();
|
|
219
|
+
if (!query)
|
|
220
|
+
return 'Search error: "query" must be a non-empty string.';
|
|
221
|
+
const notes = [];
|
|
222
|
+
// SearXNG is only used when the user explicitly configures an instance;
|
|
223
|
+
// otherwise Serper is used.
|
|
224
|
+
if (searxngConfigured())
|
|
225
|
+
return runSearxng(input, query, notes);
|
|
226
|
+
return runSerper(input, query, notes);
|
|
39
227
|
},
|
|
40
228
|
});
|
package/dist/tools/write.js
CHANGED
|
@@ -1,32 +1,55 @@
|
|
|
1
1
|
import { tool, jsonSchema } from "ai";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
3
|
+
import { existsSync } from "fs";
|
|
3
4
|
import path from "path";
|
|
4
|
-
import { confirm,
|
|
5
|
+
import { confirm, isEditAutoApprove } from "../confirm.js";
|
|
6
|
+
import { pathAccessError } from "../sandbox.js";
|
|
7
|
+
import { atomicWriteFile } from "./atomic-file.js";
|
|
8
|
+
const MAX_WRITE_BYTES = 10 * 1024 * 1024;
|
|
5
9
|
export const writeTool = tool({
|
|
6
|
-
description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Creates parent directories as needed.",
|
|
10
|
+
description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does (unless `append` is true). Creates parent directories as needed.",
|
|
7
11
|
inputSchema: jsonSchema({
|
|
8
12
|
type: "object",
|
|
9
13
|
properties: {
|
|
10
14
|
filePath: { type: "string", description: "Path to the file to write (relative to cwd or absolute)" },
|
|
11
15
|
content: { type: "string", description: "The content to write to the file" },
|
|
16
|
+
append: { type: "boolean", description: "If true, append to the file instead of overwriting" },
|
|
12
17
|
},
|
|
13
18
|
required: ["filePath", "content"],
|
|
14
19
|
}),
|
|
15
|
-
execute: async ({ filePath, content }) => {
|
|
20
|
+
execute: async ({ filePath, content, append }) => {
|
|
21
|
+
if (Buffer.byteLength(content, "utf-8") > MAX_WRITE_BYTES) {
|
|
22
|
+
return `Error: content exceeds ${MAX_WRITE_BYTES} bytes. Write in smaller chunks or check the content size.`;
|
|
23
|
+
}
|
|
24
|
+
const denied = pathAccessError(filePath, "write");
|
|
25
|
+
if (denied)
|
|
26
|
+
return `Error: ${denied}`;
|
|
16
27
|
const resolved = path.resolve(process.cwd(), filePath);
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
// Same confirmation semantics as edit: in ask mode every mutation —
|
|
29
|
+
// create, overwrite or append — is confirmed; accept-edits skips them all.
|
|
30
|
+
if (!isEditAutoApprove()) {
|
|
31
|
+
const exists = existsSync(resolved);
|
|
32
|
+
const action = append
|
|
33
|
+
? `Append to file: ${filePath}`
|
|
34
|
+
: exists
|
|
35
|
+
? `Overwrite existing file: ${filePath}`
|
|
36
|
+
: `Create new file: ${filePath}`;
|
|
37
|
+
const approved = await confirm(action);
|
|
20
38
|
if (!approved)
|
|
21
39
|
return "Write rejected by user.";
|
|
22
40
|
}
|
|
23
41
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
42
|
+
await mkdir(path.dirname(resolved), { recursive: true });
|
|
43
|
+
if (append) {
|
|
44
|
+
await writeFile(resolved, content, { flag: "a", encoding: "utf-8" });
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
await atomicWriteFile(resolved, content);
|
|
48
|
+
}
|
|
49
|
+
return `Written ${Buffer.byteLength(content, "utf-8")} bytes to ${filePath}${append ? " (appended)" : ""}`;
|
|
27
50
|
}
|
|
28
51
|
catch (err) {
|
|
29
|
-
return `Error writing file: ${err.message}`;
|
|
52
|
+
return `Error writing file: ${err instanceof Error ? err.message : String(err)}`;
|
|
30
53
|
}
|
|
31
54
|
},
|
|
32
55
|
});
|