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.
- package/README.md +111 -28
- package/dist/agent.js +1119 -256
- package/dist/cli/commands/chat.js +10 -0
- package/dist/cli/commands/exec.js +32 -0
- package/dist/cli/commands/history.js +58 -0
- package/dist/cli/commands/index.js +224 -0
- package/dist/cli/commands/init.js +18 -0
- package/dist/cli/commands/mcp.js +173 -0
- package/dist/cli/commands/memory.js +69 -0
- package/dist/cli/commands/models.js +21 -0
- package/dist/cli/commands/permission.js +12 -0
- package/dist/cli/commands/rules.js +33 -0
- package/dist/cli/commands/sandbox.js +13 -0
- package/dist/cli/commands/serve.js +9 -0
- package/dist/cli/commands/setup.js +4 -0
- package/dist/cli/commands/shared.js +16 -0
- package/dist/cli/commands/skills.js +119 -0
- package/dist/cli/commands/update.js +7 -0
- package/dist/cli/commands/write-config.js +30 -0
- package/dist/cli/errors.js +36 -0
- package/dist/cli/exec-prompt.js +26 -0
- package/dist/cli/option-helpers.js +53 -0
- package/dist/cli/program.js +180 -0
- package/dist/cli.js +5 -888
- package/dist/code-mode.js +32 -14
- package/dist/compaction.js +347 -160
- package/dist/config.js +119 -10
- package/dist/confirm.js +56 -9
- package/dist/context-window.js +107 -39
- package/dist/doom-loop.js +264 -29
- package/dist/fetch-timeout.js +152 -0
- package/dist/http-approvals.js +60 -0
- package/dist/instructions.js +21 -0
- package/dist/logger.js +33 -4
- package/dist/markdown.js +37 -11
- package/dist/mcp.js +328 -30
- package/dist/memory.js +97 -56
- package/dist/output.js +7 -5
- package/dist/permission-cli.js +43 -0
- package/dist/plugins.js +46 -8
- package/dist/pricing.js +4 -4
- package/dist/provider.js +23 -6
- package/dist/question-format.js +60 -0
- package/dist/sandbox-cli.js +82 -0
- package/dist/sandbox.js +403 -0
- package/dist/save-throttle.js +45 -0
- package/dist/serve/common.js +404 -0
- package/dist/serve/routes-chat.js +347 -0
- package/dist/serve/routes-mcp.js +212 -0
- package/dist/serve/routes-memory.js +66 -0
- package/dist/serve/routes-meta.js +205 -0
- package/dist/serve/routes-sessions.js +61 -0
- package/dist/serve/routes-skills.js +70 -0
- package/dist/serve.js +33 -883
- package/dist/sessions.js +53 -9
- package/dist/skills.js +82 -18
- package/dist/title-gen.js +8 -2
- package/dist/token-display.js +36 -0
- package/dist/tool-display.js +5 -0
- package/dist/tool-output.js +1 -3
- package/dist/tools/apply_patch.js +85 -11
- package/dist/tools/atomic-file.js +35 -0
- package/dist/tools/backend.js +2 -2
- package/dist/tools/bash.js +57 -19
- package/dist/tools/code_search.js +7 -1
- package/dist/tools/edit.js +11 -10
- package/dist/tools/explore.js +74 -14
- package/dist/tools/glob.js +4 -0
- package/dist/tools/grep.js +17 -10
- package/dist/tools/index.js +6 -21
- package/dist/tools/question.js +28 -9
- package/dist/tools/read.js +6 -4
- package/dist/tools/search-searxng.js +223 -0
- package/dist/tools/search-serper.js +189 -0
- package/dist/tools/task.js +84 -30
- package/dist/tools/todo.js +120 -19
- package/dist/tools/web_fetch.js +11 -3
- package/dist/tools/web_search.js +66 -556
- package/dist/tools/write.js +23 -6
- package/dist/tui/App.js +63 -14
- package/dist/tui/ConfirmBar.js +45 -13
- package/dist/tui/InputBar.js +150 -35
- package/dist/tui/MessageList.js +266 -125
- package/dist/tui/ModelPicker.js +8 -3
- package/dist/tui/QuestionBar.js +51 -19
- package/dist/tui/SessionPicker.js +79 -0
- package/dist/tui/StatusBar.js +8 -14
- package/dist/tui/agent-runner.js +142 -22
- package/dist/tui/caret-pos.js +48 -5
- package/dist/tui/caret.js +1 -1
- package/dist/tui/click-count.js +13 -0
- package/dist/tui/drag-state.js +8 -3
- package/dist/tui/hydrate.js +129 -0
- package/dist/tui/index.js +42 -13
- package/dist/tui/input-history.js +92 -11
- package/dist/tui/layout.js +75 -4
- package/dist/tui/prompt-queue.js +24 -0
- package/dist/tui/selection.js +113 -21
- package/dist/tui/session-switch.js +28 -0
- package/dist/tui/slash-commands.js +22 -6
- package/dist/tui/slash-handler.js +233 -58
- package/dist/tui/text-width.js +38 -16
- package/dist/tui/token-info.js +7 -0
- package/dist/tui/tool-children.js +19 -0
- package/dist/tui/undo-stack.js +1 -1
- package/dist/tui/use-sgr-mouse.js +3 -1
- package/dist/tui-chat.js +276 -40
- package/dist/updater.js +88 -29
- package/dist/xml-search.js +194 -0
- package/docs/API.md +257 -25
- package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
- package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
- package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
- package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
- package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
- package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
- package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
- package/package.json +6 -1
- package/skills/self-config/SKILL.md +90 -0
- package/skills/self-config/reference.md +149 -0
package/dist/compaction.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { generateText } from "ai";
|
|
2
|
-
import {
|
|
2
|
+
import { getEffectiveConfig } from "./config.js";
|
|
3
3
|
import { resolveModel } from "./provider.js";
|
|
4
|
-
import {
|
|
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
|
|
57
|
-
|
|
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.
|
|
62
|
+
const TAIL_TOKEN_BUDGET_RATIO = 0.04;
|
|
60
63
|
const MIN_TAIL_BUDGET = 2000;
|
|
61
|
-
const MAX_TAIL_BUDGET =
|
|
62
|
-
const
|
|
63
|
-
const
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 (
|
|
131
|
-
|
|
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
|
|
219
|
+
* Resolve context window: explicit override, then model-aware detection.
|
|
220
|
+
* Undetected models use 512k.
|
|
148
221
|
*/
|
|
149
|
-
async function
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
226
|
+
return await getContextWindowInfo(config?.modelId);
|
|
156
227
|
}
|
|
157
228
|
catch {
|
|
158
|
-
return
|
|
229
|
+
return { tokens: DEFAULT_CONTEXT_WINDOW, source: "fallback" };
|
|
159
230
|
}
|
|
160
231
|
}
|
|
161
232
|
/**
|
|
162
|
-
* Check if
|
|
163
|
-
* Uses
|
|
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
|
-
|
|
167
|
-
|
|
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
|
|
197
|
-
*
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
let
|
|
260
|
-
|
|
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 +
|
|
440
|
+
if (tokensUsed + unitTokens > budget && tokensUsed > 0)
|
|
264
441
|
break;
|
|
265
|
-
tokensUsed +=
|
|
266
|
-
tailStart =
|
|
442
|
+
tokensUsed += unitTokens;
|
|
443
|
+
tailStart = unitStart;
|
|
267
444
|
}
|
|
268
445
|
if (tailStart >= messages.length)
|
|
269
|
-
tailStart =
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 (
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
if (
|
|
365
|
-
return
|
|
366
|
-
|
|
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
|
-
/**
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
|
387
|
-
const
|
|
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 <=
|
|
393
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
|
455
|
-
return
|
|
641
|
+
console.error("[compaction] summary generation failed, keeping pruned messages:", error);
|
|
642
|
+
return pruneOnly();
|
|
456
643
|
}
|
|
457
644
|
}
|