@evalops/maestro 0.10.29 → 0.10.31

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 (34) hide show
  1. package/dist/agent/compaction/cut-points.d.ts +157 -0
  2. package/dist/agent/compaction/cut-points.d.ts.map +1 -0
  3. package/dist/agent/compaction/cut-points.js +341 -0
  4. package/dist/agent/compaction/cut-points.js.map +1 -0
  5. package/dist/agent/compaction.d.ts +4 -144
  6. package/dist/agent/compaction.d.ts.map +1 -1
  7. package/dist/agent/compaction.js +5 -340
  8. package/dist/agent/compaction.js.map +1 -1
  9. package/dist/agent/transport/events.d.ts +23 -0
  10. package/dist/agent/transport/events.d.ts.map +1 -0
  11. package/dist/agent/transport/events.js +69 -0
  12. package/dist/agent/transport/events.js.map +1 -0
  13. package/dist/agent/transport.d.ts.map +1 -1
  14. package/dist/agent/transport.js +1 -68
  15. package/dist/agent/transport.js.map +1 -1
  16. package/dist/cli.js +247 -203
  17. package/dist/node_modules/@evalops/contracts/package.json +1 -1
  18. package/dist/node_modules/@evalops/tui/package.json +1 -1
  19. package/dist/server/agent-runtime-ledger.d.ts +18 -3
  20. package/dist/server/agent-runtime-ledger.d.ts.map +1 -1
  21. package/dist/server/agent-runtime-ledger.js +45 -1
  22. package/dist/server/agent-runtime-ledger.js.map +1 -1
  23. package/dist/tools/parallel-ripgrep.d.ts.map +1 -1
  24. package/dist/tools/parallel-ripgrep.js +1 -10
  25. package/dist/tools/parallel-ripgrep.js.map +1 -1
  26. package/dist/tools/ripgrep-utils.d.ts +1 -0
  27. package/dist/tools/ripgrep-utils.d.ts.map +1 -1
  28. package/dist/tools/ripgrep-utils.js +9 -0
  29. package/dist/tools/ripgrep-utils.js.map +1 -1
  30. package/dist/tools/search.d.ts.map +1 -1
  31. package/dist/tools/search.js +4 -4
  32. package/dist/tools/search.js.map +1 -1
  33. package/dist/version.json +2 -2
  34. package/package.json +1 -1
@@ -0,0 +1,157 @@
1
+ import type { SessionEntry } from "../../session/types.js";
2
+ import type { AppMessage, Usage } from "../types.js";
3
+ /**
4
+ * Configuration for compaction behavior.
5
+ */
6
+ export interface CompactionSettings {
7
+ /** Whether auto-compaction is enabled */
8
+ enabled: boolean;
9
+ /**
10
+ * Tokens to reserve for summary generation and safety margin.
11
+ * Default: 16384 (~13k for summary + ~3k safety margin)
12
+ */
13
+ reserveTokens: number;
14
+ /**
15
+ * Approximate number of recent tokens to preserve verbatim.
16
+ * Default: 20000 (recent context preserved without summarization)
17
+ */
18
+ keepRecentTokens: number;
19
+ }
20
+ /**
21
+ * Default compaction settings based on research into Claude Code, Codex, and OpenCode.
22
+ */
23
+ export declare const DEFAULT_COMPACTION_SETTINGS: CompactionSettings;
24
+ /**
25
+ * Calculate total context tokens from usage metadata.
26
+ *
27
+ * This includes all token types that contribute to context window consumption:
28
+ * - input: Tokens from user/system messages
29
+ * - output: Tokens generated by the model
30
+ * - cacheRead: Tokens read from prompt cache
31
+ * - cacheWrite: Tokens written to prompt cache
32
+ *
33
+ * @param usage - Usage metadata from an assistant message
34
+ * @returns Total context token count
35
+ */
36
+ export declare function calculateContextTokens(usage: Usage): number;
37
+ /**
38
+ * Find the last non-aborted assistant message usage from a message array.
39
+ *
40
+ * Used to determine current context window consumption. Walks backwards
41
+ * to find the most recent valid usage metadata.
42
+ *
43
+ * @param messages - Array of application messages
44
+ * @returns Usage metadata from the last assistant message, or null
45
+ */
46
+ export declare function getLastAssistantUsage(messages: AppMessage[]): Usage | null;
47
+ /**
48
+ * Find the last non-aborted assistant message usage from session entries.
49
+ *
50
+ * @param entries - Array of session entries
51
+ * @returns Usage metadata from the last assistant message, or null
52
+ */
53
+ export declare function getLastAssistantUsageFromEntries(entries: SessionEntry[]): Usage | null;
54
+ /**
55
+ * Determine if compaction should trigger based on current context usage.
56
+ *
57
+ * Compaction triggers when context usage exceeds `contextWindow - reserveTokens`,
58
+ * leaving enough room for summary generation plus a safety margin.
59
+ *
60
+ * @param contextTokens - Current context window usage in tokens
61
+ * @param contextWindow - Model's total context window size
62
+ * @param settings - Compaction configuration
63
+ * @returns true if compaction should trigger
64
+ */
65
+ export declare function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean;
66
+ /**
67
+ * Calculate context usage percentage.
68
+ *
69
+ * @param contextTokens - Current context window usage in tokens
70
+ * @param contextWindow - Model's total context window size
71
+ * @returns Percentage of context window used (0-100)
72
+ */
73
+ export declare function calculateUsagePercent(contextTokens: number, contextWindow: number): number;
74
+ /**
75
+ * Result of finding a cut point, including split turn information.
76
+ */
77
+ export interface CutPointResult {
78
+ /** Index of first message to keep (messages before this are summarized) */
79
+ firstKeptIndex: number;
80
+ /** Index of user message that starts the turn being split, or -1 if not splitting */
81
+ turnStartIndex: number;
82
+ /** Whether this cut splits a turn (cut point is not a user message) */
83
+ isSplitTurn: boolean;
84
+ }
85
+ /**
86
+ * Find indices of user messages (turn boundaries) in a message array.
87
+ *
88
+ * Turn boundaries are important because we never want to split a turn
89
+ * (user message + assistant response + tool results). Cutting at a user
90
+ * message ensures turn integrity.
91
+ *
92
+ * @param messages - Array of messages to scan
93
+ * @param startIndex - First index to consider (inclusive)
94
+ * @param endIndex - Last index to consider (exclusive)
95
+ * @returns Array of indices where user messages appear
96
+ */
97
+ export declare function findTurnBoundaries(messages: AppMessage[], startIndex: number, endIndex: number): number[];
98
+ /**
99
+ * Find the user message that starts the turn containing the given index.
100
+ *
101
+ * @param messages - Array of messages
102
+ * @param entryIndex - Index of the message to find the turn start for
103
+ * @param startIndex - First index to consider (inclusive)
104
+ * @returns Index of the user message that starts this turn, or -1 if not found
105
+ */
106
+ export declare function findTurnStartIndex(messages: AppMessage[], entryIndex: number, startIndex: number): number;
107
+ /**
108
+ * Find the optimal cut point in messages that preserves `keepRecentTokens`.
109
+ *
110
+ * The algorithm:
111
+ * 1. Find all turn boundaries (user message indices)
112
+ * 2. Walk backwards collecting assistant token usage
113
+ * 3. Find where cumulative token difference exceeds keepRecentTokens
114
+ * 4. Return the nearest turn boundary at or before that point
115
+ *
116
+ * This ensures:
117
+ * - Recent context is preserved based on actual token usage, not message count
118
+ * - Turn integrity is maintained (user/assistant/toolResult groups stay together)
119
+ * - More context is kept when messages are small, less when large
120
+ *
121
+ * @param messages - Array of messages to analyze
122
+ * @param startIndex - First index to consider for cutting
123
+ * @param endIndex - Last index to consider (exclusive)
124
+ * @param keepRecentTokens - Approximate tokens to preserve
125
+ * @returns Index of first message to keep (messages before this are summarized)
126
+ */
127
+ export declare function findCutPoint(messages: AppMessage[], startIndex: number, endIndex: number, keepRecentTokens: number): number;
128
+ /**
129
+ * Find the optimal cut point with split turn information.
130
+ *
131
+ * Similar to findCutPoint but:
132
+ * - Can cut at assistant messages (not just user messages)
133
+ * - Returns information about whether we're splitting a turn
134
+ *
135
+ * This allows for more aggressive compaction when turns are very large,
136
+ * while providing the context needed to summarize the split turn prefix.
137
+ *
138
+ * @param messages - Array of messages to analyze
139
+ * @param startIndex - First index to consider for cutting
140
+ * @param endIndex - Last index to consider (exclusive)
141
+ * @param keepRecentTokens - Approximate tokens to preserve
142
+ * @returns CutPointResult with index and split turn information
143
+ */
144
+ export declare function findCutPointWithSplitInfo(messages: AppMessage[], startIndex: number, endIndex: number, keepRecentTokens: number): CutPointResult;
145
+ /**
146
+ * Adjust a cut boundary to preserve tool call/result integrity.
147
+ *
148
+ * Tool results must stay paired with their originating assistant message.
149
+ * If a toolResult would be kept but its toolCall would be cut, this
150
+ * moves the boundary back to include the toolCall.
151
+ *
152
+ * @param messages - Array of messages
153
+ * @param boundary - Initial cut point
154
+ * @returns Adjusted boundary that preserves tool integrity
155
+ */
156
+ export declare function adjustBoundaryForToolResults(messages: AppMessage[], boundary: number): number;
157
+ //# sourceMappingURL=cut-points.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cut-points.d.ts","sourceRoot":"","sources":["../../../src/agent/compaction/cut-points.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,KAAK,EAAE,UAAU,EAAoB,KAAK,EAAE,MAAM,aAAa,CAAC;AAEvE;;GAEG;AACH,MAAM,WAAW,kBAAkB;IAClC,yCAAyC;IACzC,OAAO,EAAE,OAAO,CAAC;IACjB;;;OAGG;IACH,aAAa,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,gBAAgB,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,eAAO,MAAM,2BAA2B,EAAE,kBAIzC,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAE3D;AAsBD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,KAAK,GAAG,IAAI,CAM1E;AAED;;;;;GAKG;AACH,wBAAgB,gCAAgC,CAC/C,OAAO,EAAE,YAAY,EAAE,GACrB,KAAK,GAAG,IAAI,CAUd;AAMD;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAC5B,aAAa,EAAE,MAAM,EACrB,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,kBAAkB,GAC1B,OAAO,CAGT;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACpC,aAAa,EAAE,MAAM,EACrB,aAAa,EAAE,MAAM,GACnB,MAAM,CAGR;AAMD;;GAEG;AACH,MAAM,WAAW,cAAc;IAC9B,2EAA2E;IAC3E,cAAc,EAAE,MAAM,CAAC;IACvB,qFAAqF;IACrF,cAAc,EAAE,MAAM,CAAC;IACvB,uEAAuE;IACvE,WAAW,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CACjC,QAAQ,EAAE,UAAU,EAAE,EACtB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GACd,MAAM,EAAE,CAQV;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CACjC,QAAQ,EAAE,UAAU,EAAE,EACtB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,GAChB,MAAM,CAOR;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,YAAY,CAC3B,QAAQ,EAAE,UAAU,EAAE,EACtB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,gBAAgB,EAAE,MAAM,GACtB,MAAM,CA8CR;AA4BD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,yBAAyB,CACxC,QAAQ,EAAE,UAAU,EAAE,EACtB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,gBAAgB,EAAE,MAAM,GACtB,cAAc,CAkDhB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,4BAA4B,CAC3C,QAAQ,EAAE,UAAU,EAAE,EACtB,QAAQ,EAAE,MAAM,GACd,MAAM,CA4CR"}
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Default compaction settings based on research into Claude Code, Codex, and OpenCode.
3
+ */
4
+ export const DEFAULT_COMPACTION_SETTINGS = {
5
+ enabled: true,
6
+ reserveTokens: 16384,
7
+ keepRecentTokens: 20000,
8
+ };
9
+ /**
10
+ * Calculate total context tokens from usage metadata.
11
+ *
12
+ * This includes all token types that contribute to context window consumption:
13
+ * - input: Tokens from user/system messages
14
+ * - output: Tokens generated by the model
15
+ * - cacheRead: Tokens read from prompt cache
16
+ * - cacheWrite: Tokens written to prompt cache
17
+ *
18
+ * @param usage - Usage metadata from an assistant message
19
+ * @returns Total context token count
20
+ */
21
+ export function calculateContextTokens(usage) {
22
+ return usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
23
+ }
24
+ /**
25
+ * Extract usage metadata from a message if it's a non-aborted assistant message.
26
+ *
27
+ * @param msg - Any application message
28
+ * @returns Usage metadata or null if not available/applicable
29
+ */
30
+ function getAssistantUsage(msg) {
31
+ if (msg.role === "assistant") {
32
+ const assistantMsg = msg;
33
+ if (assistantMsg.stopReason !== "aborted" &&
34
+ assistantMsg.stopReason !== "error" &&
35
+ assistantMsg.usage) {
36
+ return assistantMsg.usage;
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+ /**
42
+ * Find the last non-aborted assistant message usage from a message array.
43
+ *
44
+ * Used to determine current context window consumption. Walks backwards
45
+ * to find the most recent valid usage metadata.
46
+ *
47
+ * @param messages - Array of application messages
48
+ * @returns Usage metadata from the last assistant message, or null
49
+ */
50
+ export function getLastAssistantUsage(messages) {
51
+ for (let i = messages.length - 1; i >= 0; i--) {
52
+ const usage = getAssistantUsage(messages[i]);
53
+ if (usage)
54
+ return usage;
55
+ }
56
+ return null;
57
+ }
58
+ /**
59
+ * Find the last non-aborted assistant message usage from session entries.
60
+ *
61
+ * @param entries - Array of session entries
62
+ * @returns Usage metadata from the last assistant message, or null
63
+ */
64
+ export function getLastAssistantUsageFromEntries(entries) {
65
+ for (let i = entries.length - 1; i >= 0; i--) {
66
+ const entry = entries[i];
67
+ if (!entry)
68
+ continue;
69
+ if (entry.type === "message") {
70
+ const usage = getAssistantUsage(entry.message);
71
+ if (usage)
72
+ return usage;
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+ // ============================================================================
78
+ // Compaction Trigger Detection
79
+ // ============================================================================
80
+ /**
81
+ * Determine if compaction should trigger based on current context usage.
82
+ *
83
+ * Compaction triggers when context usage exceeds `contextWindow - reserveTokens`,
84
+ * leaving enough room for summary generation plus a safety margin.
85
+ *
86
+ * @param contextTokens - Current context window usage in tokens
87
+ * @param contextWindow - Model's total context window size
88
+ * @param settings - Compaction configuration
89
+ * @returns true if compaction should trigger
90
+ */
91
+ export function shouldCompact(contextTokens, contextWindow, settings) {
92
+ if (!settings.enabled)
93
+ return false;
94
+ return contextTokens > contextWindow - settings.reserveTokens;
95
+ }
96
+ /**
97
+ * Calculate context usage percentage.
98
+ *
99
+ * @param contextTokens - Current context window usage in tokens
100
+ * @param contextWindow - Model's total context window size
101
+ * @returns Percentage of context window used (0-100)
102
+ */
103
+ export function calculateUsagePercent(contextTokens, contextWindow) {
104
+ if (contextWindow <= 0)
105
+ return 0;
106
+ return (contextTokens / contextWindow) * 100;
107
+ }
108
+ /**
109
+ * Find indices of user messages (turn boundaries) in a message array.
110
+ *
111
+ * Turn boundaries are important because we never want to split a turn
112
+ * (user message + assistant response + tool results). Cutting at a user
113
+ * message ensures turn integrity.
114
+ *
115
+ * @param messages - Array of messages to scan
116
+ * @param startIndex - First index to consider (inclusive)
117
+ * @param endIndex - Last index to consider (exclusive)
118
+ * @returns Array of indices where user messages appear
119
+ */
120
+ export function findTurnBoundaries(messages, startIndex, endIndex) {
121
+ const boundaries = [];
122
+ for (let i = startIndex; i < endIndex; i++) {
123
+ if (messages[i].role === "user") {
124
+ boundaries.push(i);
125
+ }
126
+ }
127
+ return boundaries;
128
+ }
129
+ /**
130
+ * Find the user message that starts the turn containing the given index.
131
+ *
132
+ * @param messages - Array of messages
133
+ * @param entryIndex - Index of the message to find the turn start for
134
+ * @param startIndex - First index to consider (inclusive)
135
+ * @returns Index of the user message that starts this turn, or -1 if not found
136
+ */
137
+ export function findTurnStartIndex(messages, entryIndex, startIndex) {
138
+ for (let i = entryIndex; i >= startIndex; i--) {
139
+ if (messages[i].role === "user") {
140
+ return i;
141
+ }
142
+ }
143
+ return -1;
144
+ }
145
+ /**
146
+ * Find the optimal cut point in messages that preserves `keepRecentTokens`.
147
+ *
148
+ * The algorithm:
149
+ * 1. Find all turn boundaries (user message indices)
150
+ * 2. Walk backwards collecting assistant token usage
151
+ * 3. Find where cumulative token difference exceeds keepRecentTokens
152
+ * 4. Return the nearest turn boundary at or before that point
153
+ *
154
+ * This ensures:
155
+ * - Recent context is preserved based on actual token usage, not message count
156
+ * - Turn integrity is maintained (user/assistant/toolResult groups stay together)
157
+ * - More context is kept when messages are small, less when large
158
+ *
159
+ * @param messages - Array of messages to analyze
160
+ * @param startIndex - First index to consider for cutting
161
+ * @param endIndex - Last index to consider (exclusive)
162
+ * @param keepRecentTokens - Approximate tokens to preserve
163
+ * @returns Index of first message to keep (messages before this are summarized)
164
+ */
165
+ export function findCutPoint(messages, startIndex, endIndex, keepRecentTokens) {
166
+ const boundaries = findTurnBoundaries(messages, startIndex, endIndex);
167
+ if (boundaries.length === 0) {
168
+ return startIndex; // No user messages, keep everything in range
169
+ }
170
+ // Collect assistant usages walking backwards from endIndex
171
+ const assistantUsages = [];
172
+ for (let i = endIndex - 1; i >= startIndex; i--) {
173
+ const usage = getAssistantUsage(messages[i]);
174
+ if (usage) {
175
+ assistantUsages.push({
176
+ index: i,
177
+ tokens: calculateContextTokens(usage),
178
+ });
179
+ }
180
+ }
181
+ if (assistantUsages.length === 0) {
182
+ // No usage info, keep last turn only
183
+ return boundaries[boundaries.length - 1];
184
+ }
185
+ // Walk through and find where cumulative token difference exceeds keepRecentTokens
186
+ const newestTokens = assistantUsages[0].tokens;
187
+ let cutIndex = startIndex; // Default: keep everything in range
188
+ for (let i = 1; i < assistantUsages.length; i++) {
189
+ const tokenDiff = newestTokens - assistantUsages[i].tokens;
190
+ if (tokenDiff >= keepRecentTokens) {
191
+ // Find the turn boundary at or before the assistant we want to keep
192
+ const lastKeptAssistantIndex = assistantUsages[i - 1].index;
193
+ for (let b = boundaries.length - 1; b >= 0; b--) {
194
+ const boundary = boundaries[b];
195
+ if (boundary !== undefined && boundary <= lastKeptAssistantIndex) {
196
+ cutIndex = boundary;
197
+ break;
198
+ }
199
+ }
200
+ break;
201
+ }
202
+ }
203
+ return cutIndex;
204
+ }
205
+ /**
206
+ * Find valid cut points (user or assistant messages, not tool results).
207
+ * Tool results must stay with their preceding tool call.
208
+ *
209
+ * @param messages - Array of messages to scan
210
+ * @param startIndex - First index to consider (inclusive)
211
+ * @param endIndex - Last index to consider (exclusive)
212
+ * @returns Array of indices where valid cut points exist
213
+ */
214
+ function findValidCutPoints(messages, startIndex, endIndex) {
215
+ const cutPoints = [];
216
+ for (let i = startIndex; i < endIndex; i++) {
217
+ const role = messages[i].role;
218
+ // user and assistant are valid cut points
219
+ // toolResult must stay with its preceding tool call
220
+ if (role === "user" || role === "assistant") {
221
+ cutPoints.push(i);
222
+ }
223
+ }
224
+ return cutPoints;
225
+ }
226
+ /**
227
+ * Find the optimal cut point with split turn information.
228
+ *
229
+ * Similar to findCutPoint but:
230
+ * - Can cut at assistant messages (not just user messages)
231
+ * - Returns information about whether we're splitting a turn
232
+ *
233
+ * This allows for more aggressive compaction when turns are very large,
234
+ * while providing the context needed to summarize the split turn prefix.
235
+ *
236
+ * @param messages - Array of messages to analyze
237
+ * @param startIndex - First index to consider for cutting
238
+ * @param endIndex - Last index to consider (exclusive)
239
+ * @param keepRecentTokens - Approximate tokens to preserve
240
+ * @returns CutPointResult with index and split turn information
241
+ */
242
+ export function findCutPointWithSplitInfo(messages, startIndex, endIndex, keepRecentTokens) {
243
+ const cutPoints = findValidCutPoints(messages, startIndex, endIndex);
244
+ if (cutPoints.length === 0) {
245
+ return {
246
+ firstKeptIndex: startIndex,
247
+ turnStartIndex: -1,
248
+ isSplitTurn: false,
249
+ };
250
+ }
251
+ // Estimate message sizes for token-based cutting
252
+ let accumulatedTokens = 0;
253
+ let cutIndex = startIndex;
254
+ for (let i = endIndex - 1; i >= startIndex; i--) {
255
+ const usage = getAssistantUsage(messages[i]);
256
+ if (usage) {
257
+ accumulatedTokens = calculateContextTokens(usage);
258
+ // Using cumulative tokens from the last assistant message
259
+ if (accumulatedTokens >= keepRecentTokens) {
260
+ // Find the closest valid cut point at or after this index
261
+ for (const cp of cutPoints) {
262
+ if (cp >= i) {
263
+ cutIndex = cp;
264
+ break;
265
+ }
266
+ }
267
+ break;
268
+ }
269
+ }
270
+ }
271
+ // If no cut point found via tokens, use last assistant's token-based cut
272
+ if (cutIndex === startIndex) {
273
+ // Fall back to original algorithm
274
+ cutIndex = findCutPoint(messages, startIndex, endIndex, keepRecentTokens);
275
+ }
276
+ // Determine if this is a split turn
277
+ const isUserMessage = messages[cutIndex]?.role === "user";
278
+ const turnStartIndex = isUserMessage
279
+ ? -1
280
+ : findTurnStartIndex(messages, cutIndex, startIndex);
281
+ return {
282
+ firstKeptIndex: cutIndex,
283
+ turnStartIndex,
284
+ isSplitTurn: !isUserMessage && turnStartIndex !== -1,
285
+ };
286
+ }
287
+ /**
288
+ * Adjust a cut boundary to preserve tool call/result integrity.
289
+ *
290
+ * Tool results must stay paired with their originating assistant message.
291
+ * If a toolResult would be kept but its toolCall would be cut, this
292
+ * moves the boundary back to include the toolCall.
293
+ *
294
+ * @param messages - Array of messages
295
+ * @param boundary - Initial cut point
296
+ * @returns Adjusted boundary that preserves tool integrity
297
+ */
298
+ export function adjustBoundaryForToolResults(messages, boundary) {
299
+ let adjusted = boundary;
300
+ const seenToolCalls = new Set();
301
+ const missingToolCalls = new Set();
302
+ // Process assistant message to collect tool calls
303
+ const processAssistantMessage = (message) => {
304
+ if (message.role !== "assistant")
305
+ return;
306
+ const content = message.content;
307
+ if (!content)
308
+ return;
309
+ for (const part of content) {
310
+ if (part?.type === "toolCall") {
311
+ seenToolCalls.add(part.id);
312
+ if (missingToolCalls.has(part.id)) {
313
+ missingToolCalls.delete(part.id);
314
+ }
315
+ }
316
+ }
317
+ };
318
+ // Process tool result to check for missing tool calls
319
+ const processToolResultMessage = (message) => {
320
+ if (message.role !== "toolResult")
321
+ return;
322
+ const toolCallId = message.toolCallId;
323
+ if (!seenToolCalls.has(toolCallId)) {
324
+ missingToolCalls.add(toolCallId);
325
+ }
326
+ };
327
+ // First pass: process messages we're keeping
328
+ for (const message of messages.slice(adjusted)) {
329
+ processAssistantMessage(message);
330
+ processToolResultMessage(message);
331
+ }
332
+ // Walk backwards to find missing tool calls
333
+ while (missingToolCalls.size > 0 && adjusted > 0) {
334
+ adjusted -= 1;
335
+ const candidate = messages[adjusted];
336
+ processAssistantMessage(candidate);
337
+ processToolResultMessage(candidate);
338
+ }
339
+ return adjusted;
340
+ }
341
+ //# sourceMappingURL=cut-points.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cut-points.js","sourceRoot":"","sources":["../../../src/agent/compaction/cut-points.ts"],"names":[],"mappings":"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","sourcesContent":["import type { SessionEntry } from \"../../session/types.js\";\nimport type { AppMessage, AssistantMessage, Usage } from \"../types.js\";\n\n/**\n * Configuration for compaction behavior.\n */\nexport interface CompactionSettings {\n\t/** Whether auto-compaction is enabled */\n\tenabled: boolean;\n\t/**\n\t * Tokens to reserve for summary generation and safety margin.\n\t * Default: 16384 (~13k for summary + ~3k safety margin)\n\t */\n\treserveTokens: number;\n\t/**\n\t * Approximate number of recent tokens to preserve verbatim.\n\t * Default: 20000 (recent context preserved without summarization)\n\t */\n\tkeepRecentTokens: number;\n}\n\n/**\n * Default compaction settings based on research into Claude Code, Codex, and OpenCode.\n */\nexport const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {\n\tenabled: true,\n\treserveTokens: 16384,\n\tkeepRecentTokens: 20000,\n};\n\n/**\n * Calculate total context tokens from usage metadata.\n *\n * This includes all token types that contribute to context window consumption:\n * - input: Tokens from user/system messages\n * - output: Tokens generated by the model\n * - cacheRead: Tokens read from prompt cache\n * - cacheWrite: Tokens written to prompt cache\n *\n * @param usage - Usage metadata from an assistant message\n * @returns Total context token count\n */\nexport function calculateContextTokens(usage: Usage): number {\n\treturn usage.input + usage.output + usage.cacheRead + usage.cacheWrite;\n}\n\n/**\n * Extract usage metadata from a message if it's a non-aborted assistant message.\n *\n * @param msg - Any application message\n * @returns Usage metadata or null if not available/applicable\n */\nfunction getAssistantUsage(msg: AppMessage): Usage | null {\n\tif (msg.role === \"assistant\") {\n\t\tconst assistantMsg = msg as AssistantMessage;\n\t\tif (\n\t\t\tassistantMsg.stopReason !== \"aborted\" &&\n\t\t\tassistantMsg.stopReason !== \"error\" &&\n\t\t\tassistantMsg.usage\n\t\t) {\n\t\t\treturn assistantMsg.usage;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Find the last non-aborted assistant message usage from a message array.\n *\n * Used to determine current context window consumption. Walks backwards\n * to find the most recent valid usage metadata.\n *\n * @param messages - Array of application messages\n * @returns Usage metadata from the last assistant message, or null\n */\nexport function getLastAssistantUsage(messages: AppMessage[]): Usage | null {\n\tfor (let i = messages.length - 1; i >= 0; i--) {\n\t\tconst usage = getAssistantUsage(messages[i]!);\n\t\tif (usage) return usage;\n\t}\n\treturn null;\n}\n\n/**\n * Find the last non-aborted assistant message usage from session entries.\n *\n * @param entries - Array of session entries\n * @returns Usage metadata from the last assistant message, or null\n */\nexport function getLastAssistantUsageFromEntries(\n\tentries: SessionEntry[],\n): Usage | null {\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tconst entry = entries[i];\n\t\tif (!entry) continue;\n\t\tif (entry.type === \"message\") {\n\t\t\tconst usage = getAssistantUsage(entry.message as AppMessage);\n\t\t\tif (usage) return usage;\n\t\t}\n\t}\n\treturn null;\n}\n\n// ============================================================================\n// Compaction Trigger Detection\n// ============================================================================\n\n/**\n * Determine if compaction should trigger based on current context usage.\n *\n * Compaction triggers when context usage exceeds `contextWindow - reserveTokens`,\n * leaving enough room for summary generation plus a safety margin.\n *\n * @param contextTokens - Current context window usage in tokens\n * @param contextWindow - Model's total context window size\n * @param settings - Compaction configuration\n * @returns true if compaction should trigger\n */\nexport function shouldCompact(\n\tcontextTokens: number,\n\tcontextWindow: number,\n\tsettings: CompactionSettings,\n): boolean {\n\tif (!settings.enabled) return false;\n\treturn contextTokens > contextWindow - settings.reserveTokens;\n}\n\n/**\n * Calculate context usage percentage.\n *\n * @param contextTokens - Current context window usage in tokens\n * @param contextWindow - Model's total context window size\n * @returns Percentage of context window used (0-100)\n */\nexport function calculateUsagePercent(\n\tcontextTokens: number,\n\tcontextWindow: number,\n): number {\n\tif (contextWindow <= 0) return 0;\n\treturn (contextTokens / contextWindow) * 100;\n}\n\n// ============================================================================\n// Cut Point Detection\n// ============================================================================\n\n/**\n * Result of finding a cut point, including split turn information.\n */\nexport interface CutPointResult {\n\t/** Index of first message to keep (messages before this are summarized) */\n\tfirstKeptIndex: number;\n\t/** Index of user message that starts the turn being split, or -1 if not splitting */\n\tturnStartIndex: number;\n\t/** Whether this cut splits a turn (cut point is not a user message) */\n\tisSplitTurn: boolean;\n}\n\n/**\n * Find indices of user messages (turn boundaries) in a message array.\n *\n * Turn boundaries are important because we never want to split a turn\n * (user message + assistant response + tool results). Cutting at a user\n * message ensures turn integrity.\n *\n * @param messages - Array of messages to scan\n * @param startIndex - First index to consider (inclusive)\n * @param endIndex - Last index to consider (exclusive)\n * @returns Array of indices where user messages appear\n */\nexport function findTurnBoundaries(\n\tmessages: AppMessage[],\n\tstartIndex: number,\n\tendIndex: number,\n): number[] {\n\tconst boundaries: number[] = [];\n\tfor (let i = startIndex; i < endIndex; i++) {\n\t\tif (messages[i]!.role === \"user\") {\n\t\t\tboundaries.push(i);\n\t\t}\n\t}\n\treturn boundaries;\n}\n\n/**\n * Find the user message that starts the turn containing the given index.\n *\n * @param messages - Array of messages\n * @param entryIndex - Index of the message to find the turn start for\n * @param startIndex - First index to consider (inclusive)\n * @returns Index of the user message that starts this turn, or -1 if not found\n */\nexport function findTurnStartIndex(\n\tmessages: AppMessage[],\n\tentryIndex: number,\n\tstartIndex: number,\n): number {\n\tfor (let i = entryIndex; i >= startIndex; i--) {\n\t\tif (messages[i]!.role === \"user\") {\n\t\t\treturn i;\n\t\t}\n\t}\n\treturn -1;\n}\n\n/**\n * Find the optimal cut point in messages that preserves `keepRecentTokens`.\n *\n * The algorithm:\n * 1. Find all turn boundaries (user message indices)\n * 2. Walk backwards collecting assistant token usage\n * 3. Find where cumulative token difference exceeds keepRecentTokens\n * 4. Return the nearest turn boundary at or before that point\n *\n * This ensures:\n * - Recent context is preserved based on actual token usage, not message count\n * - Turn integrity is maintained (user/assistant/toolResult groups stay together)\n * - More context is kept when messages are small, less when large\n *\n * @param messages - Array of messages to analyze\n * @param startIndex - First index to consider for cutting\n * @param endIndex - Last index to consider (exclusive)\n * @param keepRecentTokens - Approximate tokens to preserve\n * @returns Index of first message to keep (messages before this are summarized)\n */\nexport function findCutPoint(\n\tmessages: AppMessage[],\n\tstartIndex: number,\n\tendIndex: number,\n\tkeepRecentTokens: number,\n): number {\n\tconst boundaries = findTurnBoundaries(messages, startIndex, endIndex);\n\n\tif (boundaries.length === 0) {\n\t\treturn startIndex; // No user messages, keep everything in range\n\t}\n\n\t// Collect assistant usages walking backwards from endIndex\n\tconst assistantUsages: Array<{ index: number; tokens: number }> = [];\n\tfor (let i = endIndex - 1; i >= startIndex; i--) {\n\t\tconst usage = getAssistantUsage(messages[i]!);\n\t\tif (usage) {\n\t\t\tassistantUsages.push({\n\t\t\t\tindex: i,\n\t\t\t\ttokens: calculateContextTokens(usage),\n\t\t\t});\n\t\t}\n\t}\n\n\tif (assistantUsages.length === 0) {\n\t\t// No usage info, keep last turn only\n\t\treturn boundaries[boundaries.length - 1]!;\n\t}\n\n\t// Walk through and find where cumulative token difference exceeds keepRecentTokens\n\tconst newestTokens = assistantUsages[0]!.tokens;\n\tlet cutIndex = startIndex; // Default: keep everything in range\n\n\tfor (let i = 1; i < assistantUsages.length; i++) {\n\t\tconst tokenDiff = newestTokens - assistantUsages[i]!.tokens;\n\t\tif (tokenDiff >= keepRecentTokens) {\n\t\t\t// Find the turn boundary at or before the assistant we want to keep\n\t\t\tconst lastKeptAssistantIndex = assistantUsages[i - 1]!.index;\n\n\t\t\tfor (let b = boundaries.length - 1; b >= 0; b--) {\n\t\t\t\tconst boundary = boundaries[b];\n\t\t\t\tif (boundary !== undefined && boundary <= lastKeptAssistantIndex) {\n\t\t\t\t\tcutIndex = boundary;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak;\n\t\t}\n\t}\n\n\treturn cutIndex;\n}\n\n/**\n * Find valid cut points (user or assistant messages, not tool results).\n * Tool results must stay with their preceding tool call.\n *\n * @param messages - Array of messages to scan\n * @param startIndex - First index to consider (inclusive)\n * @param endIndex - Last index to consider (exclusive)\n * @returns Array of indices where valid cut points exist\n */\nfunction findValidCutPoints(\n\tmessages: AppMessage[],\n\tstartIndex: number,\n\tendIndex: number,\n): number[] {\n\tconst cutPoints: number[] = [];\n\tfor (let i = startIndex; i < endIndex; i++) {\n\t\tconst role = messages[i]!.role;\n\t\t// user and assistant are valid cut points\n\t\t// toolResult must stay with its preceding tool call\n\t\tif (role === \"user\" || role === \"assistant\") {\n\t\t\tcutPoints.push(i);\n\t\t}\n\t}\n\treturn cutPoints;\n}\n\n/**\n * Find the optimal cut point with split turn information.\n *\n * Similar to findCutPoint but:\n * - Can cut at assistant messages (not just user messages)\n * - Returns information about whether we're splitting a turn\n *\n * This allows for more aggressive compaction when turns are very large,\n * while providing the context needed to summarize the split turn prefix.\n *\n * @param messages - Array of messages to analyze\n * @param startIndex - First index to consider for cutting\n * @param endIndex - Last index to consider (exclusive)\n * @param keepRecentTokens - Approximate tokens to preserve\n * @returns CutPointResult with index and split turn information\n */\nexport function findCutPointWithSplitInfo(\n\tmessages: AppMessage[],\n\tstartIndex: number,\n\tendIndex: number,\n\tkeepRecentTokens: number,\n): CutPointResult {\n\tconst cutPoints = findValidCutPoints(messages, startIndex, endIndex);\n\n\tif (cutPoints.length === 0) {\n\t\treturn {\n\t\t\tfirstKeptIndex: startIndex,\n\t\t\tturnStartIndex: -1,\n\t\t\tisSplitTurn: false,\n\t\t};\n\t}\n\n\t// Estimate message sizes for token-based cutting\n\tlet accumulatedTokens = 0;\n\tlet cutIndex = startIndex;\n\n\tfor (let i = endIndex - 1; i >= startIndex; i--) {\n\t\tconst usage = getAssistantUsage(messages[i]!);\n\t\tif (usage) {\n\t\t\taccumulatedTokens = calculateContextTokens(usage);\n\t\t\t// Using cumulative tokens from the last assistant message\n\t\t\tif (accumulatedTokens >= keepRecentTokens) {\n\t\t\t\t// Find the closest valid cut point at or after this index\n\t\t\t\tfor (const cp of cutPoints) {\n\t\t\t\t\tif (cp >= i) {\n\t\t\t\t\t\tcutIndex = cp;\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n\n\t// If no cut point found via tokens, use last assistant's token-based cut\n\tif (cutIndex === startIndex) {\n\t\t// Fall back to original algorithm\n\t\tcutIndex = findCutPoint(messages, startIndex, endIndex, keepRecentTokens);\n\t}\n\n\t// Determine if this is a split turn\n\tconst isUserMessage = messages[cutIndex]?.role === \"user\";\n\tconst turnStartIndex = isUserMessage\n\t\t? -1\n\t\t: findTurnStartIndex(messages, cutIndex, startIndex);\n\n\treturn {\n\t\tfirstKeptIndex: cutIndex,\n\t\tturnStartIndex,\n\t\tisSplitTurn: !isUserMessage && turnStartIndex !== -1,\n\t};\n}\n\n/**\n * Adjust a cut boundary to preserve tool call/result integrity.\n *\n * Tool results must stay paired with their originating assistant message.\n * If a toolResult would be kept but its toolCall would be cut, this\n * moves the boundary back to include the toolCall.\n *\n * @param messages - Array of messages\n * @param boundary - Initial cut point\n * @returns Adjusted boundary that preserves tool integrity\n */\nexport function adjustBoundaryForToolResults(\n\tmessages: AppMessage[],\n\tboundary: number,\n): number {\n\tlet adjusted = boundary;\n\tconst seenToolCalls = new Set<string>();\n\tconst missingToolCalls = new Set<string>();\n\n\t// Process assistant message to collect tool calls\n\tconst processAssistantMessage = (message: AppMessage) => {\n\t\tif (message.role !== \"assistant\") return;\n\t\tconst content = (message as AssistantMessage).content;\n\t\tif (!content) return;\n\t\tfor (const part of content) {\n\t\t\tif (part?.type === \"toolCall\") {\n\t\t\t\tseenToolCalls.add(part.id);\n\t\t\t\tif (missingToolCalls.has(part.id)) {\n\t\t\t\t\tmissingToolCalls.delete(part.id);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t};\n\n\t// Process tool result to check for missing tool calls\n\tconst processToolResultMessage = (message: AppMessage) => {\n\t\tif (message.role !== \"toolResult\") return;\n\t\tconst toolCallId = (message as { toolCallId: string }).toolCallId;\n\t\tif (!seenToolCalls.has(toolCallId)) {\n\t\t\tmissingToolCalls.add(toolCallId);\n\t\t}\n\t};\n\n\t// First pass: process messages we're keeping\n\tfor (const message of messages.slice(adjusted)) {\n\t\tprocessAssistantMessage(message);\n\t\tprocessToolResultMessage(message);\n\t}\n\n\t// Walk backwards to find missing tool calls\n\twhile (missingToolCalls.size > 0 && adjusted > 0) {\n\t\tadjusted -= 1;\n\t\tconst candidate = messages[adjusted]!;\n\t\tprocessAssistantMessage(candidate);\n\t\tprocessToolResultMessage(candidate);\n\t}\n\n\treturn adjusted;\n}\n"]}