min-agent 0.2.1 → 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 (137) hide show
  1. package/README.md +242 -31
  2. package/dist/agent.js +1233 -485
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli/commands/chat.js +10 -0
  5. package/dist/cli/commands/exec.js +32 -0
  6. package/dist/cli/commands/history.js +58 -0
  7. package/dist/cli/commands/index.js +224 -0
  8. package/dist/cli/commands/init.js +18 -0
  9. package/dist/cli/commands/mcp.js +173 -0
  10. package/dist/cli/commands/memory.js +69 -0
  11. package/dist/cli/commands/models.js +21 -0
  12. package/dist/cli/commands/permission.js +12 -0
  13. package/dist/cli/commands/rules.js +33 -0
  14. package/dist/cli/commands/sandbox.js +13 -0
  15. package/dist/cli/commands/serve.js +9 -0
  16. package/dist/cli/commands/setup.js +4 -0
  17. package/dist/cli/commands/shared.js +16 -0
  18. package/dist/cli/commands/skills.js +119 -0
  19. package/dist/cli/commands/update.js +7 -0
  20. package/dist/cli/commands/write-config.js +30 -0
  21. package/dist/cli/errors.js +36 -0
  22. package/dist/cli/exec-prompt.js +26 -0
  23. package/dist/cli/option-helpers.js +53 -0
  24. package/dist/cli/program.js +180 -0
  25. package/dist/cli.js +7 -632
  26. package/dist/clipboard.js +59 -23
  27. package/dist/code-mode.js +35 -17
  28. package/dist/compaction.js +457 -169
  29. package/dist/config.js +298 -38
  30. package/dist/confirm.js +105 -9
  31. package/dist/context-window.js +156 -75
  32. package/dist/doom-loop.js +268 -26
  33. package/dist/fetch-timeout.js +152 -0
  34. package/dist/http-approvals.js +60 -0
  35. package/dist/http.js +119 -0
  36. package/dist/instructions.js +72 -33
  37. package/dist/logger.js +95 -0
  38. package/dist/markdown.js +35 -50
  39. package/dist/mcp.js +847 -102
  40. package/dist/memory.js +128 -45
  41. package/dist/output.js +42 -31
  42. package/dist/paste-handler.js +3 -3
  43. package/dist/permission-cli.js +43 -0
  44. package/dist/plugins.js +76 -11
  45. package/dist/pricing.js +119 -0
  46. package/dist/provider.js +34 -15
  47. package/dist/question-format.js +60 -0
  48. package/dist/sandbox-cli.js +82 -0
  49. package/dist/sandbox.js +403 -0
  50. package/dist/save-throttle.js +45 -0
  51. package/dist/serve/common.js +404 -0
  52. package/dist/serve/routes-chat.js +347 -0
  53. package/dist/serve/routes-mcp.js +212 -0
  54. package/dist/serve/routes-memory.js +66 -0
  55. package/dist/serve/routes-meta.js +205 -0
  56. package/dist/serve/routes-sessions.js +61 -0
  57. package/dist/serve/routes-skills.js +70 -0
  58. package/dist/serve.js +74 -635
  59. package/dist/sessions.js +197 -15
  60. package/dist/skills.js +531 -77
  61. package/dist/synthetic.js +7 -0
  62. package/dist/title-gen.js +9 -2
  63. package/dist/token-display.js +36 -0
  64. package/dist/tool-display.js +178 -0
  65. package/dist/tool-output.js +53 -46
  66. package/dist/tools/apply_patch.js +265 -0
  67. package/dist/tools/atomic-file.js +35 -0
  68. package/dist/tools/backend.js +61 -0
  69. package/dist/tools/bash.js +186 -71
  70. package/dist/tools/code_search.js +13 -6
  71. package/dist/tools/edit.js +26 -9
  72. package/dist/tools/explore.js +144 -16
  73. package/dist/tools/glob.js +7 -3
  74. package/dist/tools/grep.js +153 -14
  75. package/dist/tools/index.js +9 -24
  76. package/dist/tools/question.js +31 -30
  77. package/dist/tools/read.js +77 -15
  78. package/dist/tools/search-searxng.js +223 -0
  79. package/dist/tools/search-serper.js +189 -0
  80. package/dist/tools/task.js +100 -33
  81. package/dist/tools/todo.js +178 -67
  82. package/dist/tools/web_fetch.js +158 -46
  83. package/dist/tools/web_search.js +217 -29
  84. package/dist/tools/write.js +34 -11
  85. package/dist/tui/App.js +89 -6
  86. package/dist/tui/ConfirmBar.js +57 -4
  87. package/dist/tui/InputBar.js +504 -44
  88. package/dist/tui/MessageList.js +674 -20
  89. package/dist/tui/ModelPicker.js +113 -0
  90. package/dist/tui/QuestionBar.js +136 -0
  91. package/dist/tui/SessionPicker.js +79 -0
  92. package/dist/tui/StatusBar.js +14 -12
  93. package/dist/tui/agent-runner.js +223 -0
  94. package/dist/tui/caret-pos.js +177 -0
  95. package/dist/tui/caret.js +69 -0
  96. package/dist/tui/click-count.js +13 -0
  97. package/dist/tui/diff-view.js +61 -0
  98. package/dist/tui/drag-state.js +49 -0
  99. package/dist/tui/hydrate.js +129 -0
  100. package/dist/tui/index.js +189 -31
  101. package/dist/tui/input-history.js +125 -0
  102. package/dist/tui/layout.js +88 -0
  103. package/dist/tui/mouse.js +46 -0
  104. package/dist/tui/prompt-queue.js +24 -0
  105. package/dist/tui/selection.js +226 -0
  106. package/dist/tui/session-switch.js +28 -0
  107. package/dist/tui/slash-commands.js +106 -0
  108. package/dist/tui/slash-handler.js +545 -0
  109. package/dist/tui/text-width.js +113 -0
  110. package/dist/tui/theme.js +12 -0
  111. package/dist/tui/token-info.js +7 -0
  112. package/dist/tui/tool-children.js +19 -0
  113. package/dist/tui/undo-stack.js +14 -0
  114. package/dist/tui/use-sgr-mouse.js +29 -0
  115. package/dist/tui-chat.js +346 -330
  116. package/dist/updater.js +116 -0
  117. package/dist/xml-search.js +194 -0
  118. package/docs/API.md +410 -32
  119. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  120. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  121. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  122. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  123. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  124. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  125. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  126. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  127. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  128. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  129. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  130. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  131. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  132. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  133. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  134. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  135. package/package.json +12 -8
  136. package/skills/self-config/SKILL.md +90 -0
  137. package/skills/self-config/reference.md +149 -0
@@ -1,6 +1,8 @@
1
1
  import { generateText } from "ai";
2
- import { loadConfig } from "./config.js";
2
+ import { getEffectiveConfig } from "./config.js";
3
3
  import { resolveModel } from "./provider.js";
4
+ import { getContextWindowInfo, DEFAULT_CONTEXT_WINDOW } from "./context-window.js";
5
+ import { collectLoadedSkillNames, buildSkillReloadNote } from "./skills.js";
4
6
  /**
5
7
  * Context compaction system — modeled after opencode's SessionCompaction.
6
8
  *
@@ -40,7 +42,7 @@ const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown below. Kee
40
42
  - [ordered next actions or "(none)"]
41
43
 
42
44
  ## Critical Context
43
- - [important technical facts, errors, open questions, or "(none)"]
45
+ - [important technical facts, numbers, dates, scores, URLs, errors, open questions, or "(none)"]
44
46
 
45
47
  ## Relevant Files
46
48
  - [file or directory path: why it matters, or "(none)"]
@@ -51,14 +53,23 @@ Rules:
51
53
  - Preserve exact file paths, commands, error strings, and identifiers when known.
52
54
  - Do not mention the summary process or that context was compacted.`;
53
55
  // ─── Constants ─────────────────────────────────────────────────────────────
54
- const DEFAULT_MAX_TOKENS = 128000;
55
- 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;
56
61
  const DEFAULT_TAIL_TURNS = 2;
57
- const TAIL_TOKEN_BUDGET_RATIO = 0.25;
62
+ const TAIL_TOKEN_BUDGET_RATIO = 0.04;
58
63
  const MIN_TAIL_BUDGET = 2000;
59
- const MAX_TAIL_BUDGET = 8000;
60
- const PRUNE_PROTECT_TOKENS = 40000;
61
- 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"]);
62
73
  /** Tools whose output should never be pruned during compaction */
63
74
  const PRUNE_PROTECTED_TOOLS = new Set(["skill"]);
64
75
  // ─── Token Tracker ─────────────────────────────────────────────────────────
@@ -66,15 +77,29 @@ export class TokenTracker {
66
77
  _lastInputTokens = 0;
67
78
  _totalOutputTokens = 0;
68
79
  _totalInputTokens = 0;
69
- _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
+ }
70
91
  update(usage) {
71
- const input = usage.inputTokens ?? 0;
72
- const output = usage.outputTokens ?? 0;
73
- const cacheRead = usage.cachedInputTokens ?? 0;
74
- this._lastInputTokens = input;
75
- this._totalInputTokens += input;
76
- this._totalOutputTokens += output;
77
- this._totalCacheRead += cacheRead;
92
+ this._totalInputTokens += usage.inputTokens ?? 0;
93
+ this._totalOutputTokens += usage.outputTokens ?? 0;
94
+ this._lastInputTokens = usage.inputTokens ?? 0;
95
+ this.emit();
96
+ }
97
+ /** Add to running totals only (e.g. compaction / sub-agent calls), without
98
+ * touching the "last step input" used for context-window display. */
99
+ add(usage) {
100
+ this._totalInputTokens += usage.inputTokens ?? 0;
101
+ this._totalOutputTokens += usage.outputTokens ?? 0;
102
+ this.emit();
78
103
  }
79
104
  get lastInputTokens() {
80
105
  return this._lastInputTokens;
@@ -88,138 +113,346 @@ export class TokenTracker {
88
113
  summary() {
89
114
  return `context: ${this._lastInputTokens} | total in: ${this._totalInputTokens} out: ${this._totalOutputTokens}`;
90
115
  }
116
+ reset() {
117
+ this._lastInputTokens = 0;
118
+ this._totalInputTokens = 0;
119
+ this._totalOutputTokens = 0;
120
+ this.emit();
121
+ }
91
122
  resetContext() {
92
123
  this._lastInputTokens = 0;
124
+ this.emit();
93
125
  }
94
126
  }
95
127
  // ─── Token Estimation ──────────────────────────────────────────────────────
96
- /** Token estimation: ~4 chars per token (aligned with opencode) */
97
- export function estimateTokens(messages) {
98
- let chars = 0;
99
- for (const msg of messages) {
100
- if (typeof msg.content === "string") {
101
- chars += msg.content.length;
128
+ function isCjk(code) {
129
+ return ((code >= 0x2e80 && code <= 0x9fff) ||
130
+ (code >= 0xf900 && code <= 0xfaff) ||
131
+ (code >= 0xff00 && code <= 0xffef) ||
132
+ (code >= 0x20000 && code <= 0x3fffd) ||
133
+ (code >= 0x3040 && code <= 0x30ff) ||
134
+ (code >= 0xac00 && code <= 0xd7af));
135
+ }
136
+ /** ~4 ASCII chars per token, CJK chars weighted separately (much denser in tokens). */
137
+ function estimateTextTokens(text) {
138
+ let ascii = 0;
139
+ let other = 0;
140
+ for (const ch of text) {
141
+ if (isCjk(ch.codePointAt(0) ?? 0))
142
+ other++;
143
+ else
144
+ ascii++;
145
+ }
146
+ return ascii / 4 + other * 0.7;
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));
102
157
  }
103
- else if (Array.isArray(msg.content)) {
104
- for (const part of msg.content) {
105
- if ("text" in part && typeof part.text === "string") {
106
- chars += part.text.length;
107
- }
158
+ catch {
159
+ return 0;
160
+ }
161
+ }
162
+ return 0;
163
+ }
164
+ function estimateMessageTextTokens(msg) {
165
+ const content = msg.content;
166
+ if (typeof content === "string")
167
+ return estimateTextTokens(content);
168
+ if (Array.isArray(content)) {
169
+ let tokens = 0;
170
+ for (const part of content) {
171
+ if (isToolResultPart(part)) {
172
+ const text = toolResultText(part);
173
+ tokens += text ? estimateTextTokens(text) : 0;
174
+ continue;
108
175
  }
176
+ tokens += estimatePartTokens(part);
109
177
  }
178
+ return tokens;
110
179
  }
111
- return Math.ceil(chars / 4);
180
+ return 0;
181
+ }
182
+ /** Token estimation: ASCII ~4 chars/token, CJK ~0.7 token/char (aligned with opencode) */
183
+ export function estimateTokens(messages) {
184
+ return Math.ceil(messages.reduce((sum, msg) => sum + estimateMessageTextTokens(msg), 0));
185
+ }
186
+ /** Same estimator for a plain string (assistant text, tool-call JSON, …). */
187
+ export function estimateStringTokens(text) {
188
+ return Math.ceil(estimateTextTokens(text));
112
189
  }
113
190
  function estimateMessageTokens(msg) {
114
- if (typeof msg.content === "string")
115
- return Math.ceil(msg.content.length / 4);
116
- if (Array.isArray(msg.content)) {
117
- let chars = 0;
118
- for (const part of msg.content) {
119
- if ("text" in part && typeof part.text === "string")
120
- chars += part.text.length;
191
+ return Math.ceil(estimateMessageTextTokens(msg));
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
+ }
121
213
  }
122
- return Math.ceil(chars / 4);
123
214
  }
124
- return 0;
215
+ return Math.ceil(tokens);
125
216
  }
126
217
  // ─── Compaction Check ──────────────────────────────────────────────────────
127
218
  /**
128
- * Check if compaction is needed.
129
- * Uses model-aware context window from config or getContextWindow cache.
219
+ * Resolve context window: explicit override, then model-aware detection.
220
+ * Undetected models use 512k.
130
221
  */
131
- export function needsCompaction(messages, tracker, config) {
132
- const maxTokens = config?.maxTokens ?? getMaxTokensFromConfig();
133
- const threshold = maxTokens * COMPACTION_RATIO;
222
+ async function resolveWindow(config) {
223
+ if (config?.maxTokens)
224
+ return { tokens: config.maxTokens, source: "config" };
225
+ try {
226
+ return await getContextWindowInfo(config?.modelId);
227
+ }
228
+ catch {
229
+ return { tokens: DEFAULT_CONTEXT_WINDOW, source: "fallback" };
230
+ }
231
+ }
232
+ /**
233
+ * Check if LLM summarization is needed.
234
+ * Uses configured or detected window; otherwise 512k.
235
+ */
236
+ export async function needsCompaction(messages, tracker, config) {
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;
134
242
  if (tracker && tracker.lastInputTokens > 0) {
243
+ // Reported input tokens already include the system prompt and tool schemas.
135
244
  return tracker.lastInputTokens > threshold;
136
245
  }
137
- return estimateTokens(messages) > threshold;
246
+ return estimateTokens(messages) + overhead > threshold;
138
247
  }
139
- /** Get max tokens from user config (model-aware) */
140
- function getMaxTokensFromConfig() {
141
- const cfg = loadConfig();
142
- return cfg.provider?.contextWindow ?? DEFAULT_MAX_TOKENS;
248
+ function toolResultText(part) {
249
+ const out = part.output;
250
+ if (typeof out === "string")
251
+ return out;
252
+ if (out && typeof out === "object" && "value" in out) {
253
+ const v = out.value;
254
+ if (typeof v === "string")
255
+ return v;
256
+ }
257
+ return null;
143
258
  }
144
- // ─── Tool Output Pruning ───────────────────────────────────────────────────
145
- /**
146
- * Prune old tool outputs in-place to free context space.
147
- * Keeps recent tool outputs intact, trims older ones.
148
- * Protects skill tool outputs from pruning.
149
- * Returns the estimated tokens saved.
150
- */
151
- export function pruneToolOutputs(messages) {
152
- let totalTokens = 0;
153
- let saved = 0;
154
- let turns = 0;
155
- for (let i = messages.length - 1; i >= 0; i--) {
156
- const msg = messages[i];
157
- if (msg.role === "user")
158
- turns++;
159
- if (turns < 2)
160
- continue;
161
- // Check if this is a tool result
162
- if (msg.role === "tool" || (Array.isArray(msg.content) && msg.content.some((p) => p.type === "tool-result"))) {
163
- // Skip protected tools (skill results are never pruned)
164
- if (isProtectedToolMessage(msg))
165
- continue;
166
- const content = typeof msg.content === "string" ? msg.content : "";
167
- const estimate = Math.ceil(content.length / 4);
168
- totalTokens += estimate;
169
- if (totalTokens > PRUNE_PROTECT_TOKENS && content.length > TOOL_OUTPUT_MAX_CHARS) {
170
- const truncated = content.slice(0, TOOL_OUTPUT_MAX_CHARS) + "\n\n[... output truncated during compaction ...]";
171
- msg.content = truncated;
172
- saved += estimate - Math.ceil(truncated.length / 4);
173
- }
174
- }
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)]));
175
280
  }
176
- return saved;
281
+ return value;
177
282
  }
178
- /** Check if a message is from a protected tool (e.g. skill) */
179
283
  function isProtectedToolMessage(msg) {
180
284
  if (typeof msg.content === "string") {
181
- // Skill tool outputs are wrapped in <skill_content> tags
182
285
  return msg.content.includes("<skill_content");
183
286
  }
184
287
  if (Array.isArray(msg.content)) {
185
- return msg.content.some((p) => p.type === "tool-result" && PRUNE_PROTECTED_TOOLS.has(p.toolName ?? ""));
288
+ return msg.content.some((p) => (p.type === "tool-result" || p.type === "tool-call") &&
289
+ "toolName" in p &&
290
+ PRUNE_PROTECTED_TOOLS.has(p.toolName));
186
291
  }
187
292
  return false;
188
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
+ }
189
361
  /**
190
- * Select how many recent turns to keep verbatim based on token budget.
362
+ * Return a copy of messages with old tool payloads stubbed.
363
+ * Recent assistant steps stay intact. Skill results are never pruned.
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.
191
371
  */
192
- function selectTail(messages, config) {
193
- const maxTokens = config?.maxTokens ?? getMaxTokensFromConfig();
194
- const tailTurns = config?.keepRecentTurns ?? DEFAULT_TAIL_TURNS;
195
- const budget = Math.min(MAX_TAIL_BUDGET, Math.max(MIN_TAIL_BUDGET, Math.floor(maxTokens * TAIL_TOKEN_BUDGET_RATIO)));
196
- const turnStarts = [];
197
- for (let i = 0; i < messages.length; i++) {
198
- if (messages[i].role === "user")
199
- turnStarts.push(i);
372
+ export function pruneToolOutputs(messages, options = {}) {
373
+ const protectFrom = protectFromIndex(messages);
374
+ const candidates = [];
375
+ for (let i = 0; i < protectFrom; i++) {
376
+ const msg = messages[i];
377
+ const pruned = msg.role === "tool" ? pruneToolResultMessage(msg) : msg.role === "assistant" ? pruneAssistantToolCalls(msg) : null;
378
+ if (!pruned)
379
+ continue;
380
+ candidates.push({
381
+ index: i,
382
+ pruned,
383
+ tokensSaved: Math.max(0, estimateMessageTokens(msg) - estimateMessageTokens(pruned)),
384
+ });
200
385
  }
201
- if (turnStarts.length <= 1) {
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)
403
+ continue;
404
+ replaced.set(c.index, c.pruned);
405
+ estimate -= c.tokensSaved;
406
+ }
407
+ }
408
+ if (replaced.size === 0)
409
+ return messages;
410
+ return messages.map((msg, i) => replaced.get(i) ?? msg);
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
+ }
421
+ /**
422
+ * Select how many recent turns to keep verbatim based on token budget.
423
+ */
424
+ async function selectTail(messages, config) {
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) {
202
429
  return { headEnd: 0, tailStart: 0 };
203
430
  }
204
431
  let tokensUsed = 0;
205
432
  let tailStart = messages.length;
206
- const recentTurns = turnStarts.slice(-tailTurns);
207
- for (let i = recentTurns.length - 1; i >= 0; i--) {
208
- const turnStart = recentTurns[i];
209
- const turnEnd = i < recentTurns.length - 1 ? recentTurns[i + 1] : messages.length;
210
- let turnTokens = 0;
211
- for (let j = turnStart; j < turnEnd; j++) {
212
- 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]);
213
439
  }
214
- if (tokensUsed + turnTokens > budget && tokensUsed > 0)
440
+ if (tokensUsed + unitTokens > budget && tokensUsed > 0)
215
441
  break;
216
- tokensUsed += turnTokens;
217
- tailStart = turnStart;
442
+ tokensUsed += unitTokens;
443
+ tailStart = unitStart;
218
444
  }
219
445
  if (tailStart >= messages.length)
220
- tailStart = messages.length - 2;
221
- if (tailStart < 0)
222
- 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
+ }
223
456
  return { headEnd: tailStart, tailStart };
224
457
  }
225
458
  // ─── Compaction Agent ──────────────────────────────────────────────────────
@@ -229,7 +462,9 @@ Summarize only the conversation history you are given. The newest turns may be k
229
462
 
230
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.
231
464
 
232
- 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.
233
468
 
234
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.`;
235
470
  /**
@@ -238,22 +473,43 @@ Do not answer the conversation itself. Do not mention that you are summarizing,
238
473
  * Otherwise falls back to the main model.
239
474
  */
240
475
  function resolveCompactionModel(mainModel) {
241
- const cfg = loadConfig();
476
+ const cfg = getEffectiveConfig();
242
477
  const compactionModelId = cfg.compaction?.model;
243
478
  if (compactionModelId) {
244
479
  return resolveModel(compactionModelId);
245
480
  }
246
481
  return mainModel;
247
482
  }
483
+ const SKILL_NOTE_HEADER = "## Skills Previously Loaded";
248
484
  function extractPreviousSummary(messages) {
249
485
  const first = messages[0];
250
486
  if (first?.role === "system" && typeof first.content === "string" && first.content.includes("[Context Summary")) {
251
487
  const match = first.content.match(/\[Context Summary[^\]]*\]\n\n([\s\S]*)/);
252
- return match?.[1];
488
+ return match?.[1]?.split(SKILL_NOTE_HEADER)[0]?.trimEnd();
253
489
  }
254
490
  return undefined;
255
491
  }
256
- function buildCompactionPrompt(previousSummary) {
492
+ /** Skills listed in a previous compaction note, so repeated compactions don't forget them. */
493
+ function extractNotedSkills(messages) {
494
+ const first = messages[0];
495
+ if (first?.role !== "system" || typeof first.content !== "string")
496
+ return [];
497
+ const section = first.content.split(SKILL_NOTE_HEADER)[1];
498
+ if (!section)
499
+ return [];
500
+ return section
501
+ .split("\n")
502
+ .filter((line) => line.startsWith("- "))
503
+ .map((line) => line
504
+ .slice(2)
505
+ .replace(/\s*\(base dir:.*$/, "")
506
+ .trim())
507
+ .filter(Boolean);
508
+ }
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
+ : "";
257
513
  const anchor = previousSummary
258
514
  ? [
259
515
  "Update the anchored summary below using the conversation history above.",
@@ -264,36 +520,84 @@ function buildCompactionPrompt(previousSummary) {
264
520
  "</previous-summary>",
265
521
  ].join("\n")
266
522
  : "Create a new anchored summary from the conversation history above.";
267
- return [anchor, "", SUMMARY_TEMPLATE].join("\n");
523
+ return [goal, anchor, "", SUMMARY_TEMPLATE].filter(Boolean).join("\n");
524
+ }
525
+ function isToolResultPart(p) {
526
+ return typeof p === "object" && p !== null && "type" in p && p.type === "tool-result";
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;
268
535
  }
269
536
  function messageToText(msg) {
270
537
  if (typeof msg.content === "string")
271
538
  return msg.content;
272
539
  if (Array.isArray(msg.content)) {
273
- return msg.content
274
- .filter((p) => "text" in p && typeof p.text === "string")
275
- .map((p) => p.text)
276
- .join("\n");
540
+ const parts = [];
541
+ for (const p of msg.content) {
542
+ if ("text" in p && typeof p.text === "string") {
543
+ parts.push(p.text);
544
+ }
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);
550
+ }
551
+ catch {
552
+ input = "";
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))}`);
565
+ }
566
+ }
567
+ return parts.join("\n");
277
568
  }
278
569
  return "";
279
570
  }
280
- /** Check if a message contains media (images, etc.) */
281
- function hasMedia(msg) {
282
- if (!Array.isArray(msg.content))
283
- return false;
284
- 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)}`;
285
577
  }
286
- /** Extract text-only content from a message (strip media) */
287
- function extractTextOnly(msg) {
288
- if (typeof msg.content === "string")
289
- return msg.content;
290
- if (Array.isArray(msg.content)) {
291
- return msg.content
292
- .filter((p) => p.type === "text")
293
- .map((p) => p.text)
294
- .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");
295
594
  }
296
- 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();
297
601
  }
298
602
  /**
299
603
  * Compact messages by summarizing older history with structured template.
@@ -301,56 +605,40 @@ function extractTextOnly(msg) {
301
605
  * and overflow replay.
302
606
  */
303
607
  export async function compactMessages(messages, model, config) {
304
- const cfg = loadConfig();
305
- const autoContinue = config?.autoContinue ?? cfg.compaction?.autoContinue ?? true;
306
- // Step 1: Prune old tool outputs (skip skill results)
307
- pruneToolOutputs(messages);
308
- // Step 2: Select tail (recent turns to keep verbatim)
309
- const { headEnd, tailStart } = selectTail(messages, config);
310
- if (headEnd <= 1) {
311
- return { messages, compacted: false, shouldContinue: false };
312
- }
313
- const toSummarize = messages.slice(0, headEnd);
314
- const toKeep = messages.slice(tailStart);
315
- // Step 3: Check for previous summary (incremental)
608
+ const pruned = pruneToolOutputs(messages, { force: true });
609
+ const pruneOnly = () => pruned === messages ? { messages, compacted: false } : { messages: pruned, compacted: true };
610
+ const { headEnd, tailStart } = await selectTail(pruned, config);
611
+ if (headEnd <= 0)
612
+ return pruneOnly();
613
+ const toSummarize = pruned.slice(0, headEnd);
614
+ const toKeep = pruned.slice(tailStart);
316
615
  const previousSummary = extractPreviousSummary(toSummarize);
317
- // Step 4: Build conversation text for summarization
318
- const conversationText = toSummarize
319
- .map((msg) => {
320
- const role = msg.role;
321
- const text = messageToText(msg);
322
- return `[${role}]: ${text.slice(0, 3000)}`;
323
- })
324
- .join("\n\n");
325
- // Step 5: Resolve compaction model (may differ from main model)
616
+ const conversationText = conversationForSummary(toSummarize);
326
617
  const compactionModel = resolveCompactionModel(model);
327
- // Step 6: Generate structured summary
328
618
  try {
329
- const prompt = buildCompactionPrompt(previousSummary);
330
- const result = await generateText({
331
- model: compactionModel,
332
- system: COMPACTION_AGENT_SYSTEM,
333
- messages: [
334
- { role: "user", content: conversationText + "\n\n" + prompt },
335
- ],
336
- });
619
+ const prompt = buildCompactionPrompt(previousSummary, config?.taskGoal);
620
+ const result = await summarizeWithRetry(compactionModel, conversationText + "\n\n" + prompt, config);
337
621
  const summary = result.text;
622
+ const stillPresent = collectLoadedSkillNames(toKeep);
623
+ const droppedSkills = [
624
+ ...new Set([...collectLoadedSkillNames(toSummarize), ...extractNotedSkills(toSummarize)]),
625
+ ].filter((name) => !stillPresent.has(name));
626
+ const reloadNote = buildSkillReloadNote(droppedSkills);
338
627
  const compactedMessages = [
339
628
  {
340
629
  role: "system",
341
- content: `[Context Summary - Previous conversation was compacted]\n\n${summary}`,
630
+ content: `[Context Summary - Previous conversation was compacted]\n\n${summary}${reloadNote ? `\n\n${reloadNote}` : ""}`,
342
631
  },
343
632
  ...toKeep,
344
633
  ];
345
- // Step 7: Check if the last user message had media — if so, provide replay text
346
- let replayText;
347
- const lastUserMsg = toKeep.find((m) => m.role === "user");
348
- if (lastUserMsg && hasMedia(lastUserMsg)) {
349
- replayText = extractTextOnly(lastUserMsg);
350
- }
351
- return { messages: compactedMessages, compacted: true, shouldContinue: autoContinue, replayText };
634
+ return {
635
+ messages: compactedMessages,
636
+ compacted: true,
637
+ usage: result.usage,
638
+ };
352
639
  }
353
- catch {
354
- return { messages: toKeep, compacted: true, shouldContinue: false };
640
+ catch (error) {
641
+ console.error("[compaction] summary generation failed, keeping pruned messages:", error);
642
+ return pruneOnly();
355
643
  }
356
644
  }