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.
Files changed (81) hide show
  1. package/README.md +146 -18
  2. package/dist/agent.js +293 -408
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli.js +403 -140
  5. package/dist/clipboard.js +59 -23
  6. package/dist/code-mode.js +3 -3
  7. package/dist/compaction.js +182 -81
  8. package/dist/config.js +186 -35
  9. package/dist/confirm.js +55 -6
  10. package/dist/context-window.js +67 -54
  11. package/dist/doom-loop.js +19 -12
  12. package/dist/http.js +119 -0
  13. package/dist/instructions.js +51 -33
  14. package/dist/logger.js +66 -0
  15. package/dist/markdown.js +3 -44
  16. package/dist/mcp.js +547 -100
  17. package/dist/memory.js +48 -6
  18. package/dist/output.js +36 -27
  19. package/dist/paste-handler.js +3 -3
  20. package/dist/plugins.js +33 -6
  21. package/dist/pricing.js +119 -0
  22. package/dist/provider.js +17 -15
  23. package/dist/serve.js +658 -369
  24. package/dist/sessions.js +151 -13
  25. package/dist/skills.js +466 -76
  26. package/dist/synthetic.js +7 -0
  27. package/dist/title-gen.js +2 -1
  28. package/dist/tool-display.js +173 -0
  29. package/dist/tool-output.js +54 -45
  30. package/dist/tools/apply_patch.js +191 -0
  31. package/dist/tools/backend.js +61 -0
  32. package/dist/tools/bash.js +147 -70
  33. package/dist/tools/code_search.js +6 -5
  34. package/dist/tools/edit.js +23 -7
  35. package/dist/tools/explore.js +80 -12
  36. package/dist/tools/glob.js +3 -3
  37. package/dist/tools/grep.js +146 -14
  38. package/dist/tools/index.js +7 -7
  39. package/dist/tools/question.js +4 -22
  40. package/dist/tools/read.js +71 -11
  41. package/dist/tools/task.js +33 -20
  42. package/dist/tools/todo.js +83 -73
  43. package/dist/tools/web_fetch.js +150 -46
  44. package/dist/tools/web_search.js +706 -28
  45. package/dist/tools/write.js +13 -7
  46. package/dist/tui/App.js +40 -6
  47. package/dist/tui/ConfirmBar.js +24 -3
  48. package/dist/tui/InputBar.js +390 -45
  49. package/dist/tui/MessageList.js +533 -20
  50. package/dist/tui/ModelPicker.js +108 -0
  51. package/dist/tui/QuestionBar.js +104 -0
  52. package/dist/tui/StatusBar.js +19 -11
  53. package/dist/tui/agent-runner.js +103 -0
  54. package/dist/tui/caret-pos.js +134 -0
  55. package/dist/tui/caret.js +69 -0
  56. package/dist/tui/diff-view.js +61 -0
  57. package/dist/tui/drag-state.js +44 -0
  58. package/dist/tui/index.js +153 -24
  59. package/dist/tui/input-history.js +44 -0
  60. package/dist/tui/layout.js +17 -0
  61. package/dist/tui/mouse.js +46 -0
  62. package/dist/tui/selection.js +134 -0
  63. package/dist/tui/slash-commands.js +90 -0
  64. package/dist/tui/slash-handler.js +370 -0
  65. package/dist/tui/text-width.js +91 -0
  66. package/dist/tui/theme.js +12 -0
  67. package/dist/tui/undo-stack.js +14 -0
  68. package/dist/tui/use-sgr-mouse.js +27 -0
  69. package/dist/tui-chat.js +111 -331
  70. package/dist/updater.js +57 -0
  71. package/docs/API.md +160 -14
  72. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  73. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  74. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  75. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  76. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  77. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  78. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  79. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  80. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  81. 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 = path.join(os.tmpdir(), `min-agent-paste-${Date.now()}.png`);
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 { readFileSync } = require("fs");
22
- const data = readFileSync(tmpFile);
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 { readFileSync, unlinkSync } = require("fs");
48
- const data = readFileSync(tmpFile);
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 = path.join(os.tmpdir(), `min-agent-paste-${Date.now()}.png`);
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 { readFileSync, unlinkSync } = require("fs");
95
- const data = readFileSync(tmpFile);
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" },
@@ -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 = 40000;
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
- /** Token estimation: ~4 chars per token (aligned with opencode) */
97
- export function estimateTokens(messages) {
98
- let chars = 0;
99
- for (const msg of messages) {
100
- if (typeof msg.content === "string") {
101
- chars += msg.content.length;
102
- }
103
- else if (Array.isArray(msg.content)) {
104
- for (const part of msg.content) {
105
- if ("text" in part && typeof part.text === "string") {
106
- chars += part.text.length;
107
- }
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 Math.ceil(chars / 4);
121
+ return ascii / 4 + other * 0.7;
112
122
  }
113
- function estimateMessageTokens(msg) {
114
- if (typeof msg.content === "string")
115
- return Math.ceil(msg.content.length / 4);
116
- if (Array.isArray(msg.content)) {
117
- let chars = 0;
118
- for (const part of msg.content) {
119
- if ("text" in part && typeof part.text === "string")
120
- chars += part.text.length;
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 Math.ceil(chars / 4);
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 from config or getContextWindow cache.
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 ?? getMaxTokensFromConfig();
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
- * Prune old tool outputs in-place to free context space.
147
- * Keeps recent tool outputs intact, trims older ones.
148
- * Protects skill tool outputs from pruning.
149
- * Returns the estimated tokens saved.
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
- // Check if this is a tool result
162
- if (msg.role === "tool" || (Array.isArray(msg.content) && msg.content.some((p) => p.type === "tool-result"))) {
163
- // Skip protected tools (skill results are never pruned)
164
- if (isProtectedToolMessage(msg))
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 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);
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
- return saved;
177
- }
178
- /** Check if a message is from a protected tool (e.g. skill) */
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 ?? getMaxTokensFromConfig();
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
- return msg.content
274
- .filter((p) => "text" in p && typeof p.text === "string")
275
- .map((p) => p.text)
276
- .join("\n");
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 outputs (skip skill results)
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(messages, config);
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 = messages.slice(0, headEnd);
314
- const toKeep = messages.slice(tailStart);
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: Check if the last user message had media if so, provide replay text
433
+ // Step 7: If the last user message (in the kept tail) had media, provide replay text
346
434
  let replayText;
347
- const lastUserMsg = toKeep.find((m) => m.role === "user");
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 { messages: compactedMessages, compacted: true, shouldContinue: autoContinue, replayText };
445
+ return {
446
+ messages: compactedMessages,
447
+ compacted: true,
448
+ shouldContinue: autoContinue,
449
+ replayText,
450
+ usage: result.usage,
451
+ };
352
452
  }
353
- catch {
354
- return { messages: toKeep, compacted: true, shouldContinue: false };
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
  }