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.
- package/README.md +242 -31
- package/dist/agent.js +1233 -485
- package/dist/assistant-stream.js +11 -7
- 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 +7 -632
- package/dist/clipboard.js +59 -23
- package/dist/code-mode.js +35 -17
- package/dist/compaction.js +457 -169
- package/dist/config.js +298 -38
- package/dist/confirm.js +105 -9
- package/dist/context-window.js +156 -75
- package/dist/doom-loop.js +268 -26
- package/dist/fetch-timeout.js +152 -0
- package/dist/http-approvals.js +60 -0
- package/dist/http.js +119 -0
- package/dist/instructions.js +72 -33
- package/dist/logger.js +95 -0
- package/dist/markdown.js +35 -50
- package/dist/mcp.js +847 -102
- package/dist/memory.js +128 -45
- package/dist/output.js +42 -31
- package/dist/paste-handler.js +3 -3
- package/dist/permission-cli.js +43 -0
- package/dist/plugins.js +76 -11
- package/dist/pricing.js +119 -0
- package/dist/provider.js +34 -15
- 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 +74 -635
- package/dist/sessions.js +197 -15
- package/dist/skills.js +531 -77
- package/dist/synthetic.js +7 -0
- package/dist/title-gen.js +9 -2
- package/dist/token-display.js +36 -0
- package/dist/tool-display.js +178 -0
- package/dist/tool-output.js +53 -46
- package/dist/tools/apply_patch.js +265 -0
- package/dist/tools/atomic-file.js +35 -0
- package/dist/tools/backend.js +61 -0
- package/dist/tools/bash.js +186 -71
- package/dist/tools/code_search.js +13 -6
- package/dist/tools/edit.js +26 -9
- package/dist/tools/explore.js +144 -16
- package/dist/tools/glob.js +7 -3
- package/dist/tools/grep.js +153 -14
- package/dist/tools/index.js +9 -24
- package/dist/tools/question.js +31 -30
- package/dist/tools/read.js +77 -15
- package/dist/tools/search-searxng.js +223 -0
- package/dist/tools/search-serper.js +189 -0
- package/dist/tools/task.js +100 -33
- package/dist/tools/todo.js +178 -67
- package/dist/tools/web_fetch.js +158 -46
- package/dist/tools/web_search.js +217 -29
- package/dist/tools/write.js +34 -11
- package/dist/tui/App.js +89 -6
- package/dist/tui/ConfirmBar.js +57 -4
- package/dist/tui/InputBar.js +504 -44
- package/dist/tui/MessageList.js +674 -20
- package/dist/tui/ModelPicker.js +113 -0
- package/dist/tui/QuestionBar.js +136 -0
- package/dist/tui/SessionPicker.js +79 -0
- package/dist/tui/StatusBar.js +14 -12
- package/dist/tui/agent-runner.js +223 -0
- package/dist/tui/caret-pos.js +177 -0
- package/dist/tui/caret.js +69 -0
- package/dist/tui/click-count.js +13 -0
- package/dist/tui/diff-view.js +61 -0
- package/dist/tui/drag-state.js +49 -0
- package/dist/tui/hydrate.js +129 -0
- package/dist/tui/index.js +189 -31
- package/dist/tui/input-history.js +125 -0
- package/dist/tui/layout.js +88 -0
- package/dist/tui/mouse.js +46 -0
- package/dist/tui/prompt-queue.js +24 -0
- package/dist/tui/selection.js +226 -0
- package/dist/tui/session-switch.js +28 -0
- package/dist/tui/slash-commands.js +106 -0
- package/dist/tui/slash-handler.js +545 -0
- package/dist/tui/text-width.js +113 -0
- package/dist/tui/theme.js +12 -0
- package/dist/tui/token-info.js +7 -0
- package/dist/tui/tool-children.js +19 -0
- package/dist/tui/undo-stack.js +14 -0
- package/dist/tui/use-sgr-mouse.js +29 -0
- package/dist/tui-chat.js +346 -330
- package/dist/updater.js +116 -0
- package/dist/xml-search.js +194 -0
- package/docs/API.md +410 -32
- package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
- package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
- package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
- package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
- 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-16-batch1-tui-improvements-design.md +183 -0
- package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
- package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
- package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
- package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -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 +12 -8
- package/skills/self-config/SKILL.md +90 -0
- package/skills/self-config/reference.md +149 -0
package/dist/compaction.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { generateText } from "ai";
|
|
2
|
-
import {
|
|
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
|
|
55
|
-
|
|
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.
|
|
62
|
+
const TAIL_TOKEN_BUDGET_RATIO = 0.04;
|
|
58
63
|
const MIN_TAIL_BUDGET = 2000;
|
|
59
|
-
const MAX_TAIL_BUDGET =
|
|
60
|
-
const
|
|
61
|
-
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"]);
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
this.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
215
|
+
return Math.ceil(tokens);
|
|
125
216
|
}
|
|
126
217
|
// ─── Compaction Check ──────────────────────────────────────────────────────
|
|
127
218
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
219
|
+
* Resolve context window: explicit override, then model-aware detection.
|
|
220
|
+
* Undetected models use 512k.
|
|
130
221
|
*/
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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"
|
|
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
|
-
*
|
|
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
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (
|
|
199
|
-
|
|
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 (
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
let
|
|
211
|
-
|
|
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 +
|
|
440
|
+
if (tokensUsed + unitTokens > budget && tokensUsed > 0)
|
|
215
441
|
break;
|
|
216
|
-
tokensUsed +=
|
|
217
|
-
tailStart =
|
|
442
|
+
tokensUsed += unitTokens;
|
|
443
|
+
tailStart = unitStart;
|
|
218
444
|
}
|
|
219
445
|
if (tailStart >= messages.length)
|
|
220
|
-
tailStart =
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
if (
|
|
283
|
-
return
|
|
284
|
-
|
|
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
|
-
/**
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
640
|
+
catch (error) {
|
|
641
|
+
console.error("[compaction] summary generation failed, keeping pruned messages:", error);
|
|
642
|
+
return pruneOnly();
|
|
355
643
|
}
|
|
356
644
|
}
|