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
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-line summaries of tool calls and their results, shared by the TUI
|
|
3
|
+
* (`⚡ search_web "query" (news)`) and the plain CLI output. Keeping both
|
|
4
|
+
* surfaces on this module means a tool only needs a summary rule once.
|
|
5
|
+
*
|
|
6
|
+
* Summaries are neutral/English because tool names, arguments and paths are.
|
|
7
|
+
*/
|
|
8
|
+
/** Truncate by code point (not UTF-16 unit) so surrogate pairs never split. */
|
|
9
|
+
export function truncateDisplay(text, max) {
|
|
10
|
+
const chars = Array.from(text);
|
|
11
|
+
if (chars.length <= max)
|
|
12
|
+
return text;
|
|
13
|
+
return chars.slice(0, Math.max(1, max - 1)).join("") + "…";
|
|
14
|
+
}
|
|
15
|
+
/** Collapse whitespace so a multi-line value stays on one row. */
|
|
16
|
+
function oneLine(text) {
|
|
17
|
+
return text.replace(/\s+/g, " ").trim();
|
|
18
|
+
}
|
|
19
|
+
function asRecord(input) {
|
|
20
|
+
return typeof input === "object" && input !== null && !Array.isArray(input)
|
|
21
|
+
? input
|
|
22
|
+
: null;
|
|
23
|
+
}
|
|
24
|
+
function str(rec, key) {
|
|
25
|
+
const v = rec[key];
|
|
26
|
+
return typeof v === "string" ? oneLine(v) : "";
|
|
27
|
+
}
|
|
28
|
+
/** Shorten a path to its last two segments (`src/tools/web_search.ts` → `tools/web_search.ts`). */
|
|
29
|
+
function shortPath(p) {
|
|
30
|
+
const parts = p.split("/").filter(Boolean);
|
|
31
|
+
return parts.length <= 2 ? p : `…/${parts.slice(-2).join("/")}`;
|
|
32
|
+
}
|
|
33
|
+
/** Generic `key=value` rendering, used for tools without a specific rule. */
|
|
34
|
+
export function formatToolArgs(input, maxValueLen = 60) {
|
|
35
|
+
const rec = asRecord(input);
|
|
36
|
+
if (!rec)
|
|
37
|
+
return "";
|
|
38
|
+
const parts = [];
|
|
39
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
40
|
+
if (v === undefined || v === null)
|
|
41
|
+
continue;
|
|
42
|
+
const raw = typeof v === "string" ? oneLine(v) : JSON.stringify(v);
|
|
43
|
+
if (raw === undefined)
|
|
44
|
+
continue;
|
|
45
|
+
parts.push(`${k}=${truncateDisplay(raw, maxValueLen)}`);
|
|
46
|
+
}
|
|
47
|
+
return parts.join(" ");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Summarize a tool call for a single row. `maxLen` bounds the whole summary;
|
|
51
|
+
* the caller still truncates to the terminal width.
|
|
52
|
+
*/
|
|
53
|
+
export function summarizeToolCall(toolName, input, maxLen = 120) {
|
|
54
|
+
const rec = asRecord(input);
|
|
55
|
+
if (!rec)
|
|
56
|
+
return "";
|
|
57
|
+
const summary = specificCallSummary(toolName, rec);
|
|
58
|
+
return truncateDisplay(summary || formatToolArgs(rec, 60), maxLen);
|
|
59
|
+
}
|
|
60
|
+
function specificCallSummary(toolName, rec) {
|
|
61
|
+
switch (toolName) {
|
|
62
|
+
case "search_web": {
|
|
63
|
+
const query = str(rec, "query");
|
|
64
|
+
if (!query)
|
|
65
|
+
return "";
|
|
66
|
+
const facets = [];
|
|
67
|
+
const categories = str(rec, "categories");
|
|
68
|
+
if (categories && categories !== "general")
|
|
69
|
+
facets.push(categories);
|
|
70
|
+
const language = str(rec, "language");
|
|
71
|
+
if (language)
|
|
72
|
+
facets.push(language);
|
|
73
|
+
const range = str(rec, "time_range");
|
|
74
|
+
if (range)
|
|
75
|
+
facets.push(`past ${range}`);
|
|
76
|
+
const engines = str(rec, "engines");
|
|
77
|
+
if (engines)
|
|
78
|
+
facets.push(`via ${engines}`);
|
|
79
|
+
return `"${query}"${facets.length > 0 ? ` (${facets.join(", ")})` : ""}`;
|
|
80
|
+
}
|
|
81
|
+
case "web_fetch": {
|
|
82
|
+
const url = str(rec, "url");
|
|
83
|
+
const method = str(rec, "method");
|
|
84
|
+
return url ? `${method && method.toUpperCase() !== "GET" ? `${method.toUpperCase()} ` : ""}${url}` : "";
|
|
85
|
+
}
|
|
86
|
+
case "read": {
|
|
87
|
+
const p = str(rec, "filePath");
|
|
88
|
+
if (!p)
|
|
89
|
+
return "";
|
|
90
|
+
const start = rec.startLine;
|
|
91
|
+
const end = rec.endLine;
|
|
92
|
+
const range = typeof start === "number" || typeof end === "number" ? `:${start ?? 1}-${end ?? ""}` : "";
|
|
93
|
+
return `${shortPath(p)}${range}`;
|
|
94
|
+
}
|
|
95
|
+
case "write":
|
|
96
|
+
case "edit":
|
|
97
|
+
case "apply_patch": {
|
|
98
|
+
const p = str(rec, "filePath") || str(rec, "path");
|
|
99
|
+
return p ? shortPath(p) : "";
|
|
100
|
+
}
|
|
101
|
+
case "bash":
|
|
102
|
+
return str(rec, "command");
|
|
103
|
+
case "grep": {
|
|
104
|
+
const pattern = str(rec, "pattern");
|
|
105
|
+
if (!pattern)
|
|
106
|
+
return "";
|
|
107
|
+
const where = str(rec, "path");
|
|
108
|
+
const include = str(rec, "include");
|
|
109
|
+
const scope = [where, include].filter(Boolean).join(" ");
|
|
110
|
+
return `${pattern}${scope ? ` in ${scope}` : ""}`;
|
|
111
|
+
}
|
|
112
|
+
case "glob":
|
|
113
|
+
return str(rec, "pattern");
|
|
114
|
+
case "todo": {
|
|
115
|
+
const todos = rec.todos;
|
|
116
|
+
return Array.isArray(todos) ? `${todos.length} item${todos.length === 1 ? "" : "s"}` : "";
|
|
117
|
+
}
|
|
118
|
+
case "skill":
|
|
119
|
+
return str(rec, "name");
|
|
120
|
+
case "question":
|
|
121
|
+
return str(rec, "question");
|
|
122
|
+
case "task":
|
|
123
|
+
return str(rec, "description");
|
|
124
|
+
case "explore":
|
|
125
|
+
case "codesearch":
|
|
126
|
+
return str(rec, "query");
|
|
127
|
+
default:
|
|
128
|
+
return "";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Text form of a tool result, for the expanded view. */
|
|
132
|
+
export function toolResultText(output) {
|
|
133
|
+
if (typeof output === "string")
|
|
134
|
+
return output;
|
|
135
|
+
if (output === undefined || output === null)
|
|
136
|
+
return "";
|
|
137
|
+
try {
|
|
138
|
+
return JSON.stringify(output, null, 2);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return String(output);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* One-line result summary. `isError` comes from the tool-error stream event;
|
|
146
|
+
* error strings produced by tools themselves are also recognized.
|
|
147
|
+
*/
|
|
148
|
+
export function summarizeToolResult(toolName, output, isError = false, maxLen = 100) {
|
|
149
|
+
const text = toolResultText(output).trim();
|
|
150
|
+
if (text === "")
|
|
151
|
+
return isError ? "error" : "(empty)";
|
|
152
|
+
const firstLine = oneLine(text.split("\n", 1)[0] ?? "");
|
|
153
|
+
if (isError || /^(error|search error)\b/i.test(firstLine)) {
|
|
154
|
+
return truncateDisplay(firstLine.replace(/^Error:\s*/i, ""), maxLen);
|
|
155
|
+
}
|
|
156
|
+
if (toolName === "search_web") {
|
|
157
|
+
const found = /^Found (\d+) results? for /m.exec(text);
|
|
158
|
+
if (found)
|
|
159
|
+
return `${found[1]} results`;
|
|
160
|
+
if (/^No search results found/m.test(text))
|
|
161
|
+
return "no results";
|
|
162
|
+
}
|
|
163
|
+
if (toolName === "skill") {
|
|
164
|
+
const loaded = /^<skill_content name="([^"]*)"/m.exec(text);
|
|
165
|
+
if (loaded)
|
|
166
|
+
return truncateDisplay(`loaded ${loaded[1]} (${text.split("\n").length} lines)`, maxLen);
|
|
167
|
+
return truncateDisplay(firstLine, maxLen);
|
|
168
|
+
}
|
|
169
|
+
const lineCount = text.split("\n").length;
|
|
170
|
+
if (lineCount === 1)
|
|
171
|
+
return truncateDisplay(firstLine, maxLen);
|
|
172
|
+
return truncateDisplay(`${firstLine} (${lineCount} lines)`, maxLen);
|
|
173
|
+
}
|
package/dist/tool-output.js
CHANGED
|
@@ -6,13 +6,18 @@ import { getConfigDir } from "./config.js";
|
|
|
6
6
|
export const TOOL_OUTPUT_MAX_LINES = 2000;
|
|
7
7
|
export const TOOL_OUTPUT_MAX_BYTES = 50 * 1024;
|
|
8
8
|
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
9
|
+
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
|
|
10
|
+
let lastCleanup = 0;
|
|
9
11
|
function toolOutputDir() {
|
|
10
12
|
return path.join(getConfigDir(), "tool-output");
|
|
11
13
|
}
|
|
12
14
|
function cleanupOldToolOutputs(dir) {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
if (now - lastCleanup < CLEANUP_INTERVAL_MS)
|
|
17
|
+
return;
|
|
18
|
+
lastCleanup = now;
|
|
13
19
|
if (!existsSync(dir))
|
|
14
20
|
return;
|
|
15
|
-
const now = Date.now();
|
|
16
21
|
try {
|
|
17
22
|
for (const f of readdirSync(dir)) {
|
|
18
23
|
if (!f.startsWith("tool-") || !f.endsWith(".txt"))
|
|
@@ -42,60 +47,64 @@ export function writeFullToolOutput(fullText) {
|
|
|
42
47
|
return filePath;
|
|
43
48
|
}
|
|
44
49
|
const hint = (filePath) => `The tool output was truncated. Full output saved to: ${filePath}\nUse the read tool with startLine/endLine, or grep, to inspect further.`;
|
|
45
|
-
/** Keep end of text within line/byte limits (good for shell logs). */
|
|
46
|
-
|
|
47
|
-
const lines = text.split("\n");
|
|
48
|
-
const totalBytes = Buffer.byteLength(text, "utf-8");
|
|
50
|
+
/** Keep start or end of text within line/byte limits (good for shell logs / files). */
|
|
51
|
+
function preview(text, maxLines, maxBytes, direction, lines, totalBytes) {
|
|
49
52
|
if (lines.length <= maxLines && totalBytes <= maxBytes) {
|
|
50
53
|
return { text, cut: false };
|
|
51
54
|
}
|
|
52
55
|
const out = [];
|
|
53
56
|
let bytes = 0;
|
|
54
|
-
|
|
55
|
-
const size = Buffer.byteLength(lines[i], "utf-8") + (
|
|
56
|
-
if (bytes + size > maxBytes)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
57
|
+
const pushLine = (i, first) => {
|
|
58
|
+
const size = Buffer.byteLength(lines[i], "utf-8") + (first ? 0 : 1);
|
|
59
|
+
if (bytes + size > maxBytes)
|
|
60
|
+
return false;
|
|
61
|
+
if (direction === "head")
|
|
62
|
+
out.push(lines[i]);
|
|
63
|
+
else
|
|
64
|
+
out.unshift(lines[i]);
|
|
65
|
+
bytes += size;
|
|
66
|
+
return true;
|
|
67
|
+
};
|
|
68
|
+
if (direction === "head") {
|
|
69
|
+
for (let i = 0; i < lines.length && out.length < maxLines; i++) {
|
|
70
|
+
if (!pushLine(i, out.length === 0)) {
|
|
71
|
+
if (out.length === 0) {
|
|
72
|
+
const buf = Buffer.from(lines[i], "utf-8");
|
|
73
|
+
let end = Math.min(maxBytes, buf.length);
|
|
74
|
+
while (end > 0 && (buf[end] & 0xc0) === 0x80)
|
|
75
|
+
end--;
|
|
76
|
+
out.push(buf.subarray(0, end).toString("utf-8"));
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
65
79
|
}
|
|
66
|
-
break;
|
|
67
80
|
}
|
|
68
|
-
out.unshift(lines[i]);
|
|
69
|
-
bytes += size;
|
|
70
81
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (bytes + size > maxBytes) {
|
|
85
|
-
if (out.length === 0) {
|
|
86
|
-
const buf = Buffer.from(lines[i], "utf-8");
|
|
87
|
-
let end = Math.min(maxBytes, buf.length);
|
|
88
|
-
while (end > 0 && (buf[end - 1] & 0xc0) === 0x80)
|
|
89
|
-
end--;
|
|
90
|
-
out.push(buf.subarray(0, end).toString("utf-8"));
|
|
82
|
+
else {
|
|
83
|
+
for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
|
|
84
|
+
if (!pushLine(i, out.length === 0)) {
|
|
85
|
+
if (out.length === 0) {
|
|
86
|
+
const buf = Buffer.from(lines[i], "utf-8");
|
|
87
|
+
let start = buf.length - maxBytes;
|
|
88
|
+
if (start < 0)
|
|
89
|
+
start = 0;
|
|
90
|
+
while (start < buf.length && (buf[start] & 0xc0) === 0x80)
|
|
91
|
+
start++;
|
|
92
|
+
out.unshift(buf.subarray(start).toString("utf-8"));
|
|
93
|
+
}
|
|
94
|
+
break;
|
|
91
95
|
}
|
|
92
|
-
break;
|
|
93
96
|
}
|
|
94
|
-
out.push(lines[i]);
|
|
95
|
-
bytes += size;
|
|
96
97
|
}
|
|
97
98
|
return { text: out.join("\n"), cut: true };
|
|
98
99
|
}
|
|
100
|
+
/** Keep end of text within line/byte limits (good for shell logs). */
|
|
101
|
+
export function tailPreview(text, maxLines, maxBytes) {
|
|
102
|
+
return preview(text, maxLines, maxBytes, "tail", text.split("\n"), Buffer.byteLength(text, "utf-8"));
|
|
103
|
+
}
|
|
104
|
+
/** Keep start of text within line/byte limits (good for files / HTTP bodies). */
|
|
105
|
+
export function headPreview(text, maxLines, maxBytes) {
|
|
106
|
+
return preview(text, maxLines, maxBytes, "head", text.split("\n"), Buffer.byteLength(text, "utf-8"));
|
|
107
|
+
}
|
|
99
108
|
/**
|
|
100
109
|
* If text exceeds limits, write full text to disk and return a preview + path hint.
|
|
101
110
|
* Otherwise returns the original string.
|
|
@@ -110,10 +119,10 @@ export function truncateToolOutput(text, options = {}) {
|
|
|
110
119
|
return { content: text, truncated: false };
|
|
111
120
|
}
|
|
112
121
|
const filePath = writeFullToolOutput(text);
|
|
113
|
-
const
|
|
122
|
+
const pv = preview(text, maxLines, maxBytes, direction, lines, totalBytes);
|
|
114
123
|
const header = "...output truncated...\n\n";
|
|
115
124
|
const content = direction === "tail"
|
|
116
|
-
? `${header}${hint(filePath)}\n\n${
|
|
117
|
-
: `${
|
|
125
|
+
? `${header}${hint(filePath)}\n\n${pv.text}`
|
|
126
|
+
: `${pv.text}\n\n${header}${hint(filePath)}`;
|
|
118
127
|
return { content, truncated: true, outputPath: filePath };
|
|
119
128
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { tool, jsonSchema } from "ai";
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { confirm, isAutoApprove } from "../confirm.js";
|
|
5
|
+
const OFFSET_TOLERANCE = 3;
|
|
6
|
+
export function parseUnifiedDiff(diff) {
|
|
7
|
+
const files = [];
|
|
8
|
+
let current = null;
|
|
9
|
+
let currentHunk = null;
|
|
10
|
+
let pendingHeader = null;
|
|
11
|
+
let remainingOld = 0;
|
|
12
|
+
let remainingNew = 0;
|
|
13
|
+
const lines = diff.replace(/\r\n/g, "\n").split("\n");
|
|
14
|
+
for (const raw of lines) {
|
|
15
|
+
if (currentHunk === null && raw.startsWith("--- ")) {
|
|
16
|
+
pendingHeader = raw.slice(4);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (currentHunk === null && pendingHeader !== null && raw.startsWith("+++ ")) {
|
|
20
|
+
const target = raw.slice(4);
|
|
21
|
+
if (current)
|
|
22
|
+
files.push(current);
|
|
23
|
+
current = { path: stripPrefix(target), isNew: pendingHeader === "/dev/null", hunks: [] };
|
|
24
|
+
currentHunk = null;
|
|
25
|
+
pendingHeader = null;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (currentHunk === null && raw.startsWith("@@ ")) {
|
|
29
|
+
const m = raw.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
30
|
+
if (m && current) {
|
|
31
|
+
currentHunk = {
|
|
32
|
+
oldStart: parseInt(m[1], 10),
|
|
33
|
+
oldCount: m[2] ? parseInt(m[2], 10) : 1,
|
|
34
|
+
newStart: parseInt(m[3], 10),
|
|
35
|
+
newCount: m[4] ? parseInt(m[4], 10) : 1,
|
|
36
|
+
lines: [],
|
|
37
|
+
};
|
|
38
|
+
current.hunks.push(currentHunk);
|
|
39
|
+
remainingOld = currentHunk.oldCount;
|
|
40
|
+
remainingNew = currentHunk.newCount;
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (currentHunk && current) {
|
|
45
|
+
if (raw.startsWith("+")) {
|
|
46
|
+
currentHunk.lines.push({ type: "add", text: raw.slice(1) });
|
|
47
|
+
remainingNew--;
|
|
48
|
+
}
|
|
49
|
+
else if (raw.startsWith("-")) {
|
|
50
|
+
currentHunk.lines.push({ type: "delete", text: raw.slice(1) });
|
|
51
|
+
remainingOld--;
|
|
52
|
+
}
|
|
53
|
+
else if (raw.startsWith(" ")) {
|
|
54
|
+
currentHunk.lines.push({ type: "context", text: raw.slice(1) });
|
|
55
|
+
remainingOld--;
|
|
56
|
+
remainingNew--;
|
|
57
|
+
}
|
|
58
|
+
if (remainingOld <= 0 && remainingNew <= 0)
|
|
59
|
+
currentHunk = null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (current)
|
|
63
|
+
files.push(current);
|
|
64
|
+
return files;
|
|
65
|
+
}
|
|
66
|
+
function stripPrefix(p) {
|
|
67
|
+
const trimmed = p.trim();
|
|
68
|
+
if (trimmed === "/dev/null")
|
|
69
|
+
return trimmed;
|
|
70
|
+
if (trimmed.startsWith("a/"))
|
|
71
|
+
return trimmed.slice(2);
|
|
72
|
+
if (trimmed.startsWith("b/"))
|
|
73
|
+
return trimmed.slice(2);
|
|
74
|
+
return trimmed;
|
|
75
|
+
}
|
|
76
|
+
function findMatch(lines, start, block) {
|
|
77
|
+
if (block.length === 0)
|
|
78
|
+
return Math.max(0, Math.min(start, lines.length));
|
|
79
|
+
for (let pos = Math.max(0, start - OFFSET_TOLERANCE); pos <= Math.min(lines.length - block.length, start + OFFSET_TOLERANCE); pos++) {
|
|
80
|
+
let ok = true;
|
|
81
|
+
for (let i = 0; i < block.length; i++) {
|
|
82
|
+
if (lines[pos + i] !== block[i]) {
|
|
83
|
+
ok = false;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (ok)
|
|
88
|
+
return pos;
|
|
89
|
+
}
|
|
90
|
+
return -1;
|
|
91
|
+
}
|
|
92
|
+
function applyHunk(lines, hunk) {
|
|
93
|
+
const block = hunk.lines.filter((l) => l.type !== "add").map((l) => l.text);
|
|
94
|
+
const pos = findMatch(lines, hunk.oldStart - 1, block);
|
|
95
|
+
if (pos === -1) {
|
|
96
|
+
return { error: `第 ${hunk.oldStart} 行附近的 hunk 未找到匹配(需要匹配 ${block.length} 行)` };
|
|
97
|
+
}
|
|
98
|
+
const result = [...lines.slice(0, pos)];
|
|
99
|
+
let src = pos;
|
|
100
|
+
for (const l of hunk.lines) {
|
|
101
|
+
if (l.type === "context") {
|
|
102
|
+
result.push(lines[src]);
|
|
103
|
+
src++;
|
|
104
|
+
}
|
|
105
|
+
else if (l.type === "delete") {
|
|
106
|
+
src++;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
result.push(l.text);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
result.push(...lines.slice(src));
|
|
113
|
+
return { lines: result };
|
|
114
|
+
}
|
|
115
|
+
/** 在内存中应用全部文件;成功后返回各文件新内容(不写盘)。 */
|
|
116
|
+
export function buildPatchedFiles(files, cwd = process.cwd()) {
|
|
117
|
+
const contents = [];
|
|
118
|
+
for (const file of files) {
|
|
119
|
+
const target = path.resolve(cwd, file.path);
|
|
120
|
+
const exists = existsSync(target);
|
|
121
|
+
if (file.isNew && !exists) {
|
|
122
|
+
const lines = [];
|
|
123
|
+
for (const hunk of file.hunks) {
|
|
124
|
+
const r = applyHunk(lines, hunk);
|
|
125
|
+
if ("error" in r)
|
|
126
|
+
return { ok: false, error: `${file.path}: ${r.error}` };
|
|
127
|
+
lines.length = 0;
|
|
128
|
+
lines.push(...r.lines);
|
|
129
|
+
}
|
|
130
|
+
contents.push({ file, content: lines.join("\n") + (lines.length > 0 ? "\n" : "") });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!exists)
|
|
134
|
+
return { ok: false, error: `${file.path}: 文件不存在` };
|
|
135
|
+
let content;
|
|
136
|
+
try {
|
|
137
|
+
content = readFileSync(target, "utf-8");
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
return { ok: false, error: `${file.path}: 读取失败 ${err.message}` };
|
|
141
|
+
}
|
|
142
|
+
const lines = content.split("\n");
|
|
143
|
+
for (const hunk of file.hunks) {
|
|
144
|
+
const r = applyHunk(lines, hunk);
|
|
145
|
+
if ("error" in r)
|
|
146
|
+
return { ok: false, error: `${file.path}: ${r.error}` };
|
|
147
|
+
lines.length = 0;
|
|
148
|
+
lines.push(...r.lines);
|
|
149
|
+
}
|
|
150
|
+
contents.push({ file, content: lines.join("\n") });
|
|
151
|
+
}
|
|
152
|
+
return { ok: true, contents };
|
|
153
|
+
}
|
|
154
|
+
export const applyPatchTool = tool({
|
|
155
|
+
description: "Apply a unified diff to the working tree. Use this for precise multi-file edits or when you have a generated diff. " +
|
|
156
|
+
"Supports standard unified diff format (---/+++ headers, @@ hunks, context/delete/add lines). " +
|
|
157
|
+
"New files are created when the diff targets /dev/null. Conflicts are reported without partial writes.",
|
|
158
|
+
inputSchema: jsonSchema({
|
|
159
|
+
type: "object",
|
|
160
|
+
properties: {
|
|
161
|
+
diff: { type: "string", description: "The unified diff text to apply" },
|
|
162
|
+
},
|
|
163
|
+
required: ["diff"],
|
|
164
|
+
}),
|
|
165
|
+
execute: async ({ diff }) => {
|
|
166
|
+
const parsed = parseUnifiedDiff(diff);
|
|
167
|
+
if (parsed.length === 0)
|
|
168
|
+
return "Error: 无法解析 diff(缺少 ---/+++ 文件头)";
|
|
169
|
+
const built = buildPatchedFiles(parsed);
|
|
170
|
+
if (!built.ok)
|
|
171
|
+
return `Error: ${built.error}`;
|
|
172
|
+
const summary = built.contents
|
|
173
|
+
.map((c) => {
|
|
174
|
+
const del = c.file.hunks.reduce((s, h) => s + h.lines.filter((l) => l.type === "delete").length, 0);
|
|
175
|
+
const add = c.file.hunks.reduce((s, h) => s + h.lines.filter((l) => l.type === "add").length, 0);
|
|
176
|
+
return ` ${c.file.path} ${c.file.isNew ? "(new)" : ""} -${del}/+${add}`;
|
|
177
|
+
})
|
|
178
|
+
.join("\n");
|
|
179
|
+
if (!isAutoApprove()) {
|
|
180
|
+
const approved = await confirm(`Apply patch to ${built.contents.length} file(s)?\n${summary}`);
|
|
181
|
+
if (!approved)
|
|
182
|
+
return "Patch rejected by user.";
|
|
183
|
+
}
|
|
184
|
+
for (const c of built.contents) {
|
|
185
|
+
const target = path.resolve(process.cwd(), c.file.path);
|
|
186
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
187
|
+
writeFileSync(target, c.content, "utf-8");
|
|
188
|
+
}
|
|
189
|
+
return `Applied patch:\n${summary}`;
|
|
190
|
+
},
|
|
191
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plumbing for the HTTP-backed tools (`search_web`, `web_fetch`):
|
|
3
|
+
* base-URL resolution, consistent "how to configure this" copy, and fetch
|
|
4
|
+
* error description. Kept separate so both tools report problems the same way.
|
|
5
|
+
*/
|
|
6
|
+
/** Human-readable pointer to both configuration channels. */
|
|
7
|
+
export function configHint(spec) {
|
|
8
|
+
return `Configure a working instance via "${spec.configKey}" in ~/.min-agent/config.json or the ${spec.envName} env var.`;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Normalize a user-supplied base URL: trim whitespace and trailing slashes so
|
|
12
|
+
* callers can always append "/path" without producing a double slash.
|
|
13
|
+
* Returns an error string for anything that is not an absolute http(s) URL.
|
|
14
|
+
*/
|
|
15
|
+
export function normalizeBaseURL(raw, source) {
|
|
16
|
+
const trimmed = raw.trim();
|
|
17
|
+
if (trimmed === "")
|
|
18
|
+
return { ok: false, error: `${source} is empty` };
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = new URL(trimmed);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return { ok: false, error: `${source} is not a valid absolute URL: ${trimmed}` };
|
|
25
|
+
}
|
|
26
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
27
|
+
return { ok: false, error: `${source} must use http or https, got "${parsed.protocol}"` };
|
|
28
|
+
}
|
|
29
|
+
// Keep any path prefix (instances behind a reverse proxy subpath) but drop
|
|
30
|
+
// query/hash and trailing slashes.
|
|
31
|
+
parsed.search = "";
|
|
32
|
+
parsed.hash = "";
|
|
33
|
+
return { ok: true, base: parsed.toString().replace(/\/+$/, "") };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a backend base URL. The environment variable wins over the config
|
|
37
|
+
* file so a single run can be redirected from the shell without editing
|
|
38
|
+
* config.json; the built-in default is the last resort.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveBackendBase(spec, configValue) {
|
|
41
|
+
const env = process.env[spec.envName];
|
|
42
|
+
if (env && env.trim())
|
|
43
|
+
return normalizeBaseURL(env, spec.envName);
|
|
44
|
+
if (configValue && configValue.trim())
|
|
45
|
+
return normalizeBaseURL(configValue, `"${spec.configKey}" in config.json`);
|
|
46
|
+
return normalizeBaseURL(spec.fallback, "the built-in default backend URL");
|
|
47
|
+
}
|
|
48
|
+
/** Turn a thrown fetch error into a short, actionable sentence. */
|
|
49
|
+
export function describeFetchError(err) {
|
|
50
|
+
if (err instanceof Error) {
|
|
51
|
+
// AbortSignal.timeout rejects with a TimeoutError DOMException.
|
|
52
|
+
if (err.name === "TimeoutError" || err.name === "AbortError")
|
|
53
|
+
return "the request timed out";
|
|
54
|
+
const cause = err.cause;
|
|
55
|
+
const causeCode = typeof cause === "object" && cause !== null ? cause.code : undefined;
|
|
56
|
+
if (typeof causeCode === "string")
|
|
57
|
+
return `${err.message} (${causeCode})`;
|
|
58
|
+
return err.message;
|
|
59
|
+
}
|
|
60
|
+
return String(err);
|
|
61
|
+
}
|