min-agent 0.2.0 → 0.3.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 +146 -18
- package/dist/agent.js +293 -408
- package/dist/assistant-stream.js +11 -7
- package/dist/cli.js +403 -140
- package/dist/clipboard.js +59 -23
- package/dist/code-mode.js +3 -3
- package/dist/compaction.js +182 -81
- package/dist/config.js +186 -35
- package/dist/confirm.js +55 -6
- package/dist/context-window.js +67 -54
- package/dist/doom-loop.js +19 -12
- package/dist/http.js +119 -0
- package/dist/instructions.js +51 -33
- package/dist/logger.js +66 -0
- package/dist/markdown.js +3 -44
- package/dist/mcp.js +547 -100
- package/dist/memory.js +48 -6
- package/dist/output.js +36 -27
- package/dist/paste-handler.js +3 -3
- package/dist/plugins.js +33 -6
- package/dist/pricing.js +119 -0
- package/dist/provider.js +17 -15
- package/dist/serve.js +658 -369
- package/dist/sessions.js +151 -13
- package/dist/skills.js +466 -76
- package/dist/synthetic.js +7 -0
- package/dist/title-gen.js +2 -1
- package/dist/tool-display.js +173 -0
- package/dist/tool-output.js +54 -45
- package/dist/tools/apply_patch.js +191 -0
- package/dist/tools/backend.js +61 -0
- package/dist/tools/bash.js +147 -70
- package/dist/tools/code_search.js +6 -5
- package/dist/tools/edit.js +23 -7
- package/dist/tools/explore.js +80 -12
- package/dist/tools/glob.js +3 -3
- package/dist/tools/grep.js +146 -14
- package/dist/tools/index.js +7 -7
- package/dist/tools/question.js +4 -22
- package/dist/tools/read.js +71 -11
- package/dist/tools/task.js +33 -20
- package/dist/tools/todo.js +83 -73
- package/dist/tools/web_fetch.js +150 -46
- package/dist/tools/web_search.js +706 -28
- package/dist/tools/write.js +13 -7
- package/dist/tui/App.js +40 -6
- package/dist/tui/ConfirmBar.js +24 -3
- package/dist/tui/InputBar.js +390 -45
- package/dist/tui/MessageList.js +533 -20
- package/dist/tui/ModelPicker.js +108 -0
- package/dist/tui/QuestionBar.js +104 -0
- package/dist/tui/StatusBar.js +19 -11
- package/dist/tui/agent-runner.js +103 -0
- package/dist/tui/caret-pos.js +134 -0
- package/dist/tui/caret.js +69 -0
- package/dist/tui/diff-view.js +61 -0
- package/dist/tui/drag-state.js +44 -0
- package/dist/tui/index.js +153 -24
- package/dist/tui/input-history.js +44 -0
- package/dist/tui/layout.js +17 -0
- package/dist/tui/mouse.js +46 -0
- package/dist/tui/selection.js +134 -0
- package/dist/tui/slash-commands.js +90 -0
- package/dist/tui/slash-handler.js +370 -0
- package/dist/tui/text-width.js +91 -0
- package/dist/tui/theme.js +12 -0
- package/dist/tui/undo-stack.js +14 -0
- package/dist/tui/use-sgr-mouse.js +27 -0
- package/dist/tui-chat.js +111 -331
- package/dist/updater.js +57 -0
- package/docs/API.md +160 -14
- 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/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/package.json +7 -8
package/dist/clipboard.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { execSync } from "child_process";
|
|
2
|
+
import { readFileSync, unlinkSync } from "fs";
|
|
2
3
|
import path from "path";
|
|
3
4
|
import os from "os";
|
|
5
|
+
function readTmpImage(tmpFile) {
|
|
6
|
+
const data = readFileSync(tmpFile);
|
|
7
|
+
try {
|
|
8
|
+
unlinkSync(tmpFile);
|
|
9
|
+
}
|
|
10
|
+
catch { }
|
|
11
|
+
return data.length > 0 ? data : null;
|
|
12
|
+
}
|
|
13
|
+
function tmpImagePath() {
|
|
14
|
+
return path.join(os.tmpdir(), `min-agent-paste-${Date.now()}.png`);
|
|
15
|
+
}
|
|
4
16
|
export function getClipboardImage() {
|
|
5
17
|
switch (process.platform) {
|
|
6
18
|
case "darwin":
|
|
@@ -13,18 +25,52 @@ export function getClipboardImage() {
|
|
|
13
25
|
return null;
|
|
14
26
|
}
|
|
15
27
|
}
|
|
28
|
+
/** Write plain text to the system clipboard. False on failure (silent). */
|
|
29
|
+
export function writeClipboard(text) {
|
|
30
|
+
switch (process.platform) {
|
|
31
|
+
case "darwin":
|
|
32
|
+
try {
|
|
33
|
+
execSync("pbcopy", { input: text, stdio: "pipe" });
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
case "linux": {
|
|
40
|
+
try {
|
|
41
|
+
execSync("xclip -selection clipboard", { input: text, stdio: "pipe" });
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch { }
|
|
45
|
+
try {
|
|
46
|
+
execSync("xsel --clipboard --input", { input: text, stdio: "pipe" });
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
case "win32":
|
|
53
|
+
try {
|
|
54
|
+
execSync('powershell -NoProfile -Command "Set-Clipboard -Value ([Console]::In.ReadToEnd())"', {
|
|
55
|
+
input: text,
|
|
56
|
+
stdio: "pipe",
|
|
57
|
+
});
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
default:
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
16
67
|
function getClipboardImageMac() {
|
|
17
|
-
const tmpFile =
|
|
68
|
+
const tmpFile = tmpImagePath();
|
|
18
69
|
try {
|
|
19
70
|
// Try pngpaste first (brew install pngpaste)
|
|
20
71
|
execSync(`pngpaste "${tmpFile}" 2>/dev/null`, { stdio: "pipe" });
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
try {
|
|
24
|
-
require("fs").unlinkSync(tmpFile);
|
|
25
|
-
}
|
|
26
|
-
catch { }
|
|
27
|
-
if (data.length > 0)
|
|
72
|
+
const data = readTmpImage(tmpFile);
|
|
73
|
+
if (data)
|
|
28
74
|
return { data, mimeType: "image/png" };
|
|
29
75
|
}
|
|
30
76
|
catch { }
|
|
@@ -44,13 +90,8 @@ function getClipboardImageMac() {
|
|
|
44
90
|
`;
|
|
45
91
|
const result = execSync(`osascript -e '${script.replace(/'/g, "'\\''")}'`, { encoding: "utf-8" }).trim();
|
|
46
92
|
if (result === "ok") {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
unlinkSync(tmpFile);
|
|
51
|
-
}
|
|
52
|
-
catch { }
|
|
53
|
-
if (data.length > 0)
|
|
93
|
+
const data = readTmpImage(tmpFile);
|
|
94
|
+
if (data)
|
|
54
95
|
return { data, mimeType: "image/png" };
|
|
55
96
|
}
|
|
56
97
|
}
|
|
@@ -81,7 +122,7 @@ function getClipboardImageLinux() {
|
|
|
81
122
|
return null;
|
|
82
123
|
}
|
|
83
124
|
function getClipboardImageWindows() {
|
|
84
|
-
const tmpFile =
|
|
125
|
+
const tmpFile = tmpImagePath();
|
|
85
126
|
try {
|
|
86
127
|
const ps = `
|
|
87
128
|
Add-Type -AssemblyName System.Windows.Forms
|
|
@@ -91,13 +132,8 @@ function getClipboardImageWindows() {
|
|
|
91
132
|
`;
|
|
92
133
|
const result = execSync(`powershell -NoProfile -Command "${ps}"`, { encoding: "utf-8" }).trim();
|
|
93
134
|
if (result === "ok") {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
try {
|
|
97
|
-
unlinkSync(tmpFile);
|
|
98
|
-
}
|
|
99
|
-
catch { }
|
|
100
|
-
if (data.length > 0)
|
|
135
|
+
const data = readTmpImage(tmpFile);
|
|
136
|
+
if (data)
|
|
101
137
|
return { data, mimeType: "image/png" };
|
|
102
138
|
}
|
|
103
139
|
}
|
package/dist/code-mode.js
CHANGED
|
@@ -7,19 +7,19 @@ export function scanProject() {
|
|
|
7
7
|
let branch;
|
|
8
8
|
if (isGitRepo) {
|
|
9
9
|
try {
|
|
10
|
-
branch = execSync("git branch --show-current", { encoding: "utf-8", cwd }).trim();
|
|
10
|
+
branch = execSync("git branch --show-current", { encoding: "utf-8", cwd, timeout: 5000 }).trim();
|
|
11
11
|
}
|
|
12
12
|
catch { }
|
|
13
13
|
}
|
|
14
14
|
const languages = [];
|
|
15
15
|
const configFiles = [];
|
|
16
16
|
const entryFiles = [];
|
|
17
|
-
// Detect by config files
|
|
17
|
+
// Detect by config files (lockfiles first so they win over package.json)
|
|
18
18
|
const checks = [
|
|
19
|
-
{ file: "package.json", lang: "TypeScript/JavaScript", pm: "npm" },
|
|
20
19
|
{ file: "bun.lock", lang: "TypeScript/JavaScript", pm: "bun" },
|
|
21
20
|
{ file: "yarn.lock", lang: "TypeScript/JavaScript", pm: "yarn" },
|
|
22
21
|
{ file: "pnpm-lock.yaml", lang: "TypeScript/JavaScript", pm: "pnpm" },
|
|
22
|
+
{ file: "package.json", lang: "TypeScript/JavaScript", pm: "npm" },
|
|
23
23
|
{ file: "tsconfig.json", lang: "TypeScript" },
|
|
24
24
|
{ file: "Cargo.toml", lang: "Rust", pm: "cargo" },
|
|
25
25
|
{ file: "go.mod", lang: "Go" },
|
package/dist/compaction.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { generateText } from "ai";
|
|
2
|
-
import { loadConfig } from "./config.js";
|
|
2
|
+
import { loadConfig, getActiveProvider } from "./config.js";
|
|
3
3
|
import { resolveModel } from "./provider.js";
|
|
4
|
+
import { getContextWindow } 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
|
*
|
|
@@ -57,7 +59,7 @@ const DEFAULT_TAIL_TURNS = 2;
|
|
|
57
59
|
const TAIL_TOKEN_BUDGET_RATIO = 0.25;
|
|
58
60
|
const MIN_TAIL_BUDGET = 2000;
|
|
59
61
|
const MAX_TAIL_BUDGET = 8000;
|
|
60
|
-
const PRUNE_PROTECT_TOKENS =
|
|
62
|
+
const PRUNE_PROTECT_TOKENS = 24000;
|
|
61
63
|
const TOOL_OUTPUT_MAX_CHARS = 2000;
|
|
62
64
|
/** Tools whose output should never be pruned during compaction */
|
|
63
65
|
const PRUNE_PROTECTED_TOOLS = new Set(["skill"]);
|
|
@@ -68,10 +70,15 @@ export class TokenTracker {
|
|
|
68
70
|
_totalInputTokens = 0;
|
|
69
71
|
_totalCacheRead = 0;
|
|
70
72
|
update(usage) {
|
|
73
|
+
this.add(usage);
|
|
74
|
+
this._lastInputTokens = usage.inputTokens ?? 0;
|
|
75
|
+
}
|
|
76
|
+
/** Add to running totals only (e.g. compaction / sub-agent calls), without
|
|
77
|
+
* touching the "last step input" used for context-window display. */
|
|
78
|
+
add(usage) {
|
|
71
79
|
const input = usage.inputTokens ?? 0;
|
|
72
80
|
const output = usage.outputTokens ?? 0;
|
|
73
81
|
const cacheRead = usage.cachedInputTokens ?? 0;
|
|
74
|
-
this._lastInputTokens = input;
|
|
75
82
|
this._totalInputTokens += input;
|
|
76
83
|
this._totalOutputTokens += output;
|
|
77
84
|
this._totalCacheRead += cacheRead;
|
|
@@ -93,104 +100,146 @@ export class TokenTracker {
|
|
|
93
100
|
}
|
|
94
101
|
}
|
|
95
102
|
// ─── Token Estimation ──────────────────────────────────────────────────────
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
103
|
+
function isCjk(code) {
|
|
104
|
+
return ((code >= 0x2e80 && code <= 0x9fff) ||
|
|
105
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
106
|
+
(code >= 0xff00 && code <= 0xffef) ||
|
|
107
|
+
(code >= 0x20000 && code <= 0x3fffd) ||
|
|
108
|
+
(code >= 0x3040 && code <= 0x30ff) ||
|
|
109
|
+
(code >= 0xac00 && code <= 0xd7af));
|
|
110
|
+
}
|
|
111
|
+
/** ~4 ASCII chars per token, CJK chars weighted separately (much denser in tokens). */
|
|
112
|
+
function estimateTextTokens(text) {
|
|
113
|
+
let ascii = 0;
|
|
114
|
+
let other = 0;
|
|
115
|
+
for (const ch of text) {
|
|
116
|
+
if (isCjk(ch.codePointAt(0) ?? 0))
|
|
117
|
+
other++;
|
|
118
|
+
else
|
|
119
|
+
ascii++;
|
|
110
120
|
}
|
|
111
|
-
return
|
|
121
|
+
return ascii / 4 + other * 0.7;
|
|
112
122
|
}
|
|
113
|
-
function
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
function estimateMessageTextTokens(msg) {
|
|
124
|
+
const content = msg.content;
|
|
125
|
+
if (typeof content === "string")
|
|
126
|
+
return estimateTextTokens(content);
|
|
127
|
+
if (Array.isArray(content)) {
|
|
128
|
+
let tokens = 0;
|
|
129
|
+
for (const part of content) {
|
|
130
|
+
if ("text" in part && typeof part.text === "string") {
|
|
131
|
+
tokens += estimateTextTokens(part.text);
|
|
132
|
+
}
|
|
121
133
|
}
|
|
122
|
-
return
|
|
134
|
+
return tokens;
|
|
123
135
|
}
|
|
124
136
|
return 0;
|
|
125
137
|
}
|
|
138
|
+
/** Token estimation: ASCII ~4 chars/token, CJK ~0.7 token/char (aligned with opencode) */
|
|
139
|
+
export function estimateTokens(messages) {
|
|
140
|
+
return Math.ceil(messages.reduce((sum, msg) => sum + estimateMessageTextTokens(msg), 0));
|
|
141
|
+
}
|
|
142
|
+
function estimateMessageTokens(msg) {
|
|
143
|
+
return Math.ceil(estimateMessageTextTokens(msg));
|
|
144
|
+
}
|
|
126
145
|
// ─── Compaction Check ──────────────────────────────────────────────────────
|
|
146
|
+
/**
|
|
147
|
+
* Resolve max tokens: explicit user config first, then model-aware detection.
|
|
148
|
+
*/
|
|
149
|
+
async function resolveMaxTokens() {
|
|
150
|
+
const cfg = loadConfig();
|
|
151
|
+
const provider = getActiveProvider(cfg);
|
|
152
|
+
if (provider?.contextWindow)
|
|
153
|
+
return provider.contextWindow;
|
|
154
|
+
try {
|
|
155
|
+
return await getContextWindow(provider?.defaultModel);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return DEFAULT_MAX_TOKENS;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
127
161
|
/**
|
|
128
162
|
* Check if compaction is needed.
|
|
129
|
-
* Uses model-aware context window
|
|
163
|
+
* Uses model-aware context window (user config or provider detection).
|
|
130
164
|
*/
|
|
131
|
-
export function needsCompaction(messages, tracker, config) {
|
|
132
|
-
const maxTokens = config?.maxTokens ??
|
|
165
|
+
export async function needsCompaction(messages, tracker, config) {
|
|
166
|
+
const maxTokens = config?.maxTokens ?? await resolveMaxTokens();
|
|
133
167
|
const threshold = maxTokens * COMPACTION_RATIO;
|
|
134
168
|
if (tracker && tracker.lastInputTokens > 0) {
|
|
135
169
|
return tracker.lastInputTokens > threshold;
|
|
136
170
|
}
|
|
137
171
|
return estimateTokens(messages) > threshold;
|
|
138
172
|
}
|
|
139
|
-
/** Get max tokens from user config (model-aware) */
|
|
140
|
-
function getMaxTokensFromConfig() {
|
|
141
|
-
const cfg = loadConfig();
|
|
142
|
-
return cfg.provider?.contextWindow ?? DEFAULT_MAX_TOKENS;
|
|
143
|
-
}
|
|
144
173
|
// ─── Tool Output Pruning ───────────────────────────────────────────────────
|
|
174
|
+
function toolResultText(part) {
|
|
175
|
+
const out = part.output;
|
|
176
|
+
if (typeof out === "string")
|
|
177
|
+
return out;
|
|
178
|
+
if (out && typeof out === "object" && "value" in out) {
|
|
179
|
+
const v = out.value;
|
|
180
|
+
if (typeof v === "string")
|
|
181
|
+
return v;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
function isProtectedToolMessage(msg) {
|
|
186
|
+
if (typeof msg.content === "string") {
|
|
187
|
+
return msg.content.includes("<skill_content");
|
|
188
|
+
}
|
|
189
|
+
if (Array.isArray(msg.content)) {
|
|
190
|
+
return msg.content.some((p) => p.type === "tool-result" &&
|
|
191
|
+
PRUNE_PROTECTED_TOOLS.has(p.toolName));
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
145
195
|
/**
|
|
146
|
-
*
|
|
147
|
-
* Keeps recent tool
|
|
148
|
-
* Protects skill tool
|
|
149
|
-
*
|
|
196
|
+
* Return a copy of messages with old tool outputs truncated to free context space.
|
|
197
|
+
* Keeps recent tool results intact, trims older ones.
|
|
198
|
+
* Protects skill tool results from pruning.
|
|
199
|
+
* Does not mutate the input.
|
|
150
200
|
*/
|
|
151
201
|
export function pruneToolOutputs(messages) {
|
|
152
202
|
let totalTokens = 0;
|
|
153
|
-
let saved = 0;
|
|
154
203
|
let turns = 0;
|
|
204
|
+
const replaced = new Map();
|
|
155
205
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
156
206
|
const msg = messages[i];
|
|
157
207
|
if (msg.role === "user")
|
|
158
208
|
turns++;
|
|
159
209
|
if (turns < 2)
|
|
160
210
|
continue;
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
211
|
+
if (msg.role !== "tool")
|
|
212
|
+
continue;
|
|
213
|
+
if (isProtectedToolMessage(msg))
|
|
214
|
+
continue;
|
|
215
|
+
const parts = [...msg.content];
|
|
216
|
+
let changed = false;
|
|
217
|
+
for (let j = 0; j < parts.length; j++) {
|
|
218
|
+
const p = parts[j];
|
|
219
|
+
if (p.type !== "tool-result")
|
|
165
220
|
continue;
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
221
|
+
const text = toolResultText(p);
|
|
222
|
+
if (!text)
|
|
223
|
+
continue;
|
|
224
|
+
totalTokens += estimateTextTokens(text);
|
|
225
|
+
if (totalTokens > PRUNE_PROTECT_TOKENS && text.length > TOOL_OUTPUT_MAX_CHARS) {
|
|
226
|
+
const truncated = text.slice(0, TOOL_OUTPUT_MAX_CHARS) + "\n\n[... output truncated during compaction ...]";
|
|
227
|
+
parts[j] = { ...p, output: { type: "text", value: truncated } };
|
|
228
|
+
changed = true;
|
|
173
229
|
}
|
|
174
230
|
}
|
|
231
|
+
if (changed)
|
|
232
|
+
replaced.set(i, { ...msg, content: parts });
|
|
175
233
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
function isProtectedToolMessage(msg) {
|
|
180
|
-
if (typeof msg.content === "string") {
|
|
181
|
-
// Skill tool outputs are wrapped in <skill_content> tags
|
|
182
|
-
return msg.content.includes("<skill_content");
|
|
183
|
-
}
|
|
184
|
-
if (Array.isArray(msg.content)) {
|
|
185
|
-
return msg.content.some((p) => p.type === "tool-result" && PRUNE_PROTECTED_TOOLS.has(p.toolName ?? ""));
|
|
186
|
-
}
|
|
187
|
-
return false;
|
|
234
|
+
if (replaced.size === 0)
|
|
235
|
+
return messages;
|
|
236
|
+
return messages.map((msg, i) => replaced.get(i) ?? msg);
|
|
188
237
|
}
|
|
189
238
|
/**
|
|
190
239
|
* Select how many recent turns to keep verbatim based on token budget.
|
|
191
240
|
*/
|
|
192
|
-
function selectTail(messages, config) {
|
|
193
|
-
const maxTokens = config?.maxTokens ??
|
|
241
|
+
async function selectTail(messages, config) {
|
|
242
|
+
const maxTokens = config?.maxTokens ?? await resolveMaxTokens();
|
|
194
243
|
const tailTurns = config?.keepRecentTurns ?? DEFAULT_TAIL_TURNS;
|
|
195
244
|
const budget = Math.min(MAX_TAIL_BUDGET, Math.max(MIN_TAIL_BUDGET, Math.floor(maxTokens * TAIL_TOKEN_BUDGET_RATIO)));
|
|
196
245
|
const turnStarts = [];
|
|
@@ -245,14 +294,29 @@ function resolveCompactionModel(mainModel) {
|
|
|
245
294
|
}
|
|
246
295
|
return mainModel;
|
|
247
296
|
}
|
|
297
|
+
const SKILL_NOTE_HEADER = "## Skills Previously Loaded";
|
|
248
298
|
function extractPreviousSummary(messages) {
|
|
249
299
|
const first = messages[0];
|
|
250
300
|
if (first?.role === "system" && typeof first.content === "string" && first.content.includes("[Context Summary")) {
|
|
251
301
|
const match = first.content.match(/\[Context Summary[^\]]*\]\n\n([\s\S]*)/);
|
|
252
|
-
return match?.[1];
|
|
302
|
+
return match?.[1]?.split(SKILL_NOTE_HEADER)[0]?.trimEnd();
|
|
253
303
|
}
|
|
254
304
|
return undefined;
|
|
255
305
|
}
|
|
306
|
+
/** Skills listed in a previous compaction note, so repeated compactions don't forget them. */
|
|
307
|
+
function extractNotedSkills(messages) {
|
|
308
|
+
const first = messages[0];
|
|
309
|
+
if (first?.role !== "system" || typeof first.content !== "string")
|
|
310
|
+
return [];
|
|
311
|
+
const section = first.content.split(SKILL_NOTE_HEADER)[1];
|
|
312
|
+
if (!section)
|
|
313
|
+
return [];
|
|
314
|
+
return section
|
|
315
|
+
.split("\n")
|
|
316
|
+
.filter((line) => line.startsWith("- "))
|
|
317
|
+
.map((line) => line.slice(2).replace(/\s*\(base dir:.*$/, "").trim())
|
|
318
|
+
.filter(Boolean);
|
|
319
|
+
}
|
|
256
320
|
function buildCompactionPrompt(previousSummary) {
|
|
257
321
|
const anchor = previousSummary
|
|
258
322
|
? [
|
|
@@ -266,14 +330,32 @@ function buildCompactionPrompt(previousSummary) {
|
|
|
266
330
|
: "Create a new anchored summary from the conversation history above.";
|
|
267
331
|
return [anchor, "", SUMMARY_TEMPLATE].join("\n");
|
|
268
332
|
}
|
|
333
|
+
function isToolResultPart(p) {
|
|
334
|
+
return typeof p === "object" && p !== null && "type" in p && p.type === "tool-result";
|
|
335
|
+
}
|
|
269
336
|
function messageToText(msg) {
|
|
270
337
|
if (typeof msg.content === "string")
|
|
271
338
|
return msg.content;
|
|
272
339
|
if (Array.isArray(msg.content)) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
340
|
+
const parts = [];
|
|
341
|
+
for (const p of msg.content) {
|
|
342
|
+
if ("text" in p && typeof p.text === "string") {
|
|
343
|
+
parts.push(p.text);
|
|
344
|
+
}
|
|
345
|
+
else if (isToolResultPart(p)) {
|
|
346
|
+
const out = p.output;
|
|
347
|
+
if (typeof out === "string") {
|
|
348
|
+
parts.push(out);
|
|
349
|
+
}
|
|
350
|
+
else if ("value" in out && out.value != null) {
|
|
351
|
+
parts.push(String(out.value));
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
parts.push(JSON.stringify(out));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return parts.join("\n");
|
|
277
359
|
}
|
|
278
360
|
return "";
|
|
279
361
|
}
|
|
@@ -303,15 +385,15 @@ function extractTextOnly(msg) {
|
|
|
303
385
|
export async function compactMessages(messages, model, config) {
|
|
304
386
|
const cfg = loadConfig();
|
|
305
387
|
const autoContinue = config?.autoContinue ?? cfg.compaction?.autoContinue ?? true;
|
|
306
|
-
// Step 1: Prune old tool
|
|
307
|
-
pruneToolOutputs(messages);
|
|
388
|
+
// Step 1: Prune old tool results (skip skill results)
|
|
389
|
+
const pruned = pruneToolOutputs(messages);
|
|
308
390
|
// Step 2: Select tail (recent turns to keep verbatim)
|
|
309
|
-
const { headEnd, tailStart } = selectTail(
|
|
391
|
+
const { headEnd, tailStart } = await selectTail(pruned, config);
|
|
310
392
|
if (headEnd <= 1) {
|
|
311
393
|
return { messages, compacted: false, shouldContinue: false };
|
|
312
394
|
}
|
|
313
|
-
const toSummarize =
|
|
314
|
-
const toKeep =
|
|
395
|
+
const toSummarize = pruned.slice(0, headEnd);
|
|
396
|
+
const toKeep = pruned.slice(tailStart);
|
|
315
397
|
// Step 3: Check for previous summary (incremental)
|
|
316
398
|
const previousSummary = extractPreviousSummary(toSummarize);
|
|
317
399
|
// Step 4: Build conversation text for summarization
|
|
@@ -333,24 +415,43 @@ export async function compactMessages(messages, model, config) {
|
|
|
333
415
|
messages: [
|
|
334
416
|
{ role: "user", content: conversationText + "\n\n" + prompt },
|
|
335
417
|
],
|
|
418
|
+
abortSignal: config?.abortSignal,
|
|
336
419
|
});
|
|
337
420
|
const summary = result.text;
|
|
421
|
+
const stillPresent = collectLoadedSkillNames(toKeep);
|
|
422
|
+
const droppedSkills = [
|
|
423
|
+
...new Set([...collectLoadedSkillNames(toSummarize), ...extractNotedSkills(toSummarize)]),
|
|
424
|
+
].filter((name) => !stillPresent.has(name));
|
|
425
|
+
const reloadNote = buildSkillReloadNote(droppedSkills);
|
|
338
426
|
const compactedMessages = [
|
|
339
427
|
{
|
|
340
428
|
role: "system",
|
|
341
|
-
content: `[Context Summary - Previous conversation was compacted]\n\n${summary}`,
|
|
429
|
+
content: `[Context Summary - Previous conversation was compacted]\n\n${summary}${reloadNote ? `\n\n${reloadNote}` : ""}`,
|
|
342
430
|
},
|
|
343
431
|
...toKeep,
|
|
344
432
|
];
|
|
345
|
-
// Step 7:
|
|
433
|
+
// Step 7: If the last user message (in the kept tail) had media, provide replay text
|
|
346
434
|
let replayText;
|
|
347
|
-
|
|
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
|
+
}
|
|
348
442
|
if (lastUserMsg && hasMedia(lastUserMsg)) {
|
|
349
443
|
replayText = extractTextOnly(lastUserMsg);
|
|
350
444
|
}
|
|
351
|
-
return {
|
|
445
|
+
return {
|
|
446
|
+
messages: compactedMessages,
|
|
447
|
+
compacted: true,
|
|
448
|
+
shouldContinue: autoContinue,
|
|
449
|
+
replayText,
|
|
450
|
+
usage: result.usage,
|
|
451
|
+
};
|
|
352
452
|
}
|
|
353
|
-
catch {
|
|
354
|
-
|
|
453
|
+
catch (error) {
|
|
454
|
+
console.error("[compaction] summary generation failed, keeping original messages:", error);
|
|
455
|
+
return { messages, compacted: false, shouldContinue: false };
|
|
355
456
|
}
|
|
356
457
|
}
|