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.
Files changed (137) hide show
  1. package/README.md +242 -31
  2. package/dist/agent.js +1233 -485
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli/commands/chat.js +10 -0
  5. package/dist/cli/commands/exec.js +32 -0
  6. package/dist/cli/commands/history.js +58 -0
  7. package/dist/cli/commands/index.js +224 -0
  8. package/dist/cli/commands/init.js +18 -0
  9. package/dist/cli/commands/mcp.js +173 -0
  10. package/dist/cli/commands/memory.js +69 -0
  11. package/dist/cli/commands/models.js +21 -0
  12. package/dist/cli/commands/permission.js +12 -0
  13. package/dist/cli/commands/rules.js +33 -0
  14. package/dist/cli/commands/sandbox.js +13 -0
  15. package/dist/cli/commands/serve.js +9 -0
  16. package/dist/cli/commands/setup.js +4 -0
  17. package/dist/cli/commands/shared.js +16 -0
  18. package/dist/cli/commands/skills.js +119 -0
  19. package/dist/cli/commands/update.js +7 -0
  20. package/dist/cli/commands/write-config.js +30 -0
  21. package/dist/cli/errors.js +36 -0
  22. package/dist/cli/exec-prompt.js +26 -0
  23. package/dist/cli/option-helpers.js +53 -0
  24. package/dist/cli/program.js +180 -0
  25. package/dist/cli.js +7 -632
  26. package/dist/clipboard.js +59 -23
  27. package/dist/code-mode.js +35 -17
  28. package/dist/compaction.js +457 -169
  29. package/dist/config.js +298 -38
  30. package/dist/confirm.js +105 -9
  31. package/dist/context-window.js +156 -75
  32. package/dist/doom-loop.js +268 -26
  33. package/dist/fetch-timeout.js +152 -0
  34. package/dist/http-approvals.js +60 -0
  35. package/dist/http.js +119 -0
  36. package/dist/instructions.js +72 -33
  37. package/dist/logger.js +95 -0
  38. package/dist/markdown.js +35 -50
  39. package/dist/mcp.js +847 -102
  40. package/dist/memory.js +128 -45
  41. package/dist/output.js +42 -31
  42. package/dist/paste-handler.js +3 -3
  43. package/dist/permission-cli.js +43 -0
  44. package/dist/plugins.js +76 -11
  45. package/dist/pricing.js +119 -0
  46. package/dist/provider.js +34 -15
  47. package/dist/question-format.js +60 -0
  48. package/dist/sandbox-cli.js +82 -0
  49. package/dist/sandbox.js +403 -0
  50. package/dist/save-throttle.js +45 -0
  51. package/dist/serve/common.js +404 -0
  52. package/dist/serve/routes-chat.js +347 -0
  53. package/dist/serve/routes-mcp.js +212 -0
  54. package/dist/serve/routes-memory.js +66 -0
  55. package/dist/serve/routes-meta.js +205 -0
  56. package/dist/serve/routes-sessions.js +61 -0
  57. package/dist/serve/routes-skills.js +70 -0
  58. package/dist/serve.js +74 -635
  59. package/dist/sessions.js +197 -15
  60. package/dist/skills.js +531 -77
  61. package/dist/synthetic.js +7 -0
  62. package/dist/title-gen.js +9 -2
  63. package/dist/token-display.js +36 -0
  64. package/dist/tool-display.js +178 -0
  65. package/dist/tool-output.js +53 -46
  66. package/dist/tools/apply_patch.js +265 -0
  67. package/dist/tools/atomic-file.js +35 -0
  68. package/dist/tools/backend.js +61 -0
  69. package/dist/tools/bash.js +186 -71
  70. package/dist/tools/code_search.js +13 -6
  71. package/dist/tools/edit.js +26 -9
  72. package/dist/tools/explore.js +144 -16
  73. package/dist/tools/glob.js +7 -3
  74. package/dist/tools/grep.js +153 -14
  75. package/dist/tools/index.js +9 -24
  76. package/dist/tools/question.js +31 -30
  77. package/dist/tools/read.js +77 -15
  78. package/dist/tools/search-searxng.js +223 -0
  79. package/dist/tools/search-serper.js +189 -0
  80. package/dist/tools/task.js +100 -33
  81. package/dist/tools/todo.js +178 -67
  82. package/dist/tools/web_fetch.js +158 -46
  83. package/dist/tools/web_search.js +217 -29
  84. package/dist/tools/write.js +34 -11
  85. package/dist/tui/App.js +89 -6
  86. package/dist/tui/ConfirmBar.js +57 -4
  87. package/dist/tui/InputBar.js +504 -44
  88. package/dist/tui/MessageList.js +674 -20
  89. package/dist/tui/ModelPicker.js +113 -0
  90. package/dist/tui/QuestionBar.js +136 -0
  91. package/dist/tui/SessionPicker.js +79 -0
  92. package/dist/tui/StatusBar.js +14 -12
  93. package/dist/tui/agent-runner.js +223 -0
  94. package/dist/tui/caret-pos.js +177 -0
  95. package/dist/tui/caret.js +69 -0
  96. package/dist/tui/click-count.js +13 -0
  97. package/dist/tui/diff-view.js +61 -0
  98. package/dist/tui/drag-state.js +49 -0
  99. package/dist/tui/hydrate.js +129 -0
  100. package/dist/tui/index.js +189 -31
  101. package/dist/tui/input-history.js +125 -0
  102. package/dist/tui/layout.js +88 -0
  103. package/dist/tui/mouse.js +46 -0
  104. package/dist/tui/prompt-queue.js +24 -0
  105. package/dist/tui/selection.js +226 -0
  106. package/dist/tui/session-switch.js +28 -0
  107. package/dist/tui/slash-commands.js +106 -0
  108. package/dist/tui/slash-handler.js +545 -0
  109. package/dist/tui/text-width.js +113 -0
  110. package/dist/tui/theme.js +12 -0
  111. package/dist/tui/token-info.js +7 -0
  112. package/dist/tui/tool-children.js +19 -0
  113. package/dist/tui/undo-stack.js +14 -0
  114. package/dist/tui/use-sgr-mouse.js +29 -0
  115. package/dist/tui-chat.js +346 -330
  116. package/dist/updater.js +116 -0
  117. package/dist/xml-search.js +194 -0
  118. package/docs/API.md +410 -32
  119. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  120. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  121. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  122. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  123. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  124. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  125. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  126. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  127. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  128. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  129. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  130. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  131. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  132. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  133. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  134. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  135. package/package.json +12 -8
  136. package/skills/self-config/SKILL.md +90 -0
  137. package/skills/self-config/reference.md +149 -0
@@ -1,8 +1,20 @@
1
1
  import { tool, jsonSchema } from "ai";
2
2
  import { truncateToolOutput } from "../tool-output.js";
3
- const FIRECRAWL_BASE = "https://fireclawl.xc.lonae.com";
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 (or markdown for HTML pages). Supports JavaScript-rendered SPA pages via fallback.",
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
- // First try: direct fetch
16
- const directResult = await directFetch(url, method);
17
- // If we got meaningful content, return it
18
- if (directResult && hasContent(directResult)) {
19
- return directResult;
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
- // Fallback: use Firecrawl for SPA/dynamic pages
22
- const firecrawlResult = await firecrawlFetch(url);
23
- if (firecrawlResult)
24
- return firecrawlResult;
25
- // Return whatever we got from direct fetch
26
- return directResult || "Failed to fetch content from URL";
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
- const response = await fetch(url, {
32
- method: method ?? "GET",
106
+ response = await fetch(url, {
107
+ method,
33
108
  headers: {
34
- "User-Agent": "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",
35
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
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(15000),
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 null;
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
- const response = await fetch(`${FIRECRAWL_BASE}/v1/scrape`, {
139
+ response = await fetch(`${resolved.base}/v1/scrape`, {
51
140
  method: "POST",
52
141
  headers: { "Content-Type": "application/json" },
53
- body: JSON.stringify({
54
- url,
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
- if (!response.ok)
63
- return null;
64
- const data = await response.json();
65
- const markdown = data?.data?.markdown || data?.data?.content;
66
- if (!markdown)
67
- return null;
68
- return truncateToolOutput(markdown, { direction: "head" }).content;
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 null;
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
- function hasContent(html) {
75
- // Check if the response has meaningful content (not just an empty SPA shell)
76
- if (html.length < 200)
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
- // SPA shells typically have very little text content outside of script tags
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
  }
@@ -1,40 +1,228 @@
1
1
  import { tool, jsonSchema } from "ai";
2
- import { truncateToolOutput } from "../tool-output.js";
3
- const SEARXNG_BASE = "https://searxng.xc.lonae.com";
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: "Search the web for information. Returns search results with titles, URLs, and snippets. Use this when you need current information, news, documentation, or answers that require up-to-date knowledge.",
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
- categories: { type: "string", description: "Search categories: general, news, images, science, it (default: general)" },
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 ({ query, categories }) => {
15
- try {
16
- const params = new URLSearchParams({
17
- q: query,
18
- format: "json",
19
- categories: categories ?? "general",
20
- });
21
- const response = await fetch(`${SEARXNG_BASE}/search?${params}`, {
22
- headers: { "Accept": "application/json" },
23
- signal: AbortSignal.timeout(15000),
24
- });
25
- if (!response.ok)
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
  });
@@ -1,32 +1,55 @@
1
1
  import { tool, jsonSchema } from "ai";
2
- import { writeFileSync, mkdirSync, existsSync } from "fs";
2
+ import { mkdir, writeFile } from "fs/promises";
3
+ import { existsSync } from "fs";
3
4
  import path from "path";
4
- import { confirm, isAutoApprove } from "../confirm.js";
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
- // Confirm overwriting existing files
18
- if (!isAutoApprove() && existsSync(resolved)) {
19
- const approved = await confirm(`Overwrite existing file: ${filePath}`);
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
- mkdirSync(path.dirname(resolved), { recursive: true });
25
- writeFileSync(resolved, content, "utf-8");
26
- return `Written ${content.length} bytes to ${filePath}`;
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
  });