min-agent 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +111 -28
  2. package/dist/agent.js +1119 -256
  3. package/dist/cli/commands/chat.js +10 -0
  4. package/dist/cli/commands/exec.js +32 -0
  5. package/dist/cli/commands/history.js +58 -0
  6. package/dist/cli/commands/index.js +224 -0
  7. package/dist/cli/commands/init.js +18 -0
  8. package/dist/cli/commands/mcp.js +173 -0
  9. package/dist/cli/commands/memory.js +69 -0
  10. package/dist/cli/commands/models.js +21 -0
  11. package/dist/cli/commands/permission.js +12 -0
  12. package/dist/cli/commands/rules.js +33 -0
  13. package/dist/cli/commands/sandbox.js +13 -0
  14. package/dist/cli/commands/serve.js +9 -0
  15. package/dist/cli/commands/setup.js +4 -0
  16. package/dist/cli/commands/shared.js +16 -0
  17. package/dist/cli/commands/skills.js +119 -0
  18. package/dist/cli/commands/update.js +7 -0
  19. package/dist/cli/commands/write-config.js +30 -0
  20. package/dist/cli/errors.js +36 -0
  21. package/dist/cli/exec-prompt.js +26 -0
  22. package/dist/cli/option-helpers.js +53 -0
  23. package/dist/cli/program.js +180 -0
  24. package/dist/cli.js +5 -888
  25. package/dist/code-mode.js +32 -14
  26. package/dist/compaction.js +347 -160
  27. package/dist/config.js +119 -10
  28. package/dist/confirm.js +56 -9
  29. package/dist/context-window.js +107 -39
  30. package/dist/doom-loop.js +264 -29
  31. package/dist/fetch-timeout.js +152 -0
  32. package/dist/http-approvals.js +60 -0
  33. package/dist/instructions.js +21 -0
  34. package/dist/logger.js +33 -4
  35. package/dist/markdown.js +37 -11
  36. package/dist/mcp.js +328 -30
  37. package/dist/memory.js +97 -56
  38. package/dist/output.js +7 -5
  39. package/dist/permission-cli.js +43 -0
  40. package/dist/plugins.js +46 -8
  41. package/dist/pricing.js +4 -4
  42. package/dist/provider.js +23 -6
  43. package/dist/question-format.js +60 -0
  44. package/dist/sandbox-cli.js +82 -0
  45. package/dist/sandbox.js +403 -0
  46. package/dist/save-throttle.js +45 -0
  47. package/dist/serve/common.js +404 -0
  48. package/dist/serve/routes-chat.js +347 -0
  49. package/dist/serve/routes-mcp.js +212 -0
  50. package/dist/serve/routes-memory.js +66 -0
  51. package/dist/serve/routes-meta.js +205 -0
  52. package/dist/serve/routes-sessions.js +61 -0
  53. package/dist/serve/routes-skills.js +70 -0
  54. package/dist/serve.js +33 -883
  55. package/dist/sessions.js +53 -9
  56. package/dist/skills.js +82 -18
  57. package/dist/title-gen.js +8 -2
  58. package/dist/token-display.js +36 -0
  59. package/dist/tool-display.js +5 -0
  60. package/dist/tool-output.js +1 -3
  61. package/dist/tools/apply_patch.js +85 -11
  62. package/dist/tools/atomic-file.js +35 -0
  63. package/dist/tools/backend.js +2 -2
  64. package/dist/tools/bash.js +57 -19
  65. package/dist/tools/code_search.js +7 -1
  66. package/dist/tools/edit.js +11 -10
  67. package/dist/tools/explore.js +74 -14
  68. package/dist/tools/glob.js +4 -0
  69. package/dist/tools/grep.js +17 -10
  70. package/dist/tools/index.js +6 -21
  71. package/dist/tools/question.js +28 -9
  72. package/dist/tools/read.js +6 -4
  73. package/dist/tools/search-searxng.js +223 -0
  74. package/dist/tools/search-serper.js +189 -0
  75. package/dist/tools/task.js +84 -30
  76. package/dist/tools/todo.js +120 -19
  77. package/dist/tools/web_fetch.js +11 -3
  78. package/dist/tools/web_search.js +66 -556
  79. package/dist/tools/write.js +23 -6
  80. package/dist/tui/App.js +63 -14
  81. package/dist/tui/ConfirmBar.js +45 -13
  82. package/dist/tui/InputBar.js +150 -35
  83. package/dist/tui/MessageList.js +266 -125
  84. package/dist/tui/ModelPicker.js +8 -3
  85. package/dist/tui/QuestionBar.js +51 -19
  86. package/dist/tui/SessionPicker.js +79 -0
  87. package/dist/tui/StatusBar.js +8 -14
  88. package/dist/tui/agent-runner.js +142 -22
  89. package/dist/tui/caret-pos.js +48 -5
  90. package/dist/tui/caret.js +1 -1
  91. package/dist/tui/click-count.js +13 -0
  92. package/dist/tui/drag-state.js +8 -3
  93. package/dist/tui/hydrate.js +129 -0
  94. package/dist/tui/index.js +42 -13
  95. package/dist/tui/input-history.js +92 -11
  96. package/dist/tui/layout.js +75 -4
  97. package/dist/tui/prompt-queue.js +24 -0
  98. package/dist/tui/selection.js +113 -21
  99. package/dist/tui/session-switch.js +28 -0
  100. package/dist/tui/slash-commands.js +22 -6
  101. package/dist/tui/slash-handler.js +233 -58
  102. package/dist/tui/text-width.js +38 -16
  103. package/dist/tui/token-info.js +7 -0
  104. package/dist/tui/tool-children.js +19 -0
  105. package/dist/tui/undo-stack.js +1 -1
  106. package/dist/tui/use-sgr-mouse.js +3 -1
  107. package/dist/tui-chat.js +276 -40
  108. package/dist/updater.js +88 -29
  109. package/dist/xml-search.js +194 -0
  110. package/docs/API.md +257 -25
  111. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  112. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  113. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  114. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  115. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  116. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  117. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  118. package/package.json +6 -1
  119. package/skills/self-config/SKILL.md +90 -0
  120. package/skills/self-config/reference.md +149 -0
@@ -1,7 +1,7 @@
1
1
  import { generateText } from "ai";
2
- import { loadConfig, getActiveProvider } from "./config.js";
2
+ import { getEffectiveConfig } from "./config.js";
3
3
  import { resolveModel } from "./provider.js";
4
- import { getContextWindow } from "./context-window.js";
4
+ import { getContextWindowInfo, DEFAULT_CONTEXT_WINDOW } from "./context-window.js";
5
5
  import { collectLoadedSkillNames, buildSkillReloadNote } from "./skills.js";
6
6
  /**
7
7
  * Context compaction system — modeled after opencode's SessionCompaction.
@@ -42,7 +42,7 @@ const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown below. Kee
42
42
  - [ordered next actions or "(none)"]
43
43
 
44
44
  ## Critical Context
45
- - [important technical facts, errors, open questions, or "(none)"]
45
+ - [important technical facts, numbers, dates, scores, URLs, errors, open questions, or "(none)"]
46
46
 
47
47
  ## Relevant Files
48
48
  - [file or directory path: why it matters, or "(none)"]
@@ -53,14 +53,23 @@ Rules:
53
53
  - Preserve exact file paths, commands, error strings, and identifiers when known.
54
54
  - Do not mention the summary process or that context was compacted.`;
55
55
  // ─── Constants ─────────────────────────────────────────────────────────────
56
- const DEFAULT_MAX_TOKENS = 128000;
57
- const COMPACTION_RATIO = 0.75;
56
+ export const COMPACTION_RATIO = 0.75;
57
+ /** Start pruning old tool payloads only once the context is this full. */
58
+ export const PRUNE_PRESSURE_RATIO = 0.6;
59
+ /** Prune (largest first) until the estimate is back under this. */
60
+ export const PRUNE_TARGET_RATIO = 0.45;
58
61
  const DEFAULT_TAIL_TURNS = 2;
59
- const TAIL_TOKEN_BUDGET_RATIO = 0.25;
62
+ const TAIL_TOKEN_BUDGET_RATIO = 0.04;
60
63
  const MIN_TAIL_BUDGET = 2000;
61
- const MAX_TAIL_BUDGET = 8000;
62
- const PRUNE_PROTECT_TOKENS = 24000;
63
- const TOOL_OUTPUT_MAX_CHARS = 2000;
64
+ const MAX_TAIL_BUDGET = 48000;
65
+ const KEEP_RECENT_ASSISTANT_UNITS = 4;
66
+ const TOOL_RESULT_STUB_CHARS = 400;
67
+ const TOOL_CALL_STRING_MAX = 500;
68
+ const SUMMARIZE_TOOL_CHARS = 400;
69
+ const SUMMARIZE_RESEARCH_CHARS = 2500;
70
+ const MAX_SUMMARIZE_CHARS = 100_000;
71
+ const OMITTED_TOOL_MARKER = "old tool output omitted";
72
+ const RESEARCH_SUMMARY_TOOLS = new Set(["search_web", "web_fetch"]);
64
73
  /** Tools whose output should never be pruned during compaction */
65
74
  const PRUNE_PROTECTED_TOOLS = new Set(["skill"]);
66
75
  // ─── Token Tracker ─────────────────────────────────────────────────────────
@@ -68,20 +77,29 @@ export class TokenTracker {
68
77
  _lastInputTokens = 0;
69
78
  _totalOutputTokens = 0;
70
79
  _totalInputTokens = 0;
71
- _totalCacheRead = 0;
80
+ listeners = new Set();
81
+ subscribe(listener) {
82
+ this.listeners.add(listener);
83
+ return () => {
84
+ this.listeners.delete(listener);
85
+ };
86
+ }
87
+ emit() {
88
+ for (const listener of this.listeners)
89
+ listener();
90
+ }
72
91
  update(usage) {
73
- this.add(usage);
92
+ this._totalInputTokens += usage.inputTokens ?? 0;
93
+ this._totalOutputTokens += usage.outputTokens ?? 0;
74
94
  this._lastInputTokens = usage.inputTokens ?? 0;
95
+ this.emit();
75
96
  }
76
97
  /** Add to running totals only (e.g. compaction / sub-agent calls), without
77
98
  * touching the "last step input" used for context-window display. */
78
99
  add(usage) {
79
- const input = usage.inputTokens ?? 0;
80
- const output = usage.outputTokens ?? 0;
81
- const cacheRead = usage.cachedInputTokens ?? 0;
82
- this._totalInputTokens += input;
83
- this._totalOutputTokens += output;
84
- this._totalCacheRead += cacheRead;
100
+ this._totalInputTokens += usage.inputTokens ?? 0;
101
+ this._totalOutputTokens += usage.outputTokens ?? 0;
102
+ this.emit();
85
103
  }
86
104
  get lastInputTokens() {
87
105
  return this._lastInputTokens;
@@ -95,8 +113,15 @@ export class TokenTracker {
95
113
  summary() {
96
114
  return `context: ${this._lastInputTokens} | total in: ${this._totalInputTokens} out: ${this._totalOutputTokens}`;
97
115
  }
116
+ reset() {
117
+ this._lastInputTokens = 0;
118
+ this._totalInputTokens = 0;
119
+ this._totalOutputTokens = 0;
120
+ this.emit();
121
+ }
98
122
  resetContext() {
99
123
  this._lastInputTokens = 0;
124
+ this.emit();
100
125
  }
101
126
  }
102
127
  // ─── Token Estimation ──────────────────────────────────────────────────────
@@ -120,6 +145,22 @@ function estimateTextTokens(text) {
120
145
  }
121
146
  return ascii / 4 + other * 0.7;
122
147
  }
148
+ const IMAGE_TOKEN_ESTIMATE = 800;
149
+ function estimatePartTokens(part) {
150
+ if (typeof part.text === "string")
151
+ return estimateTextTokens(part.text);
152
+ if (part.type === "image" || part.type === "file")
153
+ return IMAGE_TOKEN_ESTIMATE;
154
+ if (part.type === "tool-call" && part.input !== undefined) {
155
+ try {
156
+ return estimateTextTokens(JSON.stringify(part.input));
157
+ }
158
+ catch {
159
+ return 0;
160
+ }
161
+ }
162
+ return 0;
163
+ }
123
164
  function estimateMessageTextTokens(msg) {
124
165
  const content = msg.content;
125
166
  if (typeof content === "string")
@@ -127,9 +168,12 @@ function estimateMessageTextTokens(msg) {
127
168
  if (Array.isArray(content)) {
128
169
  let tokens = 0;
129
170
  for (const part of content) {
130
- if ("text" in part && typeof part.text === "string") {
131
- tokens += estimateTextTokens(part.text);
171
+ if (isToolResultPart(part)) {
172
+ const text = toolResultText(part);
173
+ tokens += text ? estimateTextTokens(text) : 0;
174
+ continue;
132
175
  }
176
+ tokens += estimatePartTokens(part);
133
177
  }
134
178
  return tokens;
135
179
  }
@@ -139,38 +183,68 @@ function estimateMessageTextTokens(msg) {
139
183
  export function estimateTokens(messages) {
140
184
  return Math.ceil(messages.reduce((sum, msg) => sum + estimateMessageTextTokens(msg), 0));
141
185
  }
186
+ /** Same estimator for a plain string (assistant text, tool-call JSON, …). */
187
+ export function estimateStringTokens(text) {
188
+ return Math.ceil(estimateTextTokens(text));
189
+ }
142
190
  function estimateMessageTokens(msg) {
143
191
  return Math.ceil(estimateMessageTextTokens(msg));
144
192
  }
193
+ /**
194
+ * Tokens spent on everything that is not in `messages`: the system prompt and
195
+ * the JSON schema of every tool. This is routinely 10k+ tokens with skills and
196
+ * MCP servers attached, so leaving it out makes every threshold too generous.
197
+ */
198
+ export function estimateOverheadTokens(systemPrompt, tools) {
199
+ let tokens = estimateTextTokens(systemPrompt);
200
+ for (const [name, tool] of Object.entries(tools ?? {})) {
201
+ tokens += estimateTextTokens(name);
202
+ const t = tool;
203
+ if (typeof t?.description === "string")
204
+ tokens += estimateTextTokens(t.description);
205
+ const schema = t?.inputSchema ?? t?.parameters;
206
+ if (schema != null) {
207
+ try {
208
+ tokens += estimateTextTokens(JSON.stringify(schema));
209
+ }
210
+ catch {
211
+ /* non-serializable schema — skip */
212
+ }
213
+ }
214
+ }
215
+ return Math.ceil(tokens);
216
+ }
145
217
  // ─── Compaction Check ──────────────────────────────────────────────────────
146
218
  /**
147
- * Resolve max tokens: explicit user config first, then model-aware detection.
219
+ * Resolve context window: explicit override, then model-aware detection.
220
+ * Undetected models use 512k.
148
221
  */
149
- async function resolveMaxTokens() {
150
- const cfg = loadConfig();
151
- const provider = getActiveProvider(cfg);
152
- if (provider?.contextWindow)
153
- return provider.contextWindow;
222
+ async function resolveWindow(config) {
223
+ if (config?.maxTokens)
224
+ return { tokens: config.maxTokens, source: "config" };
154
225
  try {
155
- return await getContextWindow(provider?.defaultModel);
226
+ return await getContextWindowInfo(config?.modelId);
156
227
  }
157
228
  catch {
158
- return DEFAULT_MAX_TOKENS;
229
+ return { tokens: DEFAULT_CONTEXT_WINDOW, source: "fallback" };
159
230
  }
160
231
  }
161
232
  /**
162
- * Check if compaction is needed.
163
- * Uses model-aware context window (user config or provider detection).
233
+ * Check if LLM summarization is needed.
234
+ * Uses configured or detected window; otherwise 512k.
164
235
  */
165
236
  export async function needsCompaction(messages, tracker, config) {
166
- const maxTokens = config?.maxTokens ?? await resolveMaxTokens();
167
- const threshold = maxTokens * COMPACTION_RATIO;
237
+ if (config?.force)
238
+ return true;
239
+ const window = await resolveWindow(config);
240
+ const threshold = window.tokens * COMPACTION_RATIO;
241
+ const overhead = config?.overheadTokens ?? 0;
168
242
  if (tracker && tracker.lastInputTokens > 0) {
243
+ // Reported input tokens already include the system prompt and tool schemas.
169
244
  return tracker.lastInputTokens > threshold;
170
245
  }
171
- return estimateTokens(messages) > threshold;
246
+ return estimateTokens(messages) + overhead > threshold;
172
247
  }
173
- // ─── Tool Output Pruning ───────────────────────────────────────────────────
174
248
  function toolResultText(part) {
175
249
  const out = part.output;
176
250
  if (typeof out === "string")
@@ -182,93 +256,203 @@ function toolResultText(part) {
182
256
  }
183
257
  return null;
184
258
  }
259
+ function isOmittedToolOutput(text) {
260
+ return text.includes(OMITTED_TOOL_MARKER);
261
+ }
262
+ /** Keep the "full output saved to <path>" hint alive so the model can re-read it. */
263
+ function savedPathHint(text) {
264
+ const match = /Full output saved to:\s*(\S+)/.exec(text);
265
+ return match ? `, full output: ${match[1]}` : "";
266
+ }
267
+ function stubToolOutput(toolName, text) {
268
+ return `[${OMITTED_TOOL_MARKER}: ${toolName}, ${text.length} chars${savedPathHint(text)}]`;
269
+ }
270
+ function truncateToolCallValue(value) {
271
+ if (typeof value === "string") {
272
+ if (value.length <= TOOL_CALL_STRING_MAX)
273
+ return value;
274
+ return `${value.slice(0, TOOL_CALL_STRING_MAX)}\n\n[... ${value.length - TOOL_CALL_STRING_MAX} chars omitted from old tool call ...]`;
275
+ }
276
+ if (Array.isArray(value))
277
+ return value.map(truncateToolCallValue);
278
+ if (value && typeof value === "object") {
279
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, truncateToolCallValue(v)]));
280
+ }
281
+ return value;
282
+ }
185
283
  function isProtectedToolMessage(msg) {
186
284
  if (typeof msg.content === "string") {
187
285
  return msg.content.includes("<skill_content");
188
286
  }
189
287
  if (Array.isArray(msg.content)) {
190
- return msg.content.some((p) => p.type === "tool-result" &&
288
+ return msg.content.some((p) => (p.type === "tool-result" || p.type === "tool-call") &&
289
+ "toolName" in p &&
191
290
  PRUNE_PROTECTED_TOOLS.has(p.toolName));
192
291
  }
193
292
  return false;
194
293
  }
294
+ function unitStarts(messages) {
295
+ const starts = [];
296
+ for (let i = 0; i < messages.length; i++) {
297
+ const role = messages[i].role;
298
+ if (role === "user" || role === "assistant")
299
+ starts.push(i);
300
+ }
301
+ return starts;
302
+ }
303
+ function assistantStarts(messages) {
304
+ const starts = [];
305
+ for (let i = 0; i < messages.length; i++) {
306
+ if (messages[i].role === "assistant")
307
+ starts.push(i);
308
+ }
309
+ return starts;
310
+ }
311
+ /** Keep the last few assistant+tool steps intact so the model can see recent work. */
312
+ function protectFromIndex(messages) {
313
+ const starts = assistantStarts(messages);
314
+ if (starts.length <= KEEP_RECENT_ASSISTANT_UNITS)
315
+ return 0;
316
+ return starts[starts.length - KEEP_RECENT_ASSISTANT_UNITS];
317
+ }
318
+ function pruneToolResultMessage(msg) {
319
+ if (msg.role !== "tool" || !Array.isArray(msg.content) || isProtectedToolMessage(msg))
320
+ return null;
321
+ const parts = [...msg.content];
322
+ let changed = false;
323
+ for (let j = 0; j < parts.length; j++) {
324
+ const p = parts[j];
325
+ if (p.type !== "tool-result")
326
+ continue;
327
+ const text = toolResultText(p);
328
+ if (!text || isOmittedToolOutput(text) || text.length <= TOOL_RESULT_STUB_CHARS)
329
+ continue;
330
+ parts[j] = { ...p, output: { type: "text", value: stubToolOutput(p.toolName, text) } };
331
+ changed = true;
332
+ }
333
+ return changed ? { ...msg, content: parts } : null;
334
+ }
335
+ function pruneAssistantToolCalls(msg) {
336
+ if (msg.role !== "assistant" || !Array.isArray(msg.content) || isProtectedToolMessage(msg))
337
+ return null;
338
+ const parts = [...msg.content];
339
+ let changed = false;
340
+ for (let j = 0; j < parts.length; j++) {
341
+ const p = parts[j];
342
+ if (p.type !== "tool-call" || p.input === undefined)
343
+ continue;
344
+ const next = truncateToolCallValue(p.input);
345
+ let unchanged = next === p.input;
346
+ if (!unchanged) {
347
+ try {
348
+ unchanged = JSON.stringify(next) === JSON.stringify(p.input);
349
+ }
350
+ catch {
351
+ unchanged = false;
352
+ }
353
+ }
354
+ if (unchanged)
355
+ continue;
356
+ parts[j] = { ...p, input: next };
357
+ changed = true;
358
+ }
359
+ return changed ? { ...msg, content: parts } : null;
360
+ }
195
361
  /**
196
- * Return a copy of messages with old tool outputs truncated to free context space.
197
- * Keeps recent tool results intact, trims older ones.
198
- * Protects skill tool results from pruning.
362
+ * Return a copy of messages with old tool payloads stubbed.
363
+ * Recent assistant steps stay intact. Skill results are never pruned.
199
364
  * Does not mutate the input.
365
+ *
366
+ * Without a token budget (or with `force`) every prunable payload outside the
367
+ * recent window is stubbed. With a budget, pruning is pressure driven: nothing
368
+ * happens below `pressureTokens`, and above it the biggest payloads go first
369
+ * until the estimate is back under `targetTokens` — so a long session with a
370
+ * roomy context window keeps its tool output.
200
371
  */
201
- export function pruneToolOutputs(messages) {
202
- let totalTokens = 0;
203
- let turns = 0;
204
- const replaced = new Map();
205
- for (let i = messages.length - 1; i >= 0; i--) {
372
+ export function pruneToolOutputs(messages, options = {}) {
373
+ const protectFrom = protectFromIndex(messages);
374
+ const candidates = [];
375
+ for (let i = 0; i < protectFrom; i++) {
206
376
  const msg = messages[i];
207
- if (msg.role === "user")
208
- turns++;
209
- if (turns < 2)
210
- continue;
211
- if (msg.role !== "tool")
212
- continue;
213
- if (isProtectedToolMessage(msg))
377
+ const pruned = msg.role === "tool" ? pruneToolResultMessage(msg) : msg.role === "assistant" ? pruneAssistantToolCalls(msg) : null;
378
+ if (!pruned)
214
379
  continue;
215
- const parts = [...msg.content];
216
- let changed = false;
217
- for (let j = 0; j < parts.length; j++) {
218
- const p = parts[j];
219
- if (p.type !== "tool-result")
220
- continue;
221
- const text = toolResultText(p);
222
- if (!text)
380
+ candidates.push({
381
+ index: i,
382
+ pruned,
383
+ tokensSaved: Math.max(0, estimateMessageTokens(msg) - estimateMessageTokens(pruned)),
384
+ });
385
+ }
386
+ if (candidates.length === 0)
387
+ return messages;
388
+ const pressure = options.pressureTokens ?? options.targetTokens;
389
+ const replaced = new Map();
390
+ if (options.force || pressure == null) {
391
+ for (const c of candidates)
392
+ replaced.set(c.index, c.pruned);
393
+ }
394
+ else {
395
+ let estimate = estimateTokens(messages) + (options.overheadTokens ?? 0);
396
+ if (estimate <= pressure)
397
+ return messages;
398
+ const goal = options.targetTokens ?? pressure;
399
+ for (const c of [...candidates].sort((a, b) => b.tokensSaved - a.tokensSaved)) {
400
+ if (estimate <= goal)
401
+ break;
402
+ if (c.tokensSaved <= 0)
223
403
  continue;
224
- totalTokens += estimateTextTokens(text);
225
- if (totalTokens > PRUNE_PROTECT_TOKENS && text.length > TOOL_OUTPUT_MAX_CHARS) {
226
- const truncated = text.slice(0, TOOL_OUTPUT_MAX_CHARS) + "\n\n[... output truncated during compaction ...]";
227
- parts[j] = { ...p, output: { type: "text", value: truncated } };
228
- changed = true;
229
- }
404
+ replaced.set(c.index, c.pruned);
405
+ estimate -= c.tokensSaved;
230
406
  }
231
- if (changed)
232
- replaced.set(i, { ...msg, content: parts });
233
407
  }
234
408
  if (replaced.size === 0)
235
409
  return messages;
236
410
  return messages.map((msg, i) => replaced.get(i) ?? msg);
237
411
  }
412
+ /** Replace `messages` in place when old tool payloads can be dropped. */
413
+ export function applyToolPrune(messages, options = {}) {
414
+ const pruned = pruneToolOutputs(messages, options);
415
+ if (pruned === messages)
416
+ return false;
417
+ messages.length = 0;
418
+ messages.push(...pruned);
419
+ return true;
420
+ }
238
421
  /**
239
422
  * Select how many recent turns to keep verbatim based on token budget.
240
423
  */
241
424
  async function selectTail(messages, config) {
242
- const maxTokens = config?.maxTokens ?? await resolveMaxTokens();
243
- const tailTurns = config?.keepRecentTurns ?? DEFAULT_TAIL_TURNS;
244
- const budget = Math.min(MAX_TAIL_BUDGET, Math.max(MIN_TAIL_BUDGET, Math.floor(maxTokens * TAIL_TOKEN_BUDGET_RATIO)));
245
- const turnStarts = [];
246
- for (let i = 0; i < messages.length; i++) {
247
- if (messages[i].role === "user")
248
- turnStarts.push(i);
249
- }
250
- if (turnStarts.length <= 1) {
425
+ const window = await resolveWindow(config);
426
+ const budget = Math.min(MAX_TAIL_BUDGET, Math.max(MIN_TAIL_BUDGET, Math.floor(window.tokens * TAIL_TOKEN_BUDGET_RATIO)));
427
+ const starts = unitStarts(messages);
428
+ if (starts.length === 0) {
251
429
  return { headEnd: 0, tailStart: 0 };
252
430
  }
253
431
  let tokensUsed = 0;
254
432
  let tailStart = messages.length;
255
- const recentTurns = turnStarts.slice(-tailTurns);
256
- for (let i = recentTurns.length - 1; i >= 0; i--) {
257
- const turnStart = recentTurns[i];
258
- const turnEnd = i < recentTurns.length - 1 ? recentTurns[i + 1] : messages.length;
259
- let turnTokens = 0;
260
- for (let j = turnStart; j < turnEnd; j++) {
261
- turnTokens += estimateMessageTokens(messages[j]);
433
+ for (let i = starts.length - 1; i >= 0; i--) {
434
+ const unitStart = starts[i];
435
+ const unitEnd = i < starts.length - 1 ? starts[i + 1] : messages.length;
436
+ let unitTokens = 0;
437
+ for (let j = unitStart; j < unitEnd; j++) {
438
+ unitTokens += estimateMessageTokens(messages[j]);
262
439
  }
263
- if (tokensUsed + turnTokens > budget && tokensUsed > 0)
440
+ if (tokensUsed + unitTokens > budget && tokensUsed > 0)
264
441
  break;
265
- tokensUsed += turnTokens;
266
- tailStart = turnStart;
442
+ tokensUsed += unitTokens;
443
+ tailStart = unitStart;
267
444
  }
268
445
  if (tailStart >= messages.length)
269
- tailStart = messages.length - 2;
270
- if (tailStart < 0)
271
- tailStart = 0;
446
+ tailStart = starts[starts.length - 1];
447
+ const keepUsers = config?.keepRecentTurns ?? DEFAULT_TAIL_TURNS;
448
+ const userStarts = [];
449
+ for (let i = 0; i < messages.length; i++) {
450
+ if (messages[i].role === "user")
451
+ userStarts.push(i);
452
+ }
453
+ if (tailStart === 0 && userStarts.length > keepUsers) {
454
+ tailStart = userStarts[userStarts.length - keepUsers];
455
+ }
272
456
  return { headEnd: tailStart, tailStart };
273
457
  }
274
458
  // ─── Compaction Agent ──────────────────────────────────────────────────────
@@ -278,7 +462,9 @@ Summarize only the conversation history you are given. The newest turns may be k
278
462
 
279
463
  If the prompt includes a <previous-summary> block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts.
280
464
 
281
- Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs.
465
+ Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths, identifiers, numeric scores, dates, and URLs when known, and prefer terse bullets over paragraphs.
466
+
467
+ When the history includes web search or fetched pages, copy concrete facts (names, scores, dates, URLs) into Critical Context. Do not drop numbers.
282
468
 
283
469
  Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.`;
284
470
  /**
@@ -287,7 +473,7 @@ Do not answer the conversation itself. Do not mention that you are summarizing,
287
473
  * Otherwise falls back to the main model.
288
474
  */
289
475
  function resolveCompactionModel(mainModel) {
290
- const cfg = loadConfig();
476
+ const cfg = getEffectiveConfig();
291
477
  const compactionModelId = cfg.compaction?.model;
292
478
  if (compactionModelId) {
293
479
  return resolveModel(compactionModelId);
@@ -314,10 +500,16 @@ function extractNotedSkills(messages) {
314
500
  return section
315
501
  .split("\n")
316
502
  .filter((line) => line.startsWith("- "))
317
- .map((line) => line.slice(2).replace(/\s*\(base dir:.*$/, "").trim())
503
+ .map((line) => line
504
+ .slice(2)
505
+ .replace(/\s*\(base dir:.*$/, "")
506
+ .trim())
318
507
  .filter(Boolean);
319
508
  }
320
- function buildCompactionPrompt(previousSummary) {
509
+ function buildCompactionPrompt(previousSummary, taskGoal) {
510
+ const goal = taskGoal?.trim()
511
+ ? ["The original user goal that must be preserved in ## Goal:", taskGoal.trim(), ""].join("\n")
512
+ : "";
321
513
  const anchor = previousSummary
322
514
  ? [
323
515
  "Update the anchored summary below using the conversation history above.",
@@ -328,11 +520,19 @@ function buildCompactionPrompt(previousSummary) {
328
520
  "</previous-summary>",
329
521
  ].join("\n")
330
522
  : "Create a new anchored summary from the conversation history above.";
331
- return [anchor, "", SUMMARY_TEMPLATE].join("\n");
523
+ return [goal, anchor, "", SUMMARY_TEMPLATE].filter(Boolean).join("\n");
332
524
  }
333
525
  function isToolResultPart(p) {
334
526
  return typeof p === "object" && p !== null && "type" in p && p.type === "tool-result";
335
527
  }
528
+ function clipForSummary(text, max) {
529
+ if (text.length <= max)
530
+ return text;
531
+ return `${text.slice(0, max)}\n[... ${text.length - max} chars omitted ...]`;
532
+ }
533
+ function clipLimitForTool(name) {
534
+ return RESEARCH_SUMMARY_TOOLS.has(name) ? SUMMARIZE_RESEARCH_CHARS : SUMMARIZE_TOOL_CHARS;
535
+ }
336
536
  function messageToText(msg) {
337
537
  if (typeof msg.content === "string")
338
538
  return msg.content;
@@ -342,40 +542,62 @@ function messageToText(msg) {
342
542
  if ("text" in p && typeof p.text === "string") {
343
543
  parts.push(p.text);
344
544
  }
345
- else if (isToolResultPart(p)) {
346
- const out = p.output;
347
- if (typeof out === "string") {
348
- parts.push(out);
545
+ else if (p.type === "tool-call") {
546
+ const name = "toolName" in p ? String(p.toolName) : "tool";
547
+ let input = "";
548
+ try {
549
+ input = JSON.stringify("input" in p ? p.input : undefined);
349
550
  }
350
- else if ("value" in out && out.value != null) {
351
- parts.push(String(out.value));
352
- }
353
- else {
354
- parts.push(JSON.stringify(out));
551
+ catch {
552
+ input = "";
355
553
  }
554
+ parts.push(`[tool-call ${name}] ${clipForSummary(input, clipLimitForTool(name))}`);
555
+ }
556
+ else if (isToolResultPart(p)) {
557
+ const name = p.toolName || "tool";
558
+ const out = p.output;
559
+ const raw = typeof out === "string"
560
+ ? out
561
+ : out && typeof out === "object" && "value" in out && out.value != null
562
+ ? String(out.value)
563
+ : JSON.stringify(out);
564
+ parts.push(`[tool-result ${name}] ${clipForSummary(raw, clipLimitForTool(name))}`);
356
565
  }
357
566
  }
358
567
  return parts.join("\n");
359
568
  }
360
569
  return "";
361
570
  }
362
- /** Check if a message contains media (images, etc.) */
363
- function hasMedia(msg) {
364
- if (!Array.isArray(msg.content))
365
- return false;
366
- return msg.content.some((p) => p.type === "image" || p.type === "file");
571
+ function conversationForSummary(messages) {
572
+ const text = messages.map((msg) => `[${msg.role}]: ${clipForSummary(messageToText(msg), 1500)}`).join("\n\n");
573
+ if (text.length <= MAX_SUMMARIZE_CHARS)
574
+ return text;
575
+ const keep = Math.floor((MAX_SUMMARIZE_CHARS - 40) / 2);
576
+ return `${text.slice(0, keep)}\n\n[... middle omitted ...]\n\n${text.slice(-keep)}`;
367
577
  }
368
- /** Extract text-only content from a message (strip media) */
369
- function extractTextOnly(msg) {
370
- if (typeof msg.content === "string")
371
- return msg.content;
372
- if (Array.isArray(msg.content)) {
373
- return msg.content
374
- .filter((p) => p.type === "text")
375
- .map((p) => p.text)
376
- .join("\n");
578
+ /**
579
+ * Summarize with one retry: losing the summary means falling back to plain
580
+ * pruning, which throws away far more context than a second attempt costs.
581
+ */
582
+ async function summarizeWithRetry(model, prompt, config) {
583
+ const attempt = () => generateText({
584
+ model,
585
+ system: COMPACTION_AGENT_SYSTEM,
586
+ messages: [{ role: "user", content: prompt }],
587
+ abortSignal: config?.abortSignal,
588
+ });
589
+ try {
590
+ const first = await attempt();
591
+ if (first.text.trim())
592
+ return first;
593
+ console.error("[compaction] empty summary, retrying once");
377
594
  }
378
- return "";
595
+ catch (err) {
596
+ if (config?.abortSignal?.aborted)
597
+ throw err;
598
+ console.error("[compaction] summary attempt failed, retrying once:", err);
599
+ }
600
+ return attempt();
379
601
  }
380
602
  /**
381
603
  * Compact messages by summarizing older history with structured template.
@@ -383,40 +605,19 @@ function extractTextOnly(msg) {
383
605
  * and overflow replay.
384
606
  */
385
607
  export async function compactMessages(messages, model, config) {
386
- const cfg = loadConfig();
387
- const autoContinue = config?.autoContinue ?? cfg.compaction?.autoContinue ?? true;
388
- // Step 1: Prune old tool results (skip skill results)
389
- const pruned = pruneToolOutputs(messages);
390
- // Step 2: Select tail (recent turns to keep verbatim)
608
+ const pruned = pruneToolOutputs(messages, { force: true });
609
+ const pruneOnly = () => pruned === messages ? { messages, compacted: false } : { messages: pruned, compacted: true };
391
610
  const { headEnd, tailStart } = await selectTail(pruned, config);
392
- if (headEnd <= 1) {
393
- return { messages, compacted: false, shouldContinue: false };
394
- }
611
+ if (headEnd <= 0)
612
+ return pruneOnly();
395
613
  const toSummarize = pruned.slice(0, headEnd);
396
614
  const toKeep = pruned.slice(tailStart);
397
- // Step 3: Check for previous summary (incremental)
398
615
  const previousSummary = extractPreviousSummary(toSummarize);
399
- // Step 4: Build conversation text for summarization
400
- const conversationText = toSummarize
401
- .map((msg) => {
402
- const role = msg.role;
403
- const text = messageToText(msg);
404
- return `[${role}]: ${text.slice(0, 3000)}`;
405
- })
406
- .join("\n\n");
407
- // Step 5: Resolve compaction model (may differ from main model)
616
+ const conversationText = conversationForSummary(toSummarize);
408
617
  const compactionModel = resolveCompactionModel(model);
409
- // Step 6: Generate structured summary
410
618
  try {
411
- const prompt = buildCompactionPrompt(previousSummary);
412
- const result = await generateText({
413
- model: compactionModel,
414
- system: COMPACTION_AGENT_SYSTEM,
415
- messages: [
416
- { role: "user", content: conversationText + "\n\n" + prompt },
417
- ],
418
- abortSignal: config?.abortSignal,
419
- });
619
+ const prompt = buildCompactionPrompt(previousSummary, config?.taskGoal);
620
+ const result = await summarizeWithRetry(compactionModel, conversationText + "\n\n" + prompt, config);
420
621
  const summary = result.text;
421
622
  const stillPresent = collectLoadedSkillNames(toKeep);
422
623
  const droppedSkills = [
@@ -430,28 +631,14 @@ export async function compactMessages(messages, model, config) {
430
631
  },
431
632
  ...toKeep,
432
633
  ];
433
- // Step 7: If the last user message (in the kept tail) had media, provide replay text
434
- let replayText;
435
- let lastUserMsg;
436
- for (let i = toKeep.length - 1; i >= 0; i--) {
437
- if (toKeep[i].role === "user") {
438
- lastUserMsg = toKeep[i];
439
- break;
440
- }
441
- }
442
- if (lastUserMsg && hasMedia(lastUserMsg)) {
443
- replayText = extractTextOnly(lastUserMsg);
444
- }
445
634
  return {
446
635
  messages: compactedMessages,
447
636
  compacted: true,
448
- shouldContinue: autoContinue,
449
- replayText,
450
637
  usage: result.usage,
451
638
  };
452
639
  }
453
640
  catch (error) {
454
- console.error("[compaction] summary generation failed, keeping original messages:", error);
455
- return { messages, compacted: false, shouldContinue: false };
641
+ console.error("[compaction] summary generation failed, keeping pruned messages:", error);
642
+ return pruneOnly();
456
643
  }
457
644
  }