min-agent 0.5.0 → 0.6.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 (156) hide show
  1. package/README.md +32 -18
  2. package/dist/cli.js +538 -12
  3. package/docs/API.md +25 -8
  4. package/package.json +6 -6
  5. package/skills/self-config/SKILL.md +7 -3
  6. package/skills/self-config/reference.md +8 -4
  7. package/dist/agent.js +0 -1342
  8. package/dist/assistant-stream.js +0 -131
  9. package/dist/cli/commands/chat.js +0 -13
  10. package/dist/cli/commands/exec.js +0 -35
  11. package/dist/cli/commands/history.js +0 -58
  12. package/dist/cli/commands/index.js +0 -241
  13. package/dist/cli/commands/init.js +0 -18
  14. package/dist/cli/commands/mcp.js +0 -173
  15. package/dist/cli/commands/memory.js +0 -87
  16. package/dist/cli/commands/models.js +0 -21
  17. package/dist/cli/commands/permission.js +0 -12
  18. package/dist/cli/commands/rules.js +0 -33
  19. package/dist/cli/commands/sandbox.js +0 -13
  20. package/dist/cli/commands/serve.js +0 -9
  21. package/dist/cli/commands/setup.js +0 -4
  22. package/dist/cli/commands/shared.js +0 -16
  23. package/dist/cli/commands/skills.js +0 -119
  24. package/dist/cli/commands/think.js +0 -12
  25. package/dist/cli/commands/update.js +0 -7
  26. package/dist/cli/commands/write-config.js +0 -52
  27. package/dist/cli/errors.js +0 -36
  28. package/dist/cli/exec-prompt.js +0 -26
  29. package/dist/cli/option-helpers.js +0 -65
  30. package/dist/cli/program.js +0 -217
  31. package/dist/clipboard.js +0 -142
  32. package/dist/code-mode.js +0 -184
  33. package/dist/compaction.js +0 -644
  34. package/dist/config.js +0 -470
  35. package/dist/confirm.js +0 -172
  36. package/dist/context-window.js +0 -246
  37. package/dist/doom-loop.js +0 -278
  38. package/dist/fetch-timeout.js +0 -152
  39. package/dist/http-approvals.js +0 -60
  40. package/dist/http.js +0 -119
  41. package/dist/instructions.js +0 -196
  42. package/dist/logger.js +0 -95
  43. package/dist/markdown.js +0 -259
  44. package/dist/mcp.js +0 -1036
  45. package/dist/memory-cli.js +0 -33
  46. package/dist/memory.js +0 -295
  47. package/dist/model-catalog.js +0 -285
  48. package/dist/output.js +0 -76
  49. package/dist/paste-handler.js +0 -41
  50. package/dist/permission-cli.js +0 -40
  51. package/dist/plugins.js +0 -131
  52. package/dist/pricing.js +0 -119
  53. package/dist/provider.js +0 -63
  54. package/dist/question-format.js +0 -60
  55. package/dist/reasoning-stream.js +0 -158
  56. package/dist/sandbox-cli.js +0 -79
  57. package/dist/sandbox.js +0 -403
  58. package/dist/save-throttle.js +0 -45
  59. package/dist/scope.js +0 -23
  60. package/dist/serve/common.js +0 -425
  61. package/dist/serve/routes-chat.js +0 -367
  62. package/dist/serve/routes-mcp.js +0 -212
  63. package/dist/serve/routes-memory.js +0 -95
  64. package/dist/serve/routes-meta.js +0 -233
  65. package/dist/serve/routes-sessions.js +0 -61
  66. package/dist/serve/routes-skills.js +0 -70
  67. package/dist/serve.js +0 -138
  68. package/dist/sessions.js +0 -265
  69. package/dist/skills.js +0 -599
  70. package/dist/structured-output.js +0 -29
  71. package/dist/synthetic.js +0 -7
  72. package/dist/think-cli.js +0 -36
  73. package/dist/thinking-wire.js +0 -228
  74. package/dist/thinking.js +0 -142
  75. package/dist/title-gen.js +0 -55
  76. package/dist/token-display.js +0 -39
  77. package/dist/tool-display.js +0 -178
  78. package/dist/tool-output.js +0 -126
  79. package/dist/tools/apply_patch.js +0 -265
  80. package/dist/tools/atomic-file.js +0 -35
  81. package/dist/tools/backend.js +0 -61
  82. package/dist/tools/bash.js +0 -231
  83. package/dist/tools/code_search.js +0 -98
  84. package/dist/tools/edit.js +0 -68
  85. package/dist/tools/explore.js +0 -232
  86. package/dist/tools/glob.js +0 -40
  87. package/dist/tools/grep.js +0 -174
  88. package/dist/tools/index.js +0 -34
  89. package/dist/tools/question.js +0 -54
  90. package/dist/tools/read.js +0 -109
  91. package/dist/tools/search-searxng.js +0 -223
  92. package/dist/tools/search-serper.js +0 -189
  93. package/dist/tools/task.js +0 -165
  94. package/dist/tools/todo.js +0 -213
  95. package/dist/tools/web_fetch.js +0 -195
  96. package/dist/tools/web_search.js +0 -228
  97. package/dist/tools/write.js +0 -55
  98. package/dist/tui/App.js +0 -126
  99. package/dist/tui/ConfirmBar.js +0 -66
  100. package/dist/tui/InputBar.js +0 -616
  101. package/dist/tui/MessageList.js +0 -718
  102. package/dist/tui/ModelPicker.js +0 -113
  103. package/dist/tui/QuestionBar.js +0 -136
  104. package/dist/tui/SessionPicker.js +0 -79
  105. package/dist/tui/Spinner.js +0 -6
  106. package/dist/tui/StatusBar.js +0 -22
  107. package/dist/tui/ThinkPicker.js +0 -77
  108. package/dist/tui/agent-runner.js +0 -223
  109. package/dist/tui/bracketed-paste.js +0 -37
  110. package/dist/tui/caret-pos.js +0 -179
  111. package/dist/tui/caret.js +0 -69
  112. package/dist/tui/click-count.js +0 -13
  113. package/dist/tui/diff-view.js +0 -61
  114. package/dist/tui/drag-state.js +0 -49
  115. package/dist/tui/hydrate.js +0 -129
  116. package/dist/tui/index.js +0 -258
  117. package/dist/tui/input-history.js +0 -125
  118. package/dist/tui/layout.js +0 -105
  119. package/dist/tui/mouse.js +0 -46
  120. package/dist/tui/overlay-input.js +0 -12
  121. package/dist/tui/paste-draft.js +0 -173
  122. package/dist/tui/prompt-queue.js +0 -24
  123. package/dist/tui/selection.js +0 -232
  124. package/dist/tui/session-switch.js +0 -28
  125. package/dist/tui/slash-commands.js +0 -123
  126. package/dist/tui/slash-handler.js +0 -589
  127. package/dist/tui/text-width.js +0 -113
  128. package/dist/tui/theme.js +0 -12
  129. package/dist/tui/token-info.js +0 -7
  130. package/dist/tui/tool-children.js +0 -19
  131. package/dist/tui/types.js +0 -1
  132. package/dist/tui/undo-stack.js +0 -14
  133. package/dist/tui/use-sgr-mouse.js +0 -29
  134. package/dist/tui-chat.js +0 -456
  135. package/dist/updater.js +0 -116
  136. package/dist/xml-search.js +0 -194
  137. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +0 -1510
  138. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +0 -2105
  139. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +0 -1595
  140. package/docs/superpowers/plans/2026-08-16-input-caret.md +0 -782
  141. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +0 -873
  142. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +0 -631
  143. package/docs/superpowers/plans/2026-08-23-input-paste-attachments.md +0 -475
  144. package/docs/superpowers/plans/2026-08-23-thinking-wire-profile.md +0 -450
  145. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +0 -183
  146. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +0 -220
  147. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +0 -196
  148. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +0 -63
  149. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +0 -116
  150. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +0 -47
  151. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +0 -37
  152. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +0 -68
  153. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +0 -273
  154. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +0 -165
  155. package/docs/superpowers/specs/2026-08-23-input-paste-attachments-design.md +0 -174
  156. package/docs/superpowers/specs/2026-08-23-thinking-wire-profile-design.md +0 -140
@@ -1,644 +0,0 @@
1
- import { generateText } from "ai";
2
- import { getEffectiveConfig } from "./config.js";
3
- import { resolveModel } from "./provider.js";
4
- import { getContextWindowInfo, DEFAULT_CONTEXT_WINDOW } from "./context-window.js";
5
- import { collectLoadedSkillNames, buildSkillReloadNote } from "./skills.js";
6
- /**
7
- * Context compaction system — modeled after opencode's SessionCompaction.
8
- *
9
- * Features:
10
- * 1. Real token tracking from API responses
11
- * 2. Structured summary template (Goal/Progress/Decisions/Files)
12
- * 3. Incremental summaries (update previous summary instead of rewriting)
13
- * 4. Tool output pruning with skill protection
14
- * 5. Auto-continue after compaction with overflow replay
15
- * 6. Token-budget-aware tail preservation
16
- * 7. Model-aware thresholds (uses actual context window)
17
- * 8. Configurable compaction model
18
- */
19
- // ─── Structured Summary Template ───────────────────────────────────────────
20
- const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown below. Keep the section order unchanged.
21
-
22
- ## Goal
23
- - [single-sentence task summary]
24
-
25
- ## Constraints & Preferences
26
- - [user constraints, preferences, specs, or "(none)"]
27
-
28
- ## Progress
29
- ### Done
30
- - [completed work or "(none)"]
31
-
32
- ### In Progress
33
- - [current work or "(none)"]
34
-
35
- ### Blocked
36
- - [blockers or "(none)"]
37
-
38
- ## Key Decisions
39
- - [decision and why, or "(none)"]
40
-
41
- ## Next Steps
42
- - [ordered next actions or "(none)"]
43
-
44
- ## Critical Context
45
- - [important technical facts, numbers, dates, scores, URLs, errors, open questions, or "(none)"]
46
-
47
- ## Relevant Files
48
- - [file or directory path: why it matters, or "(none)"]
49
-
50
- Rules:
51
- - Keep every section, even when empty.
52
- - Use terse bullets, not prose paragraphs.
53
- - Preserve exact file paths, commands, error strings, and identifiers when known.
54
- - Do not mention the summary process or that context was compacted.`;
55
- // ─── Constants ─────────────────────────────────────────────────────────────
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;
61
- const DEFAULT_TAIL_TURNS = 2;
62
- const TAIL_TOKEN_BUDGET_RATIO = 0.04;
63
- const MIN_TAIL_BUDGET = 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"]);
73
- /** Tools whose output should never be pruned during compaction */
74
- const PRUNE_PROTECTED_TOOLS = new Set(["skill"]);
75
- // ─── Token Tracker ─────────────────────────────────────────────────────────
76
- export class TokenTracker {
77
- _lastInputTokens = 0;
78
- _totalOutputTokens = 0;
79
- _totalInputTokens = 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
- }
91
- update(usage) {
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();
103
- }
104
- get lastInputTokens() {
105
- return this._lastInputTokens;
106
- }
107
- get totalInputTokens() {
108
- return this._totalInputTokens;
109
- }
110
- get totalOutputTokens() {
111
- return this._totalOutputTokens;
112
- }
113
- summary() {
114
- return `context: ${this._lastInputTokens} | total in: ${this._totalInputTokens} out: ${this._totalOutputTokens}`;
115
- }
116
- reset() {
117
- this._lastInputTokens = 0;
118
- this._totalInputTokens = 0;
119
- this._totalOutputTokens = 0;
120
- this.emit();
121
- }
122
- resetContext() {
123
- this._lastInputTokens = 0;
124
- this.emit();
125
- }
126
- }
127
- // ─── Token Estimation ──────────────────────────────────────────────────────
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));
157
- }
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;
175
- }
176
- tokens += estimatePartTokens(part);
177
- }
178
- return tokens;
179
- }
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));
189
- }
190
- function estimateMessageTokens(msg) {
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
- }
213
- }
214
- }
215
- return Math.ceil(tokens);
216
- }
217
- // ─── Compaction Check ──────────────────────────────────────────────────────
218
- /**
219
- * Resolve context window: explicit override, then model-aware detection.
220
- * Undetected models use 512k.
221
- */
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;
242
- if (tracker && tracker.lastInputTokens > 0) {
243
- // Reported input tokens already include the system prompt and tool schemas.
244
- return tracker.lastInputTokens > threshold;
245
- }
246
- return estimateTokens(messages) + overhead > threshold;
247
- }
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;
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
- }
283
- function isProtectedToolMessage(msg) {
284
- if (typeof msg.content === "string") {
285
- return msg.content.includes("<skill_content");
286
- }
287
- if (Array.isArray(msg.content)) {
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));
291
- }
292
- return false;
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
- }
361
- /**
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.
371
- */
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
- });
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)
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) {
429
- return { headEnd: 0, tailStart: 0 };
430
- }
431
- let tokensUsed = 0;
432
- let tailStart = messages.length;
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]);
439
- }
440
- if (tokensUsed + unitTokens > budget && tokensUsed > 0)
441
- break;
442
- tokensUsed += unitTokens;
443
- tailStart = unitStart;
444
- }
445
- if (tailStart >= messages.length)
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
- }
456
- return { headEnd: tailStart, tailStart };
457
- }
458
- // ─── Compaction Agent ──────────────────────────────────────────────────────
459
- const COMPACTION_AGENT_SYSTEM = `You are an anchored context summarization assistant for coding sessions.
460
-
461
- Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.
462
-
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.
464
-
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.
468
-
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.`;
470
- /**
471
- * Resolve the model to use for compaction.
472
- * If config.compaction.model is set, use that (allows cheap/fast model for summaries).
473
- * Otherwise falls back to the main model.
474
- */
475
- function resolveCompactionModel(mainModel) {
476
- const cfg = getEffectiveConfig();
477
- const compactionModelId = cfg.compaction?.model;
478
- if (compactionModelId) {
479
- return resolveModel(compactionModelId);
480
- }
481
- return mainModel;
482
- }
483
- const SKILL_NOTE_HEADER = "## Skills Previously Loaded";
484
- function extractPreviousSummary(messages) {
485
- const first = messages[0];
486
- if (first?.role === "system" && typeof first.content === "string" && first.content.includes("[Context Summary")) {
487
- const match = first.content.match(/\[Context Summary[^\]]*\]\n\n([\s\S]*)/);
488
- return match?.[1]?.split(SKILL_NOTE_HEADER)[0]?.trimEnd();
489
- }
490
- return undefined;
491
- }
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
- : "";
513
- const anchor = previousSummary
514
- ? [
515
- "Update the anchored summary below using the conversation history above.",
516
- "Preserve still-true details, remove stale details, and merge in the new facts.",
517
- "",
518
- "<previous-summary>",
519
- previousSummary,
520
- "</previous-summary>",
521
- ].join("\n")
522
- : "Create a new anchored summary from the conversation history above.";
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;
535
- }
536
- function messageToText(msg) {
537
- if (typeof msg.content === "string")
538
- return msg.content;
539
- if (Array.isArray(msg.content)) {
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");
568
- }
569
- return "";
570
- }
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)}`;
577
- }
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");
594
- }
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();
601
- }
602
- /**
603
- * Compact messages by summarizing older history with structured template.
604
- * Supports incremental summaries, skill protection, configurable model,
605
- * and overflow replay.
606
- */
607
- export async function compactMessages(messages, model, config) {
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);
615
- const previousSummary = extractPreviousSummary(toSummarize);
616
- const conversationText = conversationForSummary(toSummarize);
617
- const compactionModel = resolveCompactionModel(model);
618
- try {
619
- const prompt = buildCompactionPrompt(previousSummary, config?.taskGoal);
620
- const result = await summarizeWithRetry(compactionModel, conversationText + "\n\n" + prompt, config);
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);
627
- const compactedMessages = [
628
- {
629
- role: "system",
630
- content: `[Context Summary - Previous conversation was compacted]\n\n${summary}${reloadNote ? `\n\n${reloadNote}` : ""}`,
631
- },
632
- ...toKeep,
633
- ];
634
- return {
635
- messages: compactedMessages,
636
- compacted: true,
637
- usage: result.usage,
638
- };
639
- }
640
- catch (error) {
641
- console.error("[compaction] summary generation failed, keeping pruned messages:", error);
642
- return pruneOnly();
643
- }
644
- }