min-agent 0.2.0 → 0.3.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 (81) hide show
  1. package/README.md +146 -18
  2. package/dist/agent.js +293 -408
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli.js +403 -140
  5. package/dist/clipboard.js +59 -23
  6. package/dist/code-mode.js +3 -3
  7. package/dist/compaction.js +182 -81
  8. package/dist/config.js +186 -35
  9. package/dist/confirm.js +55 -6
  10. package/dist/context-window.js +67 -54
  11. package/dist/doom-loop.js +19 -12
  12. package/dist/http.js +119 -0
  13. package/dist/instructions.js +51 -33
  14. package/dist/logger.js +66 -0
  15. package/dist/markdown.js +3 -44
  16. package/dist/mcp.js +547 -100
  17. package/dist/memory.js +48 -6
  18. package/dist/output.js +36 -27
  19. package/dist/paste-handler.js +3 -3
  20. package/dist/plugins.js +33 -6
  21. package/dist/pricing.js +119 -0
  22. package/dist/provider.js +17 -15
  23. package/dist/serve.js +658 -369
  24. package/dist/sessions.js +151 -13
  25. package/dist/skills.js +466 -76
  26. package/dist/synthetic.js +7 -0
  27. package/dist/title-gen.js +2 -1
  28. package/dist/tool-display.js +173 -0
  29. package/dist/tool-output.js +54 -45
  30. package/dist/tools/apply_patch.js +191 -0
  31. package/dist/tools/backend.js +61 -0
  32. package/dist/tools/bash.js +147 -70
  33. package/dist/tools/code_search.js +6 -5
  34. package/dist/tools/edit.js +23 -7
  35. package/dist/tools/explore.js +80 -12
  36. package/dist/tools/glob.js +3 -3
  37. package/dist/tools/grep.js +146 -14
  38. package/dist/tools/index.js +7 -7
  39. package/dist/tools/question.js +4 -22
  40. package/dist/tools/read.js +71 -11
  41. package/dist/tools/task.js +33 -20
  42. package/dist/tools/todo.js +83 -73
  43. package/dist/tools/web_fetch.js +150 -46
  44. package/dist/tools/web_search.js +706 -28
  45. package/dist/tools/write.js +13 -7
  46. package/dist/tui/App.js +40 -6
  47. package/dist/tui/ConfirmBar.js +24 -3
  48. package/dist/tui/InputBar.js +390 -45
  49. package/dist/tui/MessageList.js +533 -20
  50. package/dist/tui/ModelPicker.js +108 -0
  51. package/dist/tui/QuestionBar.js +104 -0
  52. package/dist/tui/StatusBar.js +19 -11
  53. package/dist/tui/agent-runner.js +103 -0
  54. package/dist/tui/caret-pos.js +134 -0
  55. package/dist/tui/caret.js +69 -0
  56. package/dist/tui/diff-view.js +61 -0
  57. package/dist/tui/drag-state.js +44 -0
  58. package/dist/tui/index.js +153 -24
  59. package/dist/tui/input-history.js +44 -0
  60. package/dist/tui/layout.js +17 -0
  61. package/dist/tui/mouse.js +46 -0
  62. package/dist/tui/selection.js +134 -0
  63. package/dist/tui/slash-commands.js +90 -0
  64. package/dist/tui/slash-handler.js +370 -0
  65. package/dist/tui/text-width.js +91 -0
  66. package/dist/tui/theme.js +12 -0
  67. package/dist/tui/undo-stack.js +14 -0
  68. package/dist/tui/use-sgr-mouse.js +27 -0
  69. package/dist/tui-chat.js +111 -331
  70. package/dist/updater.js +57 -0
  71. package/docs/API.md +160 -14
  72. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  73. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  74. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  75. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  76. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  77. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  78. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  79. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  80. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  81. package/package.json +7 -8
@@ -1,8 +1,19 @@
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
+ export const FIRECRAWL_BACKEND = {
7
+ envName: "MIN_AGENT_FIRECRAWL_URL",
8
+ configKey: "webFetchURL",
9
+ fallback: "https://fireclawl.xc.lonae.com",
10
+ };
11
+ const MAX_BODY_BYTES = 2 * 1024 * 1024;
12
+ const DIRECT_TIMEOUT_MS = 8000;
13
+ const SCRAPE_TIMEOUT_MS = 35000;
14
+ 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
15
  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.",
16
+ 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).",
6
17
  inputSchema: jsonSchema({
7
18
  type: "object",
8
19
  properties: {
@@ -12,72 +23,165 @@ export const webFetchTool = tool({
12
23
  required: ["url"],
13
24
  }),
14
25
  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;
26
+ const target = (url ?? "").trim();
27
+ if (!target)
28
+ return 'Error: "url" must be a non-empty string.';
29
+ const blocked = await assertSafeUrl(target);
30
+ if (blocked)
31
+ return `Error: ${blocked}`;
32
+ const verb = (method ?? "GET").trim().toUpperCase() || "GET";
33
+ const direct = await directFetch(target, verb);
34
+ // Plain text / JSON responses are what the caller asked for; return them
35
+ // without paying for a scrape round trip.
36
+ if (direct.kind === "ok" && !direct.html && hasContent(direct.text)) {
37
+ return renderBody(direct.text, direct.bodyTruncated);
20
38
  }
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";
39
+ if (direct.kind === "binary") {
40
+ return `Error: the response is not text (Content-Type: ${direct.contentType}). Use a different tool to download binary content.`;
41
+ }
42
+ // A non-GET request cannot be replayed by the scrape backend (it always
43
+ // performs a GET), so report the direct outcome as-is.
44
+ if (verb !== "GET") {
45
+ if (direct.kind === "ok")
46
+ return renderBody(direct.text, direct.bodyTruncated);
47
+ return `Error: ${describeDirect(direct, target)}`;
48
+ }
49
+ // HTML (or an empty/JS-rendered shell): the scrape backend turns it into
50
+ // markdown, which is both cheaper and more readable than raw markup.
51
+ const scraped = await firecrawlFetch(target);
52
+ if (scraped.kind === "ok")
53
+ return renderBody(scraped.markdown, scraped.bodyTruncated);
54
+ if (direct.kind === "ok" && hasContent(direct.text)) {
55
+ return [
56
+ `Note: returning raw HTML — the scrape backend could not convert this page (${scraped.reason}).`,
57
+ renderBody(direct.text, direct.bodyTruncated),
58
+ ].join("\n\n");
59
+ }
60
+ return [
61
+ `Failed to fetch content from ${target}.`,
62
+ `Direct request: ${describeDirect(direct, target)}`,
63
+ `Scrape backend: ${scraped.reason}`,
64
+ configHint(FIRECRAWL_BACKEND),
65
+ ].join("\n");
27
66
  },
28
67
  });
68
+ function renderBody(text, bodyTruncated) {
69
+ const { content } = truncateToolOutput(text, { direction: "head" });
70
+ // readBodyLimited cutting at MAX_BODY_BYTES is a different truncation from
71
+ // truncateToolOutput's (which persists the full text), so disclose it.
72
+ if (!bodyTruncated)
73
+ return content;
74
+ return `${content}\n\n...(response body was cut off at ${MAX_BODY_BYTES / 1024 / 1024} MB)`;
75
+ }
76
+ function describeDirect(outcome, url) {
77
+ switch (outcome.kind) {
78
+ case "ok":
79
+ return `HTTP 200 but the body had almost no readable content (${url} may require JavaScript)`;
80
+ case "binary":
81
+ return `non-text response (${outcome.contentType})`;
82
+ case "http":
83
+ return `HTTP ${outcome.status}${outcome.statusText ? ` ${outcome.statusText}` : ""}`;
84
+ case "network":
85
+ return outcome.message;
86
+ }
87
+ }
88
+ function isHtml(contentType) {
89
+ return /html|xhtml/i.test(contentType);
90
+ }
91
+ /** Content types worth handing to the model as text. */
92
+ function isTextual(contentType) {
93
+ if (!contentType)
94
+ return true; // Many APIs omit it; the NUL sniff below catches binaries.
95
+ const ct = contentType.toLowerCase();
96
+ if (ct.startsWith("text/"))
97
+ return true;
98
+ return /(json|xml|javascript|ecmascript|yaml|csv|graphql|x-www-form-urlencoded|\+json)/.test(ct);
99
+ }
29
100
  async function directFetch(url, method) {
101
+ let response;
30
102
  try {
31
- const response = await fetch(url, {
32
- method: method ?? "GET",
103
+ response = await fetch(url, {
104
+ method,
33
105
  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",
106
+ "User-Agent": BROWSER_UA,
107
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,application/json;q=0.9,*/*;q=0.8",
36
108
  },
37
- signal: AbortSignal.timeout(15000),
109
+ signal: AbortSignal.timeout(DIRECT_TIMEOUT_MS),
38
110
  });
39
- if (!response.ok)
40
- return `HTTP ${response.status}`;
41
- const text = await response.text();
42
- return truncateToolOutput(text, { direction: "head" }).content;
43
111
  }
44
112
  catch (err) {
45
- return null;
113
+ return { kind: "network", message: describeFetchError(err) };
114
+ }
115
+ const contentType = response.headers.get("content-type") ?? "";
116
+ if (!response.ok)
117
+ return { kind: "http", status: response.status, statusText: response.statusText };
118
+ if (!isTextual(contentType))
119
+ return { kind: "binary", contentType };
120
+ try {
121
+ const { text, truncated } = await readBodyLimited(response, MAX_BODY_BYTES);
122
+ if (text.includes("\u0000"))
123
+ return { kind: "binary", contentType: contentType || "unknown (binary)" };
124
+ return { kind: "ok", text, bodyTruncated: truncated, html: isHtml(contentType) || looksLikeHtml(text) };
125
+ }
126
+ catch (err) {
127
+ return { kind: "network", message: describeFetchError(err) };
46
128
  }
47
129
  }
48
130
  async function firecrawlFetch(url) {
131
+ const resolved = resolveBackendBase(FIRECRAWL_BACKEND, loadConfig().webFetchURL);
132
+ if (!resolved.ok)
133
+ return { kind: "unavailable", reason: resolved.error };
134
+ let response;
49
135
  try {
50
- const response = await fetch(`${FIRECRAWL_BASE}/v1/scrape`, {
136
+ response = await fetch(`${resolved.base}/v1/scrape`, {
51
137
  method: "POST",
52
138
  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),
139
+ body: JSON.stringify({ url, formats: ["markdown"], onlyMainContent: true, waitFor: 3000, timeout: 30000 }),
140
+ signal: AbortSignal.timeout(SCRAPE_TIMEOUT_MS),
61
141
  });
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;
142
+ }
143
+ catch (err) {
144
+ return { kind: "unavailable", reason: `could not reach the scrape backend (${describeFetchError(err)})` };
145
+ }
146
+ let text;
147
+ let truncated;
148
+ try {
149
+ const body = await readBodyLimited(response, MAX_BODY_BYTES);
150
+ text = body.text;
151
+ truncated = body.truncated;
152
+ }
153
+ catch (err) {
154
+ return { kind: "unavailable", reason: `could not read the scrape response (${describeFetchError(err)})` };
155
+ }
156
+ let data;
157
+ try {
158
+ data = JSON.parse(text);
69
159
  }
70
160
  catch {
71
- return null;
161
+ return {
162
+ kind: "unavailable",
163
+ reason: response.ok ? "the scrape backend returned a non-JSON response" : `HTTP ${response.status} from the scrape backend`,
164
+ };
72
165
  }
166
+ const record = typeof data === "object" && data !== null ? data : {};
167
+ if (!response.ok) {
168
+ const message = typeof record.error === "string" ? record.error : response.statusText;
169
+ return { kind: "unavailable", reason: `HTTP ${response.status} from the scrape backend${message ? ` (${message})` : ""}` };
170
+ }
171
+ const payload = typeof record.data === "object" && record.data !== null ? record.data : {};
172
+ const markdown = typeof payload.markdown === "string" ? payload.markdown : typeof payload.content === "string" ? payload.content : "";
173
+ if (!markdown.trim())
174
+ return { kind: "unavailable", reason: "the scrape backend returned no content for this URL" };
175
+ return { kind: "ok", markdown, bodyTruncated: truncated };
176
+ }
177
+ function looksLikeHtml(body) {
178
+ return /^\s*(<!doctype html|<html\b|<head\b)/i.test(body);
73
179
  }
74
- function hasContent(html) {
75
- // Check if the response has meaningful content (not just an empty SPA shell)
76
- if (html.length < 200)
180
+ /** Whether a body carries enough readable text to be worth returning as-is. */
181
+ function hasContent(body) {
182
+ if (body.trim().length < 200)
77
183
  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, "");
184
+ const withoutScripts = body.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "");
80
185
  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
186
  return textContent.length > 100;
83
187
  }