codemini-cli 0.5.10 → 0.5.12
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/OPERATIONS.md +242 -242
- package/README.md +588 -588
- package/codemini-web/dist/assets/{highlighted-body-OFNGDK62-7HL7yft8.js → highlighted-body-OFNGDK62-B-G99D0A.js} +1 -1
- package/codemini-web/dist/assets/{index-BK75hMb2.js → index-DIGUEzan.js} +108 -108
- package/codemini-web/dist/assets/index-Dkq1DdDX.css +2 -0
- package/codemini-web/dist/assets/mermaid-GHXKKRXX-va2Kl89u.js +1 -0
- package/codemini-web/dist/index.html +35 -23
- package/codemini-web/lib/approval-manager.js +32 -32
- package/codemini-web/lib/runtime-bridge.js +17 -11
- package/codemini-web/server.js +534 -205
- package/deployment.md +212 -212
- package/package.json +2 -2
- package/skills/brainstorm/SKILL.md +77 -77
- package/skills/codemini.skills.json +40 -40
- package/skills/grill-me/SKILL.md +30 -30
- package/skills/superpowers-lite/SKILL.md +82 -82
- package/src/cli.js +74 -74
- package/src/commands/chat.js +210 -210
- package/src/commands/run.js +313 -313
- package/src/commands/skill.js +438 -304
- package/src/commands/web.js +57 -57
- package/src/core/agent-loop.js +980 -980
- package/src/core/ast.js +309 -307
- package/src/core/chat-runtime.js +6261 -6253
- package/src/core/command-evaluator.js +72 -72
- package/src/core/command-loader.js +311 -311
- package/src/core/command-policy.js +301 -301
- package/src/core/command-risk.js +156 -156
- package/src/core/config-store.js +286 -285
- package/src/core/constants.js +18 -1
- package/src/core/context-compact.js +365 -365
- package/src/core/default-system-prompt.js +114 -107
- package/src/core/dream-audit.js +105 -105
- package/src/core/dream-consolidate.js +229 -229
- package/src/core/dream-evaluator.js +185 -185
- package/src/core/fff-adapter.js +383 -383
- package/src/core/memory-store.js +543 -543
- package/src/core/project-index.js +737 -548
- package/src/core/project-instructions.js +98 -98
- package/src/core/provider/anthropic.js +514 -514
- package/src/core/provider/openai-compatible.js +501 -501
- package/src/core/reflect-skill.js +178 -178
- package/src/core/reply-language.js +40 -40
- package/src/core/session-store.js +474 -474
- package/src/core/shell-profile.js +237 -237
- package/src/core/shell.js +323 -323
- package/src/core/soul.js +69 -69
- package/src/core/system-prompt-composer.js +52 -52
- package/src/core/tool-args.js +199 -154
- package/src/core/tool-output.js +184 -184
- package/src/core/tool-result-store.js +206 -206
- package/src/core/tools.js +3024 -2893
- package/src/core/version.js +11 -11
- package/src/tui/chat-app.js +5173 -5171
- package/src/tui/tool-activity/presenters/misc.js +30 -30
- package/src/tui/tool-activity/presenters/system.js +20 -20
- package/templates/project-requirements/report-shell.html +582 -582
- package/codemini-web/dist/assets/index-BSdIdn3L.css +0 -2
- package/codemini-web/dist/assets/mermaid-GHXKKRXX-Dg9qh8mg.js +0 -1
|
@@ -1,365 +1,365 @@
|
|
|
1
|
-
import { trimInline } from './string-utils.js';
|
|
2
|
-
import { summarizeToolResult } from './tool-result-store.js';
|
|
3
|
-
|
|
4
|
-
const MICRO_CLEAR_MARKER = '[Old tool result cleared by micro-compact]';
|
|
5
|
-
|
|
6
|
-
function textFromContent(content) {
|
|
7
|
-
if (typeof content === 'string') return content;
|
|
8
|
-
if (Array.isArray(content)) {
|
|
9
|
-
return content
|
|
10
|
-
.map((part) => {
|
|
11
|
-
if (typeof part === 'string') return part;
|
|
12
|
-
if (part?.type === 'text') return part.text || '';
|
|
13
|
-
return '';
|
|
14
|
-
})
|
|
15
|
-
.join('');
|
|
16
|
-
}
|
|
17
|
-
return '';
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function estimateMessagesTokens(messages) {
|
|
21
|
-
let total = 0;
|
|
22
|
-
for (const message of messages || []) {
|
|
23
|
-
const roleOverhead = 6;
|
|
24
|
-
const text = textFromContent(message.content);
|
|
25
|
-
let asciiChars = 0;
|
|
26
|
-
let nonAsciiChars = 0;
|
|
27
|
-
for (const char of text) {
|
|
28
|
-
if (char.charCodeAt(0) <= 0x7f) asciiChars += 1;
|
|
29
|
-
else nonAsciiChars += 1;
|
|
30
|
-
}
|
|
31
|
-
total += roleOverhead + Math.ceil(asciiChars / 4) + Math.ceil(nonAsciiChars / 2);
|
|
32
|
-
}
|
|
33
|
-
return total;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function modeToKeepRecent(mode) {
|
|
37
|
-
if (mode === 'aggressive') return 4;
|
|
38
|
-
if (mode === 'conservative') return 10;
|
|
39
|
-
return 6;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function getToolCallId(call) {
|
|
43
|
-
return String(call?.id || '').trim();
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function getMessageToolCallIds(message) {
|
|
47
|
-
if (!Array.isArray(message?.tool_calls)) return [];
|
|
48
|
-
return message.tool_calls.map(getToolCallId).filter(Boolean);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function toolResultNote(message) {
|
|
52
|
-
const text = textFromContent(message?.content);
|
|
53
|
-
let parsed;
|
|
54
|
-
try { parsed = JSON.parse(text); } catch { parsed = null; }
|
|
55
|
-
const summary = parsed && typeof parsed === 'object'
|
|
56
|
-
? summarizeToolResult(parsed)
|
|
57
|
-
: text.replace(/\s+/g, ' ').trim();
|
|
58
|
-
const clipped = summary.length > 600 ? `${summary.slice(0, 597)}...` : summary;
|
|
59
|
-
return `[Compacted orphan tool result]\n${clipped || 'No content'}`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function expandRecentStartToToolBoundary(messages, start) {
|
|
63
|
-
let adjusted = Math.max(0, Math.min(start, messages.length));
|
|
64
|
-
while (adjusted > 0 && messages[adjusted]?.role === 'tool') {
|
|
65
|
-
adjusted -= 1;
|
|
66
|
-
}
|
|
67
|
-
if (
|
|
68
|
-
adjusted > 0 &&
|
|
69
|
-
messages[adjusted]?.role !== 'assistant' &&
|
|
70
|
-
messages[adjusted + 1]?.role === 'tool'
|
|
71
|
-
) {
|
|
72
|
-
adjusted += 1;
|
|
73
|
-
}
|
|
74
|
-
return adjusted;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function sanitizeRecentMessagesForModel(messages) {
|
|
78
|
-
const out = [];
|
|
79
|
-
let activeAssistantIndex = -1;
|
|
80
|
-
let expectedToolIds = new Set();
|
|
81
|
-
let matchedToolIds = new Set();
|
|
82
|
-
|
|
83
|
-
const finalizeActiveAssistant = () => {
|
|
84
|
-
if (activeAssistantIndex < 0) return;
|
|
85
|
-
const assistant = out[activeAssistantIndex];
|
|
86
|
-
if (!Array.isArray(assistant?.tool_calls)) {
|
|
87
|
-
activeAssistantIndex = -1;
|
|
88
|
-
expectedToolIds = new Set();
|
|
89
|
-
matchedToolIds = new Set();
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
const toolCalls = assistant.tool_calls.filter((call) => matchedToolIds.has(getToolCallId(call)));
|
|
93
|
-
if (toolCalls.length > 0) {
|
|
94
|
-
out[activeAssistantIndex] = { ...assistant, tool_calls: toolCalls };
|
|
95
|
-
} else {
|
|
96
|
-
const { tool_calls, ...rest } = assistant;
|
|
97
|
-
out[activeAssistantIndex] = rest;
|
|
98
|
-
}
|
|
99
|
-
activeAssistantIndex = -1;
|
|
100
|
-
expectedToolIds = new Set();
|
|
101
|
-
matchedToolIds = new Set();
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
for (const message of messages) {
|
|
105
|
-
if (!message || typeof message !== 'object') continue;
|
|
106
|
-
if (message.role === 'assistant') {
|
|
107
|
-
finalizeActiveAssistant();
|
|
108
|
-
const clone = { ...message };
|
|
109
|
-
out.push(clone);
|
|
110
|
-
const ids = getMessageToolCallIds(clone);
|
|
111
|
-
if (ids.length > 0) {
|
|
112
|
-
activeAssistantIndex = out.length - 1;
|
|
113
|
-
expectedToolIds = new Set(ids);
|
|
114
|
-
matchedToolIds = new Set();
|
|
115
|
-
}
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (message.role === 'tool') {
|
|
120
|
-
const id = String(message.tool_call_id || '').trim();
|
|
121
|
-
if (id && expectedToolIds.has(id)) {
|
|
122
|
-
out.push({ ...message });
|
|
123
|
-
matchedToolIds.add(id);
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
finalizeActiveAssistant();
|
|
127
|
-
out.push({ role: 'assistant', content: toolResultNote(message), at: message.at });
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
finalizeActiveAssistant();
|
|
132
|
-
out.push({ ...message });
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
finalizeActiveAssistant();
|
|
136
|
-
return out;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function buildLocalSummary(messages) {
|
|
140
|
-
const goal = [];
|
|
141
|
-
const constraints = [];
|
|
142
|
-
const changedFiles = new Set();
|
|
143
|
-
const verification = [];
|
|
144
|
-
const openThreads = [];
|
|
145
|
-
const limit = 16;
|
|
146
|
-
for (const msg of messages.slice(-limit)) {
|
|
147
|
-
if (msg.role === 'tool') {
|
|
148
|
-
const text = textFromContent(msg.content);
|
|
149
|
-
let parsed;
|
|
150
|
-
try { parsed = JSON.parse(text); } catch { parsed = null; }
|
|
151
|
-
if (parsed && typeof parsed === 'object') {
|
|
152
|
-
const summary = summarizeToolResult(parsed);
|
|
153
|
-
if (parsed.path) changedFiles.add(String(parsed.path));
|
|
154
|
-
if (parsed.command || parsed.code != null || parsed.stderr || parsed.stdout) {
|
|
155
|
-
verification.push(summary);
|
|
156
|
-
} else {
|
|
157
|
-
openThreads.push(`tool_result: ${summary}`);
|
|
158
|
-
}
|
|
159
|
-
} else {
|
|
160
|
-
const clipped = text.length > 120 ? `${text.slice(0, 117)}...` : text;
|
|
161
|
-
const match = clipped.match(/([A-Za-z0-9_./-]+\.[A-Za-z0-9]+):\d+/);
|
|
162
|
-
if (match) changedFiles.add(match[1]);
|
|
163
|
-
openThreads.push(`tool_result: ${clipped}`);
|
|
164
|
-
}
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
if (msg.role === 'assistant') {
|
|
168
|
-
const text = textFromContent(msg.content).replace(/\s+/g, ' ').trim();
|
|
169
|
-
const toolCallCount = Array.isArray(msg.tool_calls) ? msg.tool_calls.length : 0;
|
|
170
|
-
const toolInfo = toolCallCount > 0 ? ` [called ${toolCallCount} tool(s)]` : '';
|
|
171
|
-
const clipped = text.length > 300 ? `${text.slice(0, 297)}...` : text;
|
|
172
|
-
if (clipped) openThreads.push(`assistant: ${clipped}${toolInfo}`);
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
if (msg.role === 'user') {
|
|
176
|
-
const text = textFromContent(msg.content).replace(/\s+/g, ' ').trim();
|
|
177
|
-
const clipped = text.length > 200 ? `${text.slice(0, 197)}...` : text;
|
|
178
|
-
if (goal.length === 0) goal.push(clipped);
|
|
179
|
-
else constraints.push(clipped);
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
const text = textFromContent(msg.content).replace(/\s+/g, ' ').trim();
|
|
183
|
-
if (!text) continue;
|
|
184
|
-
const clipped = text.length > 160 ? `${text.slice(0, 157)}...` : text;
|
|
185
|
-
openThreads.push(`${msg.role}: ${clipped}`);
|
|
186
|
-
}
|
|
187
|
-
const lines = [
|
|
188
|
-
'Context Summary',
|
|
189
|
-
'Goal:',
|
|
190
|
-
goal.length > 0 ? `- ${goal[0]}` : '- Unknown from compacted context',
|
|
191
|
-
'Key Constraints:',
|
|
192
|
-
...(constraints.length > 0 ? constraints.slice(-4).map((item) => `- ${item}`) : ['- None recorded']),
|
|
193
|
-
'Changed Files:',
|
|
194
|
-
...(changedFiles.size > 0 ? [...changedFiles].slice(0, 8).map((item) => `- ${item}`) : ['- None recorded']),
|
|
195
|
-
'Verification:',
|
|
196
|
-
...(verification.length > 0 ? verification.slice(-4).map((item) => `- ${item}`) : ['- None recorded']),
|
|
197
|
-
'Open Threads:',
|
|
198
|
-
...(openThreads.length > 0 ? openThreads.slice(-8).map((item) => `- ${item}`) : ['- None recorded'])
|
|
199
|
-
];
|
|
200
|
-
return lines.join('\n').trim();
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Build a conversation transcript from messages for LLM summarization input.
|
|
205
|
-
* Includes structured metadata (tool calls, file changes) alongside the text.
|
|
206
|
-
*/
|
|
207
|
-
export function buildTranscriptForLLM(messages) {
|
|
208
|
-
const parts = [];
|
|
209
|
-
for (const msg of messages) {
|
|
210
|
-
const text = textFromContent(msg.content).replace(/\s+/g, ' ').trim();
|
|
211
|
-
if (!text && !Array.isArray(msg.tool_calls) && msg.role !== 'user') continue;
|
|
212
|
-
if (msg.role === 'user') {
|
|
213
|
-
parts.push(`[User]\n${text.slice(0, 600)}`);
|
|
214
|
-
} else if (msg.role === 'assistant') {
|
|
215
|
-
let block = `[Assistant]\n${text.slice(0, 600)}`;
|
|
216
|
-
if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
|
|
217
|
-
const toolNames = msg.tool_calls.map(tc => tc.function?.name || tc.name || 'tool').join(', ');
|
|
218
|
-
block += `\n[Called tools: ${toolNames}]`;
|
|
219
|
-
}
|
|
220
|
-
parts.push(block);
|
|
221
|
-
} else if (msg.role === 'tool') {
|
|
222
|
-
let parsed;
|
|
223
|
-
try { parsed = JSON.parse(text); } catch { parsed = null; }
|
|
224
|
-
if (parsed && typeof parsed === 'object') {
|
|
225
|
-
const summary = summarizeToolResult(parsed);
|
|
226
|
-
parts.push(`[Tool Result]\n${summary.slice(0, 400)}`);
|
|
227
|
-
} else {
|
|
228
|
-
parts.push(`[Tool Result]\n${text.slice(0, 300)}`);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
return parts.join('\n\n');
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export const COMPACT_SUMMARY_PROMPT = `Summarize the following conversation into a structured context summary that preserves all critical information for continuing the task. Be thorough and specific.
|
|
236
|
-
|
|
237
|
-
Include:
|
|
238
|
-
- The user's goal and requirements
|
|
239
|
-
- Key decisions made and reasoning
|
|
240
|
-
- Files that were read, modified, or created (with paths)
|
|
241
|
-
- Current progress and what remains
|
|
242
|
-
- Any errors encountered and how they were resolved
|
|
243
|
-
- Important constraints or conventions discovered
|
|
244
|
-
|
|
245
|
-
Write in the same language as the conversation. Be concise but do not omit important details.`;
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Micro-compact: in-place clearing of old tool result content.
|
|
249
|
-
* Does NOT change message count or order — only replaces tool result text
|
|
250
|
-
* with a lightweight marker, preserving conversation structure.
|
|
251
|
-
*
|
|
252
|
-
* Strategy inspired by Claude Code's Phase 0 micro-compact:
|
|
253
|
-
* keep recent N tool results intact, clear the rest.
|
|
254
|
-
*/
|
|
255
|
-
export function microCompactMessages(messages, { keepRecent = 5, enabled = true } = {}) {
|
|
256
|
-
if (!enabled || !Array.isArray(messages)) {
|
|
257
|
-
return { messages: [...messages], changed: false, tokensSaved: 0 };
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Collect indices of all tool-role messages
|
|
261
|
-
const toolIndices = [];
|
|
262
|
-
for (let i = 0; i < messages.length; i++) {
|
|
263
|
-
if (messages[i].role === 'tool') toolIndices.push(i);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
if (toolIndices.length <= keepRecent) {
|
|
267
|
-
return { messages: [...messages], changed: false, tokensSaved: 0 };
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Indices to clear = all except the last keepRecent
|
|
271
|
-
const keepSet = new Set(toolIndices.slice(-keepRecent));
|
|
272
|
-
const clearSet = new Set(toolIndices.filter((idx) => !keepSet.has(idx)));
|
|
273
|
-
|
|
274
|
-
if (clearSet.size === 0) {
|
|
275
|
-
return { messages: [...messages], changed: false, tokensSaved: 0 };
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const beforeTokens = estimateMessagesTokens(messages);
|
|
279
|
-
const result = messages.map((msg, i) => {
|
|
280
|
-
if (!clearSet.has(i)) return msg;
|
|
281
|
-
const text = textFromContent(msg.content);
|
|
282
|
-
if (!text || text === MICRO_CLEAR_MARKER) return msg;
|
|
283
|
-
return { ...msg, content: MICRO_CLEAR_MARKER };
|
|
284
|
-
});
|
|
285
|
-
const afterTokens = estimateMessagesTokens(result);
|
|
286
|
-
const tokensSaved = beforeTokens - afterTokens;
|
|
287
|
-
|
|
288
|
-
if (tokensSaved <= 0) {
|
|
289
|
-
return { messages: [...messages], changed: false, tokensSaved: 0 };
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
return { messages: result, changed: true, tokensSaved };
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
export async function compactMessagesLocally(messages, { mode = 'default', force = false, generateSummary = null } = {}) {
|
|
296
|
-
const keepRecent = modeToKeepRecent(mode);
|
|
297
|
-
if (!Array.isArray(messages) || messages.length <= 1) {
|
|
298
|
-
return {
|
|
299
|
-
compacted: [...(messages || [])],
|
|
300
|
-
changed: false
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
// Skip compact when message count is low enough to keep all, unless forced
|
|
304
|
-
if (!force && messages.length <= keepRecent + 1) {
|
|
305
|
-
return {
|
|
306
|
-
compacted: [...(messages || [])],
|
|
307
|
-
changed: false
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const recentStart = expandRecentStartToToolBoundary(messages, Math.max(0, messages.length - keepRecent));
|
|
312
|
-
const older = messages.slice(0, recentStart);
|
|
313
|
-
const recent = sanitizeRecentMessagesForModel(messages.slice(recentStart));
|
|
314
|
-
|
|
315
|
-
let summary;
|
|
316
|
-
if (typeof generateSummary === 'function') {
|
|
317
|
-
try {
|
|
318
|
-
summary = await generateSummary(older);
|
|
319
|
-
} catch {
|
|
320
|
-
summary = buildLocalSummary(older);
|
|
321
|
-
}
|
|
322
|
-
} else {
|
|
323
|
-
summary = buildLocalSummary(older);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const compacted = [{ role: 'assistant', content: summary }, ...recent];
|
|
327
|
-
const boundaryIndex = recentStart;
|
|
328
|
-
|
|
329
|
-
return {
|
|
330
|
-
compacted,
|
|
331
|
-
changed: true,
|
|
332
|
-
summary,
|
|
333
|
-
boundaryIndex
|
|
334
|
-
};
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
export function parseCompactArgs(args = []) {
|
|
338
|
-
const parsed = {
|
|
339
|
-
mode: 'default',
|
|
340
|
-
preview: false,
|
|
341
|
-
restore: false,
|
|
342
|
-
micro: false,
|
|
343
|
-
auto: undefined,
|
|
344
|
-
threshold: undefined
|
|
345
|
-
};
|
|
346
|
-
|
|
347
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
348
|
-
const arg = args[i];
|
|
349
|
-
if (arg === '--preview') parsed.preview = true;
|
|
350
|
-
if (arg === '--restore') parsed.restore = true;
|
|
351
|
-
if (arg === '--micro') parsed.micro = true;
|
|
352
|
-
if (arg === '--aggressive') parsed.mode = 'aggressive';
|
|
353
|
-
if (arg === '--conservative') parsed.mode = 'conservative';
|
|
354
|
-
if (arg === '--default') parsed.mode = 'default';
|
|
355
|
-
if (arg === '--auto-on') parsed.auto = 'on';
|
|
356
|
-
if (arg === '--auto-off') parsed.auto = 'off';
|
|
357
|
-
if (arg === '--threshold') {
|
|
358
|
-
const n = Number(args[i + 1]);
|
|
359
|
-
if (!Number.isNaN(n)) parsed.threshold = n;
|
|
360
|
-
i += 1;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
return parsed;
|
|
365
|
-
}
|
|
1
|
+
import { trimInline } from './string-utils.js';
|
|
2
|
+
import { summarizeToolResult } from './tool-result-store.js';
|
|
3
|
+
|
|
4
|
+
const MICRO_CLEAR_MARKER = '[Old tool result cleared by micro-compact]';
|
|
5
|
+
|
|
6
|
+
function textFromContent(content) {
|
|
7
|
+
if (typeof content === 'string') return content;
|
|
8
|
+
if (Array.isArray(content)) {
|
|
9
|
+
return content
|
|
10
|
+
.map((part) => {
|
|
11
|
+
if (typeof part === 'string') return part;
|
|
12
|
+
if (part?.type === 'text') return part.text || '';
|
|
13
|
+
return '';
|
|
14
|
+
})
|
|
15
|
+
.join('');
|
|
16
|
+
}
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function estimateMessagesTokens(messages) {
|
|
21
|
+
let total = 0;
|
|
22
|
+
for (const message of messages || []) {
|
|
23
|
+
const roleOverhead = 6;
|
|
24
|
+
const text = textFromContent(message.content);
|
|
25
|
+
let asciiChars = 0;
|
|
26
|
+
let nonAsciiChars = 0;
|
|
27
|
+
for (const char of text) {
|
|
28
|
+
if (char.charCodeAt(0) <= 0x7f) asciiChars += 1;
|
|
29
|
+
else nonAsciiChars += 1;
|
|
30
|
+
}
|
|
31
|
+
total += roleOverhead + Math.ceil(asciiChars / 4) + Math.ceil(nonAsciiChars / 2);
|
|
32
|
+
}
|
|
33
|
+
return total;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function modeToKeepRecent(mode) {
|
|
37
|
+
if (mode === 'aggressive') return 4;
|
|
38
|
+
if (mode === 'conservative') return 10;
|
|
39
|
+
return 6;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getToolCallId(call) {
|
|
43
|
+
return String(call?.id || '').trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getMessageToolCallIds(message) {
|
|
47
|
+
if (!Array.isArray(message?.tool_calls)) return [];
|
|
48
|
+
return message.tool_calls.map(getToolCallId).filter(Boolean);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function toolResultNote(message) {
|
|
52
|
+
const text = textFromContent(message?.content);
|
|
53
|
+
let parsed;
|
|
54
|
+
try { parsed = JSON.parse(text); } catch { parsed = null; }
|
|
55
|
+
const summary = parsed && typeof parsed === 'object'
|
|
56
|
+
? summarizeToolResult(parsed)
|
|
57
|
+
: text.replace(/\s+/g, ' ').trim();
|
|
58
|
+
const clipped = summary.length > 600 ? `${summary.slice(0, 597)}...` : summary;
|
|
59
|
+
return `[Compacted orphan tool result]\n${clipped || 'No content'}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function expandRecentStartToToolBoundary(messages, start) {
|
|
63
|
+
let adjusted = Math.max(0, Math.min(start, messages.length));
|
|
64
|
+
while (adjusted > 0 && messages[adjusted]?.role === 'tool') {
|
|
65
|
+
adjusted -= 1;
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
adjusted > 0 &&
|
|
69
|
+
messages[adjusted]?.role !== 'assistant' &&
|
|
70
|
+
messages[adjusted + 1]?.role === 'tool'
|
|
71
|
+
) {
|
|
72
|
+
adjusted += 1;
|
|
73
|
+
}
|
|
74
|
+
return adjusted;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sanitizeRecentMessagesForModel(messages) {
|
|
78
|
+
const out = [];
|
|
79
|
+
let activeAssistantIndex = -1;
|
|
80
|
+
let expectedToolIds = new Set();
|
|
81
|
+
let matchedToolIds = new Set();
|
|
82
|
+
|
|
83
|
+
const finalizeActiveAssistant = () => {
|
|
84
|
+
if (activeAssistantIndex < 0) return;
|
|
85
|
+
const assistant = out[activeAssistantIndex];
|
|
86
|
+
if (!Array.isArray(assistant?.tool_calls)) {
|
|
87
|
+
activeAssistantIndex = -1;
|
|
88
|
+
expectedToolIds = new Set();
|
|
89
|
+
matchedToolIds = new Set();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const toolCalls = assistant.tool_calls.filter((call) => matchedToolIds.has(getToolCallId(call)));
|
|
93
|
+
if (toolCalls.length > 0) {
|
|
94
|
+
out[activeAssistantIndex] = { ...assistant, tool_calls: toolCalls };
|
|
95
|
+
} else {
|
|
96
|
+
const { tool_calls, ...rest } = assistant;
|
|
97
|
+
out[activeAssistantIndex] = rest;
|
|
98
|
+
}
|
|
99
|
+
activeAssistantIndex = -1;
|
|
100
|
+
expectedToolIds = new Set();
|
|
101
|
+
matchedToolIds = new Set();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
for (const message of messages) {
|
|
105
|
+
if (!message || typeof message !== 'object') continue;
|
|
106
|
+
if (message.role === 'assistant') {
|
|
107
|
+
finalizeActiveAssistant();
|
|
108
|
+
const clone = { ...message };
|
|
109
|
+
out.push(clone);
|
|
110
|
+
const ids = getMessageToolCallIds(clone);
|
|
111
|
+
if (ids.length > 0) {
|
|
112
|
+
activeAssistantIndex = out.length - 1;
|
|
113
|
+
expectedToolIds = new Set(ids);
|
|
114
|
+
matchedToolIds = new Set();
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (message.role === 'tool') {
|
|
120
|
+
const id = String(message.tool_call_id || '').trim();
|
|
121
|
+
if (id && expectedToolIds.has(id)) {
|
|
122
|
+
out.push({ ...message });
|
|
123
|
+
matchedToolIds.add(id);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
finalizeActiveAssistant();
|
|
127
|
+
out.push({ role: 'assistant', content: toolResultNote(message), at: message.at });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
finalizeActiveAssistant();
|
|
132
|
+
out.push({ ...message });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
finalizeActiveAssistant();
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function buildLocalSummary(messages) {
|
|
140
|
+
const goal = [];
|
|
141
|
+
const constraints = [];
|
|
142
|
+
const changedFiles = new Set();
|
|
143
|
+
const verification = [];
|
|
144
|
+
const openThreads = [];
|
|
145
|
+
const limit = 16;
|
|
146
|
+
for (const msg of messages.slice(-limit)) {
|
|
147
|
+
if (msg.role === 'tool') {
|
|
148
|
+
const text = textFromContent(msg.content);
|
|
149
|
+
let parsed;
|
|
150
|
+
try { parsed = JSON.parse(text); } catch { parsed = null; }
|
|
151
|
+
if (parsed && typeof parsed === 'object') {
|
|
152
|
+
const summary = summarizeToolResult(parsed);
|
|
153
|
+
if (parsed.path) changedFiles.add(String(parsed.path));
|
|
154
|
+
if (parsed.command || parsed.code != null || parsed.stderr || parsed.stdout) {
|
|
155
|
+
verification.push(summary);
|
|
156
|
+
} else {
|
|
157
|
+
openThreads.push(`tool_result: ${summary}`);
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
const clipped = text.length > 120 ? `${text.slice(0, 117)}...` : text;
|
|
161
|
+
const match = clipped.match(/([A-Za-z0-9_./-]+\.[A-Za-z0-9]+):\d+/);
|
|
162
|
+
if (match) changedFiles.add(match[1]);
|
|
163
|
+
openThreads.push(`tool_result: ${clipped}`);
|
|
164
|
+
}
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (msg.role === 'assistant') {
|
|
168
|
+
const text = textFromContent(msg.content).replace(/\s+/g, ' ').trim();
|
|
169
|
+
const toolCallCount = Array.isArray(msg.tool_calls) ? msg.tool_calls.length : 0;
|
|
170
|
+
const toolInfo = toolCallCount > 0 ? ` [called ${toolCallCount} tool(s)]` : '';
|
|
171
|
+
const clipped = text.length > 300 ? `${text.slice(0, 297)}...` : text;
|
|
172
|
+
if (clipped) openThreads.push(`assistant: ${clipped}${toolInfo}`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (msg.role === 'user') {
|
|
176
|
+
const text = textFromContent(msg.content).replace(/\s+/g, ' ').trim();
|
|
177
|
+
const clipped = text.length > 200 ? `${text.slice(0, 197)}...` : text;
|
|
178
|
+
if (goal.length === 0) goal.push(clipped);
|
|
179
|
+
else constraints.push(clipped);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const text = textFromContent(msg.content).replace(/\s+/g, ' ').trim();
|
|
183
|
+
if (!text) continue;
|
|
184
|
+
const clipped = text.length > 160 ? `${text.slice(0, 157)}...` : text;
|
|
185
|
+
openThreads.push(`${msg.role}: ${clipped}`);
|
|
186
|
+
}
|
|
187
|
+
const lines = [
|
|
188
|
+
'Context Summary',
|
|
189
|
+
'Goal:',
|
|
190
|
+
goal.length > 0 ? `- ${goal[0]}` : '- Unknown from compacted context',
|
|
191
|
+
'Key Constraints:',
|
|
192
|
+
...(constraints.length > 0 ? constraints.slice(-4).map((item) => `- ${item}`) : ['- None recorded']),
|
|
193
|
+
'Changed Files:',
|
|
194
|
+
...(changedFiles.size > 0 ? [...changedFiles].slice(0, 8).map((item) => `- ${item}`) : ['- None recorded']),
|
|
195
|
+
'Verification:',
|
|
196
|
+
...(verification.length > 0 ? verification.slice(-4).map((item) => `- ${item}`) : ['- None recorded']),
|
|
197
|
+
'Open Threads:',
|
|
198
|
+
...(openThreads.length > 0 ? openThreads.slice(-8).map((item) => `- ${item}`) : ['- None recorded'])
|
|
199
|
+
];
|
|
200
|
+
return lines.join('\n').trim();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Build a conversation transcript from messages for LLM summarization input.
|
|
205
|
+
* Includes structured metadata (tool calls, file changes) alongside the text.
|
|
206
|
+
*/
|
|
207
|
+
export function buildTranscriptForLLM(messages) {
|
|
208
|
+
const parts = [];
|
|
209
|
+
for (const msg of messages) {
|
|
210
|
+
const text = textFromContent(msg.content).replace(/\s+/g, ' ').trim();
|
|
211
|
+
if (!text && !Array.isArray(msg.tool_calls) && msg.role !== 'user') continue;
|
|
212
|
+
if (msg.role === 'user') {
|
|
213
|
+
parts.push(`[User]\n${text.slice(0, 600)}`);
|
|
214
|
+
} else if (msg.role === 'assistant') {
|
|
215
|
+
let block = `[Assistant]\n${text.slice(0, 600)}`;
|
|
216
|
+
if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
|
|
217
|
+
const toolNames = msg.tool_calls.map(tc => tc.function?.name || tc.name || 'tool').join(', ');
|
|
218
|
+
block += `\n[Called tools: ${toolNames}]`;
|
|
219
|
+
}
|
|
220
|
+
parts.push(block);
|
|
221
|
+
} else if (msg.role === 'tool') {
|
|
222
|
+
let parsed;
|
|
223
|
+
try { parsed = JSON.parse(text); } catch { parsed = null; }
|
|
224
|
+
if (parsed && typeof parsed === 'object') {
|
|
225
|
+
const summary = summarizeToolResult(parsed);
|
|
226
|
+
parts.push(`[Tool Result]\n${summary.slice(0, 400)}`);
|
|
227
|
+
} else {
|
|
228
|
+
parts.push(`[Tool Result]\n${text.slice(0, 300)}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return parts.join('\n\n');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export const COMPACT_SUMMARY_PROMPT = `Summarize the following conversation into a structured context summary that preserves all critical information for continuing the task. Be thorough and specific.
|
|
236
|
+
|
|
237
|
+
Include:
|
|
238
|
+
- The user's goal and requirements
|
|
239
|
+
- Key decisions made and reasoning
|
|
240
|
+
- Files that were read, modified, or created (with paths)
|
|
241
|
+
- Current progress and what remains
|
|
242
|
+
- Any errors encountered and how they were resolved
|
|
243
|
+
- Important constraints or conventions discovered
|
|
244
|
+
|
|
245
|
+
Write in the same language as the conversation. Be concise but do not omit important details.`;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Micro-compact: in-place clearing of old tool result content.
|
|
249
|
+
* Does NOT change message count or order — only replaces tool result text
|
|
250
|
+
* with a lightweight marker, preserving conversation structure.
|
|
251
|
+
*
|
|
252
|
+
* Strategy inspired by Claude Code's Phase 0 micro-compact:
|
|
253
|
+
* keep recent N tool results intact, clear the rest.
|
|
254
|
+
*/
|
|
255
|
+
export function microCompactMessages(messages, { keepRecent = 5, enabled = true } = {}) {
|
|
256
|
+
if (!enabled || !Array.isArray(messages)) {
|
|
257
|
+
return { messages: [...messages], changed: false, tokensSaved: 0 };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Collect indices of all tool-role messages
|
|
261
|
+
const toolIndices = [];
|
|
262
|
+
for (let i = 0; i < messages.length; i++) {
|
|
263
|
+
if (messages[i].role === 'tool') toolIndices.push(i);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (toolIndices.length <= keepRecent) {
|
|
267
|
+
return { messages: [...messages], changed: false, tokensSaved: 0 };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Indices to clear = all except the last keepRecent
|
|
271
|
+
const keepSet = new Set(toolIndices.slice(-keepRecent));
|
|
272
|
+
const clearSet = new Set(toolIndices.filter((idx) => !keepSet.has(idx)));
|
|
273
|
+
|
|
274
|
+
if (clearSet.size === 0) {
|
|
275
|
+
return { messages: [...messages], changed: false, tokensSaved: 0 };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const beforeTokens = estimateMessagesTokens(messages);
|
|
279
|
+
const result = messages.map((msg, i) => {
|
|
280
|
+
if (!clearSet.has(i)) return msg;
|
|
281
|
+
const text = textFromContent(msg.content);
|
|
282
|
+
if (!text || text === MICRO_CLEAR_MARKER) return msg;
|
|
283
|
+
return { ...msg, content: MICRO_CLEAR_MARKER };
|
|
284
|
+
});
|
|
285
|
+
const afterTokens = estimateMessagesTokens(result);
|
|
286
|
+
const tokensSaved = beforeTokens - afterTokens;
|
|
287
|
+
|
|
288
|
+
if (tokensSaved <= 0) {
|
|
289
|
+
return { messages: [...messages], changed: false, tokensSaved: 0 };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return { messages: result, changed: true, tokensSaved };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function compactMessagesLocally(messages, { mode = 'default', force = false, generateSummary = null } = {}) {
|
|
296
|
+
const keepRecent = modeToKeepRecent(mode);
|
|
297
|
+
if (!Array.isArray(messages) || messages.length <= 1) {
|
|
298
|
+
return {
|
|
299
|
+
compacted: [...(messages || [])],
|
|
300
|
+
changed: false
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
// Skip compact when message count is low enough to keep all, unless forced
|
|
304
|
+
if (!force && messages.length <= keepRecent + 1) {
|
|
305
|
+
return {
|
|
306
|
+
compacted: [...(messages || [])],
|
|
307
|
+
changed: false
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const recentStart = expandRecentStartToToolBoundary(messages, Math.max(0, messages.length - keepRecent));
|
|
312
|
+
const older = messages.slice(0, recentStart);
|
|
313
|
+
const recent = sanitizeRecentMessagesForModel(messages.slice(recentStart));
|
|
314
|
+
|
|
315
|
+
let summary;
|
|
316
|
+
if (typeof generateSummary === 'function') {
|
|
317
|
+
try {
|
|
318
|
+
summary = await generateSummary(older);
|
|
319
|
+
} catch {
|
|
320
|
+
summary = buildLocalSummary(older);
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
summary = buildLocalSummary(older);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const compacted = [{ role: 'assistant', content: summary }, ...recent];
|
|
327
|
+
const boundaryIndex = recentStart;
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
compacted,
|
|
331
|
+
changed: true,
|
|
332
|
+
summary,
|
|
333
|
+
boundaryIndex
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function parseCompactArgs(args = []) {
|
|
338
|
+
const parsed = {
|
|
339
|
+
mode: 'default',
|
|
340
|
+
preview: false,
|
|
341
|
+
restore: false,
|
|
342
|
+
micro: false,
|
|
343
|
+
auto: undefined,
|
|
344
|
+
threshold: undefined
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
348
|
+
const arg = args[i];
|
|
349
|
+
if (arg === '--preview') parsed.preview = true;
|
|
350
|
+
if (arg === '--restore') parsed.restore = true;
|
|
351
|
+
if (arg === '--micro') parsed.micro = true;
|
|
352
|
+
if (arg === '--aggressive') parsed.mode = 'aggressive';
|
|
353
|
+
if (arg === '--conservative') parsed.mode = 'conservative';
|
|
354
|
+
if (arg === '--default') parsed.mode = 'default';
|
|
355
|
+
if (arg === '--auto-on') parsed.auto = 'on';
|
|
356
|
+
if (arg === '--auto-off') parsed.auto = 'off';
|
|
357
|
+
if (arg === '--threshold') {
|
|
358
|
+
const n = Number(args[i + 1]);
|
|
359
|
+
if (!Number.isNaN(n)) parsed.threshold = n;
|
|
360
|
+
i += 1;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return parsed;
|
|
365
|
+
}
|