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.
- package/README.md +146 -18
- package/dist/agent.js +293 -408
- package/dist/assistant-stream.js +11 -7
- package/dist/cli.js +403 -140
- package/dist/clipboard.js +59 -23
- package/dist/code-mode.js +3 -3
- package/dist/compaction.js +182 -81
- package/dist/config.js +186 -35
- package/dist/confirm.js +55 -6
- package/dist/context-window.js +67 -54
- package/dist/doom-loop.js +19 -12
- package/dist/http.js +119 -0
- package/dist/instructions.js +51 -33
- package/dist/logger.js +66 -0
- package/dist/markdown.js +3 -44
- package/dist/mcp.js +547 -100
- package/dist/memory.js +48 -6
- package/dist/output.js +36 -27
- package/dist/paste-handler.js +3 -3
- package/dist/plugins.js +33 -6
- package/dist/pricing.js +119 -0
- package/dist/provider.js +17 -15
- package/dist/serve.js +658 -369
- package/dist/sessions.js +151 -13
- package/dist/skills.js +466 -76
- package/dist/synthetic.js +7 -0
- package/dist/title-gen.js +2 -1
- package/dist/tool-display.js +173 -0
- package/dist/tool-output.js +54 -45
- package/dist/tools/apply_patch.js +191 -0
- package/dist/tools/backend.js +61 -0
- package/dist/tools/bash.js +147 -70
- package/dist/tools/code_search.js +6 -5
- package/dist/tools/edit.js +23 -7
- package/dist/tools/explore.js +80 -12
- package/dist/tools/glob.js +3 -3
- package/dist/tools/grep.js +146 -14
- package/dist/tools/index.js +7 -7
- package/dist/tools/question.js +4 -22
- package/dist/tools/read.js +71 -11
- package/dist/tools/task.js +33 -20
- package/dist/tools/todo.js +83 -73
- package/dist/tools/web_fetch.js +150 -46
- package/dist/tools/web_search.js +706 -28
- package/dist/tools/write.js +13 -7
- package/dist/tui/App.js +40 -6
- package/dist/tui/ConfirmBar.js +24 -3
- package/dist/tui/InputBar.js +390 -45
- package/dist/tui/MessageList.js +533 -20
- package/dist/tui/ModelPicker.js +108 -0
- package/dist/tui/QuestionBar.js +104 -0
- package/dist/tui/StatusBar.js +19 -11
- package/dist/tui/agent-runner.js +103 -0
- package/dist/tui/caret-pos.js +134 -0
- package/dist/tui/caret.js +69 -0
- package/dist/tui/diff-view.js +61 -0
- package/dist/tui/drag-state.js +44 -0
- package/dist/tui/index.js +153 -24
- package/dist/tui/input-history.js +44 -0
- package/dist/tui/layout.js +17 -0
- package/dist/tui/mouse.js +46 -0
- package/dist/tui/selection.js +134 -0
- package/dist/tui/slash-commands.js +90 -0
- package/dist/tui/slash-handler.js +370 -0
- package/dist/tui/text-width.js +91 -0
- package/dist/tui/theme.js +12 -0
- package/dist/tui/undo-stack.js +14 -0
- package/dist/tui/use-sgr-mouse.js +27 -0
- package/dist/tui-chat.js +111 -331
- package/dist/updater.js +57 -0
- package/docs/API.md +160 -14
- 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/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/package.json +7 -8
package/dist/tools/web_fetch.js
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
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
|
+
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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
|
|
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
|
-
|
|
32
|
-
method
|
|
103
|
+
response = await fetch(url, {
|
|
104
|
+
method,
|
|
33
105
|
headers: {
|
|
34
|
-
"User-Agent":
|
|
35
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
136
|
+
response = await fetch(`${resolved.base}/v1/scrape`, {
|
|
51
137
|
method: "POST",
|
|
52
138
|
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),
|
|
139
|
+
body: JSON.stringify({ url, formats: ["markdown"], onlyMainContent: true, waitFor: 3000, timeout: 30000 }),
|
|
140
|
+
signal: AbortSignal.timeout(SCRAPE_TIMEOUT_MS),
|
|
61
141
|
});
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
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
|
-
|
|
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
|
}
|