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
package/dist/agent.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { streamText, stepCountIs } from "ai";
2
- import { readFileSync, existsSync } from "fs";
2
+ import { readFileSync, existsSync, statSync } from "fs";
3
3
  import path from "path";
4
4
  import { resolveModel } from "./provider.js";
5
+ import { loadConfig, getActiveProvider } from "./config.js";
6
+ import { getModelPrice, estimateCost } from "./pricing.js";
5
7
  import { createChatTools, createCodeTools } from "./tools/index.js";
6
8
  import { initMcp, shutdownMcp, getMcpTools } from "./mcp.js";
7
- import { discoverSkills, getSkillsTool, getSkillsSystemPrompt, getSkills } from "./skills.js";
9
+ import { discoverSkills, attachSkills, collectLoadedSkillNames } from "./skills.js";
8
10
  import { loadInstructions } from "./instructions.js";
9
11
  import { getMemorySystemPrompt, getMemoryTools } from "./memory.js";
10
12
  import { needsCompaction, compactMessages, estimateTokens, TokenTracker } from "./compaction.js";
@@ -13,19 +15,27 @@ import { MarkdownRenderer } from "./markdown.js";
13
15
  import { DoomLoopDetector } from "./doom-loop.js";
14
16
  import { ThinkingBodySplitter, stripThinkingFromAssistantText } from "./assistant-stream.js";
15
17
  import { printHeader, printDivider, printToolCall, printToolResult, printDone } from "./output.js";
18
+ import { createTodoTool } from "./tools/todo.js";
19
+ import { getContextWindow } from "./context-window.js";
20
+ import { log, logToolCall, logToolResult } from "./logger.js";
21
+ import { markSyntheticMessage } from "./synthetic.js";
16
22
  const MAX_STEPS = 30;
17
- function dimStyle() {
18
- if ("NO_COLOR" in process.env)
19
- return { dim: "", reset: "" };
20
- return { dim: "\x1b[90m", reset: "\x1b[0m" };
21
- }
23
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
24
+ const IMAGE_MIME_TYPES = {
25
+ ".png": "image/png",
26
+ ".jpg": "image/jpeg",
27
+ ".jpeg": "image/jpeg",
28
+ ".gif": "image/gif",
29
+ ".webp": "image/webp",
30
+ };
31
+ const DIM_STYLE = "NO_COLOR" in process.env ? { dim: "", reset: "" } : { dim: "\x1b[90m", reset: "\x1b[0m" };
22
32
  /** Shown immediately so the terminal does not look frozen while MCP / rules load. */
23
33
  function printInitLoading() {
24
- const { dim, reset } = dimStyle();
34
+ const { dim, reset } = DIM_STYLE;
25
35
  console.log(`${dim}⟳ 正在初始化(MCP、技能、规则)…${reset}`);
26
36
  }
27
37
  function printInitReady() {
28
- const { dim, reset } = dimStyle();
38
+ const { dim, reset } = DIM_STYLE;
29
39
  console.log(`${dim}✓ 就绪${reset}`);
30
40
  }
31
41
  /** Stream thinking to stderr (dim). Set MIN_AGENT_SHOW_THINKING=0 to hide. */
@@ -34,10 +44,7 @@ function writeThinkingDelta(text) {
34
44
  return;
35
45
  if (process.env.MIN_AGENT_SHOW_THINKING === "0" || process.env.MIN_AGENT_SHOW_THINKING === "false")
36
46
  return;
37
- const noColor = "NO_COLOR" in process.env;
38
- const dim = noColor ? "" : "\x1b[2m";
39
- const reset = noColor ? "" : "\x1b[0m";
40
- process.stderr.write(`${dim}${text}${reset}`);
47
+ process.stderr.write(`${DIM_STYLE.dim}${text}${DIM_STYLE.reset}`);
41
48
  }
42
49
  function buildSystemPrompt(instructions) {
43
50
  const parts = [
@@ -45,7 +52,7 @@ function buildSystemPrompt(instructions) {
45
52
  "",
46
53
  "Be concise and direct. When you run a command, briefly explain why.",
47
54
  "Use the available tools to complete tasks. When multiple independent operations are needed, call tools in parallel.",
48
- "When the user asks about current events, news, or anything requiring up-to-date information, use the web_search tool.",
55
+ "When the user asks about current events, news, or anything requiring up-to-date information, use the search_web tool.",
49
56
  "",
50
57
  `Working directory: ${process.cwd()}`,
51
58
  `Platform: ${process.platform}`,
@@ -53,330 +60,245 @@ function buildSystemPrompt(instructions) {
53
60
  "",
54
61
  `When the user asks to configure, install, or manage MCP servers, skills, rules, memory, or other min-agent features, use the read tool on the file at ${path.resolve(path.dirname(new URL(import.meta.url).pathname), "../README.md")} first, then follow what it says.`,
55
62
  ];
56
- const skillsPrompt = getSkillsSystemPrompt();
57
- if (skillsPrompt) {
58
- parts.push("", skillsPrompt);
59
- }
60
- const memoryPrompt = getMemorySystemPrompt();
61
- if (memoryPrompt) {
62
- parts.push("", memoryPrompt);
63
- }
64
63
  if (instructions.length > 0) {
65
64
  parts.push("", "# User Instructions", "");
66
65
  parts.push(...instructions);
67
66
  }
68
67
  return parts.join("\n");
69
68
  }
70
- /** Build user message content, optionally with images */
71
- export async function buildUserContent(message, imagePaths) {
72
- if (!imagePaths || imagePaths.length === 0)
73
- return message;
74
- const parts = [{ type: "text", text: message }];
69
+ export function loadImageParts(imagePaths, notify) {
70
+ const parts = [];
75
71
  for (const imgPath of imagePaths) {
76
72
  const resolved = path.resolve(process.cwd(), imgPath);
77
73
  if (!existsSync(resolved)) {
78
- console.error(`\x1b[33m Warning: Image not found: ${imgPath}\x1b[0m`);
74
+ notify?.("not_found", imgPath);
75
+ continue;
76
+ }
77
+ const size = statSync(resolved).size;
78
+ if (size > MAX_IMAGE_BYTES) {
79
+ notify?.("too_large", imgPath, size);
79
80
  continue;
80
81
  }
81
82
  const data = readFileSync(resolved);
82
83
  const ext = path.extname(resolved).toLowerCase();
83
- const mimeMap = {
84
- ".png": "image/png",
85
- ".jpg": "image/jpeg",
86
- ".jpeg": "image/jpeg",
87
- ".gif": "image/gif",
88
- ".webp": "image/webp",
89
- };
90
- const mimeType = mimeMap[ext] ?? "image/png";
91
- parts.push({
92
- type: "image",
93
- image: data,
94
- mimeType,
95
- });
96
- console.log(`\x1b[90m 📎 ${imgPath}\x1b[0m`);
84
+ parts.push({ type: "image", image: data, mimeType: IMAGE_MIME_TYPES[ext] ?? "image/png" });
85
+ notify?.("attached", imgPath);
97
86
  }
98
87
  return parts;
99
88
  }
89
+ /** Build user message content, optionally with images */
90
+ export async function buildUserContent(message, imagePaths) {
91
+ if (!imagePaths || imagePaths.length === 0)
92
+ return message;
93
+ const images = loadImageParts(imagePaths, (kind, imgPath, sizeBytes) => {
94
+ if (kind === "not_found") {
95
+ console.error(`\x1b[33m Warning: Image not found: ${imgPath}\x1b[0m`);
96
+ }
97
+ else if (kind === "too_large") {
98
+ console.error(`\x1b[33m Warning: Image skipped (${((sizeBytes ?? 0) / 1024 / 1024).toFixed(1)} MB exceeds ${MAX_IMAGE_BYTES / 1024 / 1024} MB limit): ${imgPath}\x1b[0m`);
99
+ }
100
+ else {
101
+ console.log(`\x1b[90m 📎 ${imgPath}\x1b[0m`);
102
+ }
103
+ });
104
+ return [{ type: "text", text: message }, ...images];
105
+ }
100
106
  /** Single-shot: send one message, get response, exit */
101
- export async function runAgent(message, modelId, imagePaths) {
107
+ export async function runAgent(message, modelId, imagePaths, providerName) {
102
108
  printHeader(modelId);
103
109
  printDivider();
104
110
  printInitLoading();
105
111
  await initMcp();
106
- discoverSkills();
107
- const instructions = await loadInstructions();
108
- printInitReady();
109
- console.log(`\x1b[36m> ${message}\x1b[0m\n`);
110
- const content = await buildUserContent(message, imagePaths);
111
- const messages = [{ role: "user", content }];
112
- const tracker = new TokenTracker();
113
- await runOnce(messages, instructions, modelId, undefined, undefined, tracker);
114
- await shutdownMcp();
112
+ try {
113
+ discoverSkills();
114
+ const instructions = await loadInstructions();
115
+ printInitReady();
116
+ console.log(`\x1b[36m> ${message}\x1b[0m\n`);
117
+ const content = await buildUserContent(message, imagePaths);
118
+ const messages = [{ role: "user", content }];
119
+ const tracker = new TokenTracker();
120
+ await runOnce(messages, instructions, modelId, undefined, undefined, tracker, { providerName });
121
+ }
122
+ finally {
123
+ await shutdownMcp();
124
+ }
115
125
  }
116
- /** runOnce variant that accepts a pre-built system prompt (for code mode) */
117
- export async function runOnceWithSystem(messages, systemPrompt, modelId, abortSignal, callbacks, tracker) {
118
- const model = resolveModel(modelId);
119
- const api = !!callbacks;
120
- if (needsCompaction(messages, tracker)) {
121
- console.log("\x1b[90m⟳ Compacting context...\x1b[0m");
122
- const result = await compactMessages(messages, model);
123
- if (result.compacted) {
124
- messages.length = 0;
125
- messages.push(...result.messages);
126
- if (result.shouldContinue) {
127
- const continueText = result.replayText || "Continue with your task.";
128
- messages.push({ role: "user", content: continueText });
129
- }
130
- if (tracker)
131
- tracker.resetContext();
132
- console.log(`\x1b[90m ✓ Compacted (${estimateTokens(messages)} tokens estimated)\x1b[0m`);
133
- }
126
+ /**
127
+ * Push assistant text + tool call/result history into `messages` so the model
128
+ * remembers its tool activity across turns and resumed sessions.
129
+ */
130
+ export function pushTurn(messages, assistantText, toolCalls, toolResults) {
131
+ const cleaned = stripThinkingFromAssistantText(assistantText);
132
+ const resultIds = new Set(toolResults.map((r) => r.toolCallId));
133
+ const parts = [];
134
+ if (cleaned.trim())
135
+ parts.push({ type: "text", text: cleaned });
136
+ for (const c of toolCalls) {
137
+ if (!resultIds.has(c.toolCallId))
138
+ continue;
139
+ parts.push({ type: "tool-call", toolCallId: c.toolCallId, toolName: c.toolName, input: c.input });
140
+ }
141
+ if (parts.length > 0) {
142
+ messages.push({ role: "assistant", content: parts });
143
+ }
144
+ for (const r of toolResults) {
145
+ const text = typeof r.output === "string" ? r.output : JSON.stringify(r.output);
146
+ messages.push({
147
+ role: "tool",
148
+ content: [
149
+ {
150
+ type: "tool-result",
151
+ toolCallId: r.toolCallId,
152
+ toolName: r.toolName,
153
+ output: { type: "text", value: text },
154
+ },
155
+ ],
156
+ });
134
157
  }
135
- const builtinTools = createCodeTools();
158
+ }
159
+ async function buildTools(mode, modelId, abortSignal, planMode = false, messages = [], tracker) {
160
+ const builtinTools = mode === "code" ? createCodeTools() : createChatTools();
161
+ builtinTools["todo"] = createTodoTool();
136
162
  const mcpTools = getMcpTools();
137
163
  const memoryTools = getMemoryTools();
138
164
  const pluginTools = await loadPluginTools();
139
- const { createTaskTool } = await import("./tools/task.js");
140
- const { createExploreTool } = await import("./tools/explore.js");
141
- const skills = getSkills();
142
165
  const allTools = { ...builtinTools, ...memoryTools, ...pluginTools };
143
166
  for (const [id, t] of Object.entries(mcpTools))
144
167
  allTools[id] = t;
145
- if (skills.length > 0)
146
- allTools["skill"] = getSkillsTool();
147
- allTools["task"] = createTaskTool(modelId);
148
- allTools["explore"] = createExploreTool(modelId);
149
- let stepCount = 0;
150
- let hasError = false;
151
- const doomLoop = new DoomLoopDetector();
152
- const result = streamText({
153
- model,
154
- system: systemPrompt,
155
- messages,
168
+ const skillsPrompt = attachSkills(allTools, collectLoadedSkillNames(messages));
169
+ const onUsage = tracker ? (usage) => tracker.add(usage) : undefined;
170
+ const { createExploreTool } = await import("./tools/explore.js");
171
+ allTools["explore"] = createExploreTool(modelId, abortSignal, onUsage);
172
+ if (mode === "code") {
173
+ const { createTaskTool } = await import("./tools/task.js");
174
+ allTools["task"] = createTaskTool(modelId, abortSignal, onUsage);
175
+ }
176
+ if (planMode) {
177
+ for (const name of ["bash", "write", "edit", "apply_patch"])
178
+ delete allTools[name];
179
+ }
180
+ return {
156
181
  tools: allTools,
157
- stopWhen: stepCountIs(MAX_STEPS),
158
- maxRetries: 3,
159
- abortSignal,
160
- onStepFinish() { stepCount++; },
161
- onError() { },
162
- });
163
- Promise.resolve(result.usage).catch(() => { });
164
- let rawText = "";
165
- let assistantText = "";
166
- const md = new MarkdownRenderer();
167
- const thinkingSplit = new ThinkingBodySplitter();
182
+ promptSections: [skillsPrompt, getMemorySystemPrompt()].filter((s) => s.length > 0),
183
+ };
184
+ }
185
+ function formatErrorMessage(msg) {
186
+ if (msg.includes("API key") || msg.includes("Unauthorized") || msg.includes("Forbidden")) {
187
+ return "Authentication error: Check your API key.";
188
+ }
189
+ if (msg.includes("429") || msg.includes("rate limit") || msg.includes("Rate limit")) {
190
+ return "Rate limited after retries. Please wait and try again.";
191
+ }
192
+ if (msg.includes("timeout") || msg.includes("ETIMEDOUT") || msg.includes("ECONNRESET")) {
193
+ return `Network error (retries exhausted): ${msg}`;
194
+ }
195
+ return msg;
196
+ }
197
+ async function safeUsage(result) {
168
198
  try {
169
- for await (const event of result.fullStream) {
170
- switch (event.type) {
171
- case "text-delta": {
172
- const { display, thinking } = thinkingSplit.feed(event.text);
173
- if (thinking) {
174
- if (callbacks?.onThinkingDelta)
175
- callbacks.onThinkingDelta(thinking);
176
- else
177
- writeThinkingDelta(thinking);
178
- }
179
- if (display) {
180
- rawText += display;
181
- assistantText += display;
182
- if (callbacks?.onAssistantDisplayDelta)
183
- callbacks.onAssistantDisplayDelta(display);
184
- else {
185
- const formatted = md.write(display);
186
- if (formatted)
187
- process.stdout.write(formatted);
188
- }
189
- }
190
- break;
191
- }
192
- case "tool-call": {
193
- if (doomLoop.record(event.toolName, event.input)) {
194
- const warn = `Doom loop detected: "${event.toolName}" — breaking.`;
195
- if (callbacks?.onStreamError)
196
- callbacks.onStreamError(warn);
197
- else
198
- console.log(`\n\x1b[33m⚠ ${warn}\x1b[0m`);
199
- hasError = true;
200
- break;
201
- }
202
- const flush = thinkingSplit.flush();
203
- if (flush.thinking) {
204
- if (callbacks?.onThinkingDelta)
205
- callbacks.onThinkingDelta(flush.thinking);
206
- else
207
- writeThinkingDelta(flush.thinking);
208
- }
209
- if (flush.display) {
210
- rawText += flush.display;
211
- assistantText += flush.display;
212
- if (callbacks?.onAssistantDisplayDelta)
213
- callbacks.onAssistantDisplayDelta(flush.display);
214
- else {
215
- const extra = md.write(flush.display);
216
- if (extra)
217
- process.stdout.write(extra);
218
- }
219
- }
220
- if (!api) {
221
- const flushed = md.flush();
222
- if (flushed)
223
- process.stdout.write(flushed);
224
- if (rawText.trim())
225
- console.log();
226
- }
227
- rawText = "";
228
- if (callbacks?.onToolCall)
229
- callbacks.onToolCall(event.toolName, event.input);
230
- else
231
- printToolCall(event.toolName, event.input);
232
- break;
233
- }
234
- case "tool-result":
235
- if (callbacks?.onToolResult)
236
- callbacks.onToolResult(event.toolName, event.output);
237
- else
238
- printToolResult(event.toolName, event.output);
239
- break;
240
- case "error":
241
- hasError = true;
242
- if (callbacks?.onStreamError)
243
- callbacks.onStreamError(String(event.error));
244
- else
245
- console.error(`\x1b[31mError: ${event.error}\x1b[0m`);
246
- break;
247
- case "finish":
248
- break;
249
- }
250
- }
251
- const end = thinkingSplit.flush();
252
- if (end.thinking) {
253
- if (callbacks?.onThinkingDelta)
254
- callbacks.onThinkingDelta(end.thinking);
255
- else
256
- writeThinkingDelta(end.thinking);
257
- }
258
- if (end.display) {
259
- assistantText += end.display;
260
- if (callbacks?.onAssistantDisplayDelta)
261
- callbacks.onAssistantDisplayDelta(end.display);
262
- else {
263
- const tail = md.write(end.display);
264
- if (tail)
265
- process.stdout.write(tail);
266
- }
267
- }
268
- if (!api) {
269
- const remaining = md.flush();
270
- if (remaining)
271
- process.stdout.write(remaining);
272
- if (rawText.trim())
273
- console.log();
274
- }
275
- const cleaned = stripThinkingFromAssistantText(assistantText);
276
- if (cleaned.trim())
277
- messages.push({ role: "assistant", content: cleaned });
278
- let usage;
279
- try {
280
- usage = await result.usage;
281
- }
282
- catch {
283
- usage = undefined;
284
- }
285
- if (usage && tracker)
286
- tracker.update(usage);
287
- if (callbacks?.onRunFinish) {
288
- callbacks.onRunFinish({ stepCount, usage, hasError, aborted: false });
289
- }
290
- else {
291
- if (!hasError && usage) {
292
- printDivider();
293
- const { getContextWindow } = await import("./context-window.js");
294
- const ctxWindow = await getContextWindow(modelId);
295
- printDone(stepCount, usage, ctxWindow);
296
- }
297
- else {
298
- printDivider();
299
- }
300
- }
199
+ return await result.usage;
301
200
  }
302
- catch (err) {
303
- if (err.name === "AbortError" || abortSignal?.aborted) {
304
- const cleaned = stripThinkingFromAssistantText(assistantText);
305
- if (cleaned.trim())
306
- messages.push({ role: "assistant", content: cleaned });
307
- if (callbacks?.onRunFinish)
308
- callbacks.onRunFinish({ stepCount, usage: undefined, hasError: false, aborted: true });
309
- return;
310
- }
311
- if (callbacks?.onStreamError)
312
- callbacks.onStreamError(err.message);
313
- else {
314
- printDivider();
315
- console.error(`\x1b[31mError: ${err.message}\x1b[0m`);
316
- }
201
+ catch {
202
+ return undefined;
317
203
  }
318
204
  }
319
- export async function runOnce(messages, instructions, modelId, abortSignal, callbacks, tracker) {
320
- const model = resolveModel(modelId);
321
- const api = !!callbacks;
205
+ async function runOnceCore(messages, systemPrompt, modelId, abortSignal, callbacks, tracker, mode, options) {
206
+ const model = resolveModel(modelId, options?.providerName);
207
+ log("info", `run start mode=${mode} messages=${messages.length} model=${typeof model === "string" ? model : model.modelId}`);
208
+ const cbs = callbacks ?? {};
209
+ const sampling = loadConfig().sampling ?? {};
210
+ const temperature = options?.temperature ?? sampling.temperature;
211
+ const maxTokens = options?.maxTokens ?? sampling.maxTokens;
212
+ const topP = options?.topP ?? sampling.topP;
322
213
  // Auto-compact if context is getting too large
323
- if (needsCompaction(messages, tracker)) {
324
- if (api) {
325
- callbacks.onCompaction?.("compacting_start");
214
+ if (await needsCompaction(messages, tracker)) {
215
+ if (cbs.onCompaction) {
216
+ cbs.onCompaction("compacting_start");
326
217
  }
327
- else {
218
+ else if (!callbacks) {
328
219
  console.log("\x1b[90m⟳ Compacting context...\x1b[0m");
329
220
  }
330
- const result = await compactMessages(messages, model);
221
+ const result = await compactMessages(messages, model, { abortSignal });
331
222
  if (result.compacted) {
223
+ if (result.usage && tracker)
224
+ tracker.add(result.usage);
332
225
  messages.length = 0;
333
226
  messages.push(...result.messages);
334
227
  // Auto-continue: inject a message so the agent keeps working
335
228
  if (result.shouldContinue) {
336
229
  const continueText = result.replayText ||
337
230
  "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.";
338
- messages.push({ role: "user", content: continueText });
231
+ const continueMsg = { role: "user", content: continueText };
232
+ messages.push(continueMsg);
233
+ markSyntheticMessage(continueMsg);
339
234
  }
340
235
  if (tracker)
341
236
  tracker.resetContext();
342
- if (api) {
343
- callbacks.onCompaction?.(`compacted_ok estimated_tokens=${estimateTokens(messages)}`);
237
+ if (cbs.onCompaction) {
238
+ cbs.onCompaction(`compacted_ok estimated_tokens=${estimateTokens(messages)}`);
344
239
  }
345
- else {
240
+ else if (!callbacks) {
346
241
  console.log(`\x1b[90m ✓ Compacted (${estimateTokens(messages)} tokens estimated)\x1b[0m`);
347
242
  }
348
243
  }
349
244
  }
350
- // Merge tools: chat builtin + MCP + skill + memory + plugins (no task/explore in chat mode)
351
- const builtinTools = createChatTools();
352
- const mcpTools = getMcpTools();
353
- const memoryTools = getMemoryTools();
354
- const pluginTools = await loadPluginTools();
355
- const skills = getSkills();
356
- const allTools = { ...builtinTools, ...memoryTools, ...pluginTools };
357
- for (const [id, t] of Object.entries(mcpTools)) {
358
- allTools[id] = t;
359
- }
360
- if (skills.length > 0) {
361
- allTools["skill"] = getSkillsTool();
362
- }
245
+ const { tools: allTools, promptSections } = await buildTools(mode, modelId, abortSignal, options?.planMode, messages, tracker);
246
+ const system = [systemPrompt, ...promptSections].join("\n\n");
363
247
  let stepCount = 0;
248
+ let maxStepsReached = false;
364
249
  let hasError = false;
250
+ let budgetExceeded = false;
251
+ const cfg = loadConfig();
252
+ const budgetLimit = cfg.budget?.maxCostUSD;
253
+ let budgetPrice = null;
254
+ if (budgetLimit != null && budgetLimit > 0) {
255
+ const provider = options?.providerName
256
+ ? cfg.providers?.find((p) => p.name === options.providerName)
257
+ : getActiveProvider(cfg);
258
+ budgetPrice = await getModelPrice(modelId ?? provider?.defaultModel ?? "");
259
+ }
260
+ const checkBudget = () => {
261
+ if (!tracker || budgetLimit == null || budgetLimit <= 0)
262
+ return false;
263
+ const cost = estimateCost({ inputTokens: tracker.totalInputTokens, outputTokens: tracker.totalOutputTokens }, budgetPrice);
264
+ if (cost != null && cost > budgetLimit) {
265
+ budgetExceeded = true;
266
+ return true;
267
+ }
268
+ return false;
269
+ };
270
+ // Internal controller so the budget limit can abort mid-run; external
271
+ // abort requests are forwarded to it.
272
+ const budgetController = new AbortController();
273
+ const forwardAbort = () => budgetController.abort();
274
+ abortSignal?.addEventListener("abort", forwardAbort, { once: true });
365
275
  const doomLoop = new DoomLoopDetector();
276
+ const toolCalls = [];
277
+ const toolResults = [];
366
278
  const result = streamText({
367
279
  model,
368
- system: buildSystemPrompt(instructions),
280
+ system,
369
281
  messages,
370
282
  tools: allTools,
371
283
  stopWhen: stepCountIs(MAX_STEPS),
372
284
  maxRetries: 3,
373
- abortSignal,
374
- onStepFinish() {
375
- stepCount++;
285
+ abortSignal: budgetController.signal,
286
+ ...(temperature != null ? { temperature } : {}),
287
+ ...(maxTokens != null ? { maxTokens } : {}),
288
+ ...(topP != null ? { topP } : {}),
289
+ onStepFinish({ stepNumber, usage }) {
290
+ stepCount = stepNumber;
291
+ if (usage && tracker)
292
+ tracker.update(usage);
293
+ if (stepNumber >= MAX_STEPS)
294
+ maxStepsReached = true;
295
+ if (checkBudget())
296
+ budgetController.abort();
297
+ },
298
+ onError({ error }) {
299
+ log("warn", `step failed (will retry): ${String(error)}`);
376
300
  },
377
- onError() { },
378
301
  });
379
- Promise.resolve(result.usage).catch(() => { });
380
302
  let rawText = "";
381
303
  let assistantText = "";
382
304
  const md = new MarkdownRenderer();
@@ -384,13 +306,42 @@ export async function runOnce(messages, instructions, modelId, abortSignal, call
384
306
  const emitThinking = (t) => {
385
307
  if (!t)
386
308
  return;
387
- if (callbacks?.onThinkingDelta)
388
- callbacks.onThinkingDelta(t);
309
+ if (cbs.onThinkingDelta)
310
+ cbs.onThinkingDelta(t);
389
311
  else
390
312
  writeThinkingDelta(t);
391
313
  };
314
+ const emitDisplay = (delta) => {
315
+ if (cbs.onAssistantDisplayDelta) {
316
+ cbs.onAssistantDisplayDelta(delta);
317
+ return;
318
+ }
319
+ const formatted = md.write(delta);
320
+ if (formatted)
321
+ process.stdout.write(formatted);
322
+ };
323
+ const flushSplit = () => {
324
+ const out = thinkingSplit.flush();
325
+ emitThinking(out.thinking);
326
+ if (out.display) {
327
+ rawText += out.display;
328
+ assistantText += out.display;
329
+ emitDisplay(out.display);
330
+ }
331
+ };
332
+ const flushOutput = () => {
333
+ flushSplit();
334
+ if (!callbacks) {
335
+ const flushed = md.flush();
336
+ if (flushed)
337
+ process.stdout.write(flushed);
338
+ if (rawText.trim())
339
+ console.log();
340
+ }
341
+ rawText = "";
342
+ };
392
343
  try {
393
- for await (const event of result.fullStream) {
344
+ streamLoop: for await (const event of result.fullStream) {
394
345
  switch (event.type) {
395
346
  case "text-delta": {
396
347
  const { display, thinking } = thinkingSplit.feed(event.text);
@@ -398,114 +349,65 @@ export async function runOnce(messages, instructions, modelId, abortSignal, call
398
349
  if (display) {
399
350
  rawText += display;
400
351
  assistantText += display;
401
- if (callbacks?.onAssistantDisplayDelta)
402
- callbacks.onAssistantDisplayDelta(display);
403
- else {
404
- const formatted = md.write(display);
405
- if (formatted)
406
- process.stdout.write(formatted);
407
- }
352
+ emitDisplay(display);
408
353
  }
409
354
  break;
410
355
  }
411
356
  case "tool-call": {
412
- // Doom loop detection
357
+ toolCalls.push({ toolCallId: event.toolCallId, toolName: event.toolName, input: event.input });
413
358
  if (doomLoop.record(event.toolName, event.input)) {
414
- const warning = `\x1b[33m⚠ Doom loop detected: "${event.toolName}" called ${3} times with same args. Breaking loop.\x1b[0m`;
415
- if (callbacks?.onStreamError)
416
- callbacks.onStreamError(warning);
417
- else
418
- console.log(`\n${warning}`);
419
359
  hasError = true;
420
- break;
421
- }
422
- const splitFlush = thinkingSplit.flush();
423
- emitThinking(splitFlush.thinking);
424
- if (splitFlush.display) {
425
- rawText += splitFlush.display;
426
- assistantText += splitFlush.display;
427
- if (callbacks?.onAssistantDisplayDelta)
428
- callbacks.onAssistantDisplayDelta(splitFlush.display);
429
- else {
430
- const extra = md.write(splitFlush.display);
431
- if (extra)
432
- process.stdout.write(extra);
433
- }
434
- }
435
- if (!callbacks) {
436
- const flushed = md.flush();
437
- if (flushed)
438
- process.stdout.write(flushed);
439
- if (rawText.trim())
440
- console.log();
360
+ const warning = `Doom loop detected: "${event.toolName}" called 3 times with same args. Breaking loop.`;
361
+ if (cbs.onStreamError)
362
+ cbs.onStreamError(warning);
363
+ else
364
+ console.log(`\n\x1b[33m⚠ ${warning}\x1b[0m`);
365
+ flushOutput();
366
+ break streamLoop;
441
367
  }
442
- rawText = "";
443
- if (callbacks?.onToolCall)
444
- callbacks.onToolCall(event.toolName, event.input);
368
+ logToolCall(event.toolName, event.input);
369
+ flushOutput();
370
+ if (cbs.onToolCall)
371
+ cbs.onToolCall(event.toolName, event.input, event.toolCallId);
445
372
  else
446
373
  printToolCall(event.toolName, event.input);
447
374
  break;
448
375
  }
449
376
  case "tool-result":
450
- if (callbacks?.onToolResult)
451
- callbacks.onToolResult(event.toolName, event.output);
377
+ case "tool-error": {
378
+ const isError = event.type === "tool-error";
379
+ const output = isError ? event.error : event.output;
380
+ const displayed = isError ? `Error: ${event.error}` : event.output;
381
+ toolResults.push({ toolCallId: event.toolCallId, toolName: event.toolName, output });
382
+ logToolResult(event.toolName, displayed);
383
+ if (cbs.onToolResult)
384
+ cbs.onToolResult(event.toolName, displayed, { toolCallId: event.toolCallId, isError });
452
385
  else
453
- printToolResult(event.toolName, event.output);
386
+ printToolResult(event.toolName, displayed, isError);
454
387
  break;
388
+ }
455
389
  case "error":
456
390
  hasError = true;
457
391
  const errorMsg = String(event.error);
458
- if (callbacks?.onStreamError) {
459
- callbacks.onStreamError(errorMsg);
460
- }
461
- else if (errorMsg.includes("Forbidden") || errorMsg.includes("Unauthorized") || errorMsg.includes("API key")) {
462
- console.error(`\x1b[31mAuthentication error: Check your API key.\x1b[0m`);
392
+ log("error", `stream error: ${errorMsg}`);
393
+ if (cbs.onStreamError) {
394
+ cbs.onStreamError(errorMsg);
463
395
  }
464
396
  else {
465
- console.error(`\x1b[31mError: ${errorMsg}\x1b[0m`);
397
+ console.error(`\x1b[31m${formatErrorMessage(errorMsg)}\x1b[0m`);
466
398
  }
467
399
  break;
468
- case "finish":
469
- break;
470
- }
471
- }
472
- const splitEnd = thinkingSplit.flush();
473
- emitThinking(splitEnd.thinking);
474
- if (splitEnd.display) {
475
- rawText += splitEnd.display;
476
- assistantText += splitEnd.display;
477
- if (callbacks?.onAssistantDisplayDelta)
478
- callbacks.onAssistantDisplayDelta(splitEnd.display);
479
- else {
480
- const tail = md.write(splitEnd.display);
481
- if (tail)
482
- process.stdout.write(tail);
483
400
  }
484
401
  }
485
- if (!callbacks) {
486
- const remaining = md.flush();
487
- if (remaining)
488
- process.stdout.write(remaining);
489
- if (rawText.trim())
490
- console.log();
491
- }
492
- const cleanedAssistant = stripThinkingFromAssistantText(assistantText);
493
- if (cleanedAssistant.trim()) {
494
- messages.push({ role: "assistant", content: cleanedAssistant });
495
- }
496
- let usage;
497
- try {
498
- usage = await result.usage;
499
- }
500
- catch {
501
- usage = undefined;
402
+ flushOutput();
403
+ pushTurn(messages, assistantText, toolCalls, toolResults);
404
+ const usage = await safeUsage(result);
405
+ if (maxStepsReached && !callbacks) {
406
+ console.log(`\x1b[33m⚠ Reached the ${MAX_STEPS}-step limit for this turn. Send a follow-up message to continue.\x1b[0m`);
502
407
  }
503
- // Update token tracker with real usage from API
504
- if (usage && tracker) {
505
- tracker.update(usage);
506
- }
507
- if (callbacks?.onRunFinish) {
508
- callbacks.onRunFinish({ stepCount, usage, hasError, aborted: false });
408
+ log("info", `run end steps=${stepCount} tokens_in=${usage?.inputTokens ?? 0} tokens_out=${usage?.outputTokens ?? 0}`);
409
+ if (cbs.onRunFinish) {
410
+ cbs.onRunFinish({ stepCount, usage, hasError, aborted: false, maxStepsReached, budgetExceeded });
509
411
  }
510
412
  else {
511
413
  if (hasError) {
@@ -513,68 +415,51 @@ export async function runOnce(messages, instructions, modelId, abortSignal, call
513
415
  return;
514
416
  }
515
417
  printDivider();
516
- const { getContextWindow } = await import("./context-window.js");
517
418
  const ctxWindow = await getContextWindow(modelId);
518
419
  printDone(stepCount, usage, ctxWindow);
420
+ if (budgetExceeded)
421
+ console.log(`\x1b[33m⚠ 已达到预算上限 ($${budgetLimit}),运行已中断\x1b[0m`);
519
422
  }
520
423
  }
521
424
  catch (err) {
522
425
  if (!callbacks && rawText.trim())
523
426
  console.log();
524
- if (err.name === "AbortError" || abortSignal?.aborted) {
525
- const splitAbort = thinkingSplit.flush();
526
- emitThinking(splitAbort.thinking);
527
- if (splitAbort.display) {
528
- assistantText += splitAbort.display;
529
- if (callbacks?.onAssistantDisplayDelta)
530
- callbacks.onAssistantDisplayDelta(splitAbort.display);
531
- else {
532
- const w = md.write(splitAbort.display);
533
- if (w)
534
- process.stdout.write(w);
535
- }
536
- }
537
- if (!callbacks)
538
- process.stdout.write(md.flush());
539
- const cleaned = stripThinkingFromAssistantText(assistantText);
540
- if (cleaned.trim()) {
541
- messages.push({ role: "assistant", content: cleaned });
542
- }
543
- let usage;
544
- try {
545
- usage = await result.usage;
546
- }
547
- catch {
548
- usage = undefined;
549
- }
550
- callbacks?.onRunFinish?.({ stepCount, usage, hasError: false, aborted: true });
427
+ const aborted = abortSignal?.aborted || (err instanceof Error && err.name === "AbortError");
428
+ flushSplit();
429
+ if (!callbacks)
430
+ process.stdout.write(md.flush());
431
+ pushTurn(messages, assistantText, toolCalls, toolResults);
432
+ const usage = await safeUsage(result);
433
+ if (aborted) {
434
+ if (budgetExceeded && !callbacks)
435
+ console.log(`\x1b[33m⚠ 已达到预算上限 ($${budgetLimit}),运行已中断\x1b[0m`);
436
+ cbs.onRunFinish?.({ stepCount, usage, hasError, aborted: true, maxStepsReached, budgetExceeded });
551
437
  return;
552
438
  }
553
439
  if (!callbacks)
554
440
  printDivider();
555
- const msg = err.message ?? String(err);
556
- if (callbacks?.onStreamError) {
557
- callbacks.onStreamError(msg);
558
- }
559
- else if (msg.includes("API key") || msg.includes("Unauthorized") || msg.includes("Forbidden")) {
560
- console.error(`\x1b[31mAuthentication error: Check your API key.\x1b[0m`);
441
+ const msg = err instanceof Error ? err.message : String(err);
442
+ log("error", msg);
443
+ const display = formatErrorMessage(msg);
444
+ if (cbs.onStreamError) {
445
+ cbs.onStreamError(msg);
561
446
  }
562
- else if (msg.includes("429") || msg.includes("rate limit") || msg.includes("Rate limit")) {
563
- console.error(`\x1b[31mRate limited after retries. Please wait and try again.\x1b[0m`);
564
- }
565
- else if (msg.includes("timeout") || msg.includes("ETIMEDOUT") || msg.includes("ECONNRESET")) {
566
- console.error(`\x1b[31mNetwork error (retries exhausted): ${msg}\x1b[0m`);
447
+ else if (display !== msg) {
448
+ console.error(`\x1b[31m${display}\x1b[0m`);
567
449
  }
568
450
  else {
569
451
  console.error(`\x1b[31mError: ${msg}\x1b[0m`);
570
452
  }
571
- let usage;
572
- try {
573
- usage = await result.usage;
574
- }
575
- catch {
576
- usage = undefined;
577
- }
578
- callbacks?.onRunFinish?.({ stepCount, usage, hasError: true, aborted: false });
453
+ cbs.onRunFinish?.({ stepCount, usage, hasError: true, aborted: false, maxStepsReached, budgetExceeded });
454
+ }
455
+ finally {
456
+ abortSignal?.removeEventListener("abort", forwardAbort);
579
457
  }
580
458
  }
459
+ export async function runOnce(messages, instructions, modelId, abortSignal, callbacks, tracker, options) {
460
+ await runOnceCore(messages, buildSystemPrompt(instructions), modelId, abortSignal, callbacks, tracker, "chat", options);
461
+ }
462
+ /** runOnce variant that accepts a pre-built system prompt (for code mode) */
463
+ export async function runOnceWithSystem(messages, systemPrompt, modelId, abortSignal, callbacks, tracker, options) {
464
+ await runOnceCore(messages, systemPrompt, modelId, abortSignal, callbacks, tracker, "code", options);
465
+ }