jeopi-agent-core 16.2.13

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 (66) hide show
  1. package/CHANGELOG.md +1016 -0
  2. package/README.md +473 -0
  3. package/dist/types/agent-loop.d.ts +66 -0
  4. package/dist/types/agent.d.ts +427 -0
  5. package/dist/types/append-only-context.d.ts +133 -0
  6. package/dist/types/compaction/branch-summarization.d.ts +101 -0
  7. package/dist/types/compaction/compaction-v2-streaming.d.ts +82 -0
  8. package/dist/types/compaction/compaction.d.ts +283 -0
  9. package/dist/types/compaction/entries.d.ts +110 -0
  10. package/dist/types/compaction/errors.d.ts +26 -0
  11. package/dist/types/compaction/index.d.ts +12 -0
  12. package/dist/types/compaction/messages.d.ts +77 -0
  13. package/dist/types/compaction/openai.d.ts +77 -0
  14. package/dist/types/compaction/pruning.d.ts +105 -0
  15. package/dist/types/compaction/shake.d.ts +92 -0
  16. package/dist/types/compaction/tool-protection.d.ts +17 -0
  17. package/dist/types/compaction/utils.d.ts +58 -0
  18. package/dist/types/compaction.d.ts +1 -0
  19. package/dist/types/index.d.ts +12 -0
  20. package/dist/types/proxy.d.ts +85 -0
  21. package/dist/types/replay-policy.d.ts +5 -0
  22. package/dist/types/run-collector.d.ts +196 -0
  23. package/dist/types/telemetry.d.ts +590 -0
  24. package/dist/types/thinking.d.ts +17 -0
  25. package/dist/types/tokenizer.d.ts +1 -0
  26. package/dist/types/types.d.ts +640 -0
  27. package/dist/types/utils/yield.d.ts +71 -0
  28. package/package.json +78 -0
  29. package/src/agent-loop.ts +2188 -0
  30. package/src/agent.ts +1457 -0
  31. package/src/append-only-context.ts +348 -0
  32. package/src/compaction/branch-summarization.ts +370 -0
  33. package/src/compaction/compaction-v2-streaming.ts +719 -0
  34. package/src/compaction/compaction.ts +1553 -0
  35. package/src/compaction/entries.ts +142 -0
  36. package/src/compaction/errors.ts +31 -0
  37. package/src/compaction/index.ts +13 -0
  38. package/src/compaction/messages.ts +237 -0
  39. package/src/compaction/openai.ts +581 -0
  40. package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
  41. package/src/compaction/prompts/branch-summary-context.md +5 -0
  42. package/src/compaction/prompts/branch-summary-preamble.md +2 -0
  43. package/src/compaction/prompts/branch-summary.md +30 -0
  44. package/src/compaction/prompts/compaction-short-summary.md +9 -0
  45. package/src/compaction/prompts/compaction-summary-context.md +5 -0
  46. package/src/compaction/prompts/compaction-summary.md +38 -0
  47. package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
  48. package/src/compaction/prompts/compaction-update-summary.md +45 -0
  49. package/src/compaction/prompts/file-operations.md +5 -0
  50. package/src/compaction/prompts/handoff-document.md +49 -0
  51. package/src/compaction/prompts/snapcompact-archive-context.md +3 -0
  52. package/src/compaction/prompts/summarization-system.md +3 -0
  53. package/src/compaction/pruning.ts +424 -0
  54. package/src/compaction/shake.ts +429 -0
  55. package/src/compaction/tool-protection.ts +55 -0
  56. package/src/compaction/utils.ts +323 -0
  57. package/src/compaction.ts +1 -0
  58. package/src/index.ts +24 -0
  59. package/src/proxy.ts +376 -0
  60. package/src/replay-policy.ts +13 -0
  61. package/src/run-collector.ts +631 -0
  62. package/src/telemetry.ts +2034 -0
  63. package/src/thinking.ts +19 -0
  64. package/src/tokenizer.ts +17 -0
  65. package/src/types.ts +718 -0
  66. package/src/utils/yield.ts +183 -0
@@ -0,0 +1,1553 @@
1
+ /**
2
+ * Context compaction for long sessions.
3
+ *
4
+ * Pure functions for compaction logic. The session manager handles I/O,
5
+ * and after compaction the session is reloaded.
6
+ */
7
+
8
+ import {
9
+ type Api,
10
+ type ApiKey,
11
+ type AssistantMessage,
12
+ type Context,
13
+ Effort,
14
+ type FetchImpl,
15
+ type Message,
16
+ type MessageAttribution,
17
+ type Model,
18
+ type SimpleStreamOptions,
19
+ type Tool,
20
+ type Usage,
21
+ withAuth,
22
+ } from "jeopi-ai";
23
+ import { ProviderHttpError } from "jeopi-ai/error";
24
+ import { convertTools } from "jeopi-ai/providers/openai-responses";
25
+ import { buildResponsesInput, resolveOpenAICompatPolicy } from "jeopi-ai/providers/openai-shared";
26
+ import { preferredDialect } from "jeopi-catalog/identity";
27
+ import { clampThinkingLevelForModel } from "jeopi-catalog/model-thinking";
28
+ import * as snapcompact from "jeopi-snapcompact";
29
+ import { logger, prompt } from "jeopi-utils";
30
+ import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
31
+ import { ThinkingLevel } from "../thinking";
32
+ import { countTokens } from "../tokenizer";
33
+ import type { AgentMessage } from "../types";
34
+ import {
35
+ buildCompactionV2Request,
36
+ getCompactionV2PreserveData,
37
+ requestCompactionV2Streaming,
38
+ shouldUseCompactionV2Streaming,
39
+ storeCompactionV2PreserveData,
40
+ V2_RETAINED_MESSAGE_TOKEN_BUDGET,
41
+ } from "./compaction-v2-streaming";
42
+ import type { CompactionEntry, SessionEntry } from "./entries";
43
+ import { type ConvertToLlm, createBranchSummaryMessage, createCustomMessage, defaultConvertToLlm } from "./messages";
44
+ import {
45
+ buildOpenAiNativeHistory,
46
+ getPreservedOpenAiRemoteCompactionData,
47
+ requestOpenAiRemoteCompaction,
48
+ requestRemoteCompaction,
49
+ shouldUseOpenAiRemoteCompaction,
50
+ withOpenAiRemoteCompactionPreserveData,
51
+ } from "./openai";
52
+ import autoHandoffThresholdFocusPrompt from "./prompts/auto-handoff-threshold-focus.md" with { type: "text" };
53
+ import compactionShortSummaryPrompt from "./prompts/compaction-short-summary.md" with { type: "text" };
54
+ import compactionSummaryPrompt from "./prompts/compaction-summary.md" with { type: "text" };
55
+ import compactionTurnPrefixPrompt from "./prompts/compaction-turn-prefix.md" with { type: "text" };
56
+ import compactionUpdateSummaryPrompt from "./prompts/compaction-update-summary.md" with { type: "text" };
57
+ import handoffDocumentPrompt from "./prompts/handoff-document.md" with { type: "text" };
58
+ import snapcompactArchiveContextPrompt from "./prompts/snapcompact-archive-context.md" with { type: "text" };
59
+
60
+ import {
61
+ computeFileLists,
62
+ createFileOps,
63
+ extractFileOpsFromMessage,
64
+ type FileOperations,
65
+ SUMMARIZATION_SYSTEM_PROMPT,
66
+ serializeConversation,
67
+ stripReadSelector,
68
+ upsertFileOperations,
69
+ } from "./utils";
70
+
71
+ // ============================================================================
72
+ // File Operation Tracking
73
+ // ============================================================================
74
+
75
+ /** Details stored in CompactionEntry.details for file tracking */
76
+ export interface CompactionDetails {
77
+ readFiles: string[];
78
+ modifiedFiles: string[];
79
+ }
80
+
81
+ /**
82
+ * Extract file operations from messages and previous compaction entries.
83
+ */
84
+ function extractFileOperations(
85
+ messages: AgentMessage[],
86
+ entries: SessionEntry[],
87
+ prevCompactionIndex: number,
88
+ ): FileOperations {
89
+ const fileOps = createFileOps();
90
+
91
+ // Collect from previous compaction's details (if pi-generated)
92
+ if (prevCompactionIndex >= 0) {
93
+ const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
94
+ if (!prevCompaction.fromExtension && prevCompaction.details) {
95
+ const details = prevCompaction.details as CompactionDetails;
96
+ if (Array.isArray(details.readFiles)) {
97
+ for (const f of details.readFiles) fileOps.read.add(stripReadSelector(f));
98
+ }
99
+ if (Array.isArray(details.modifiedFiles)) {
100
+ for (const f of details.modifiedFiles) fileOps.edited.add(f);
101
+ }
102
+ }
103
+ }
104
+
105
+ // Extract from tool calls in messages
106
+ for (const msg of messages) {
107
+ extractFileOpsFromMessage(msg, fileOps);
108
+ }
109
+
110
+ return fileOps;
111
+ }
112
+
113
+ // ============================================================================
114
+ // Message Extraction
115
+ // ============================================================================
116
+
117
+ /**
118
+ * Extract AgentMessage from an entry if it produces one.
119
+ * Returns undefined for entries that don't contribute to LLM context.
120
+ */
121
+ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
122
+ if (entry.type === "message") {
123
+ return entry.message;
124
+ }
125
+ if (entry.type === "custom_message") {
126
+ return createCustomMessage(
127
+ entry.customType,
128
+ entry.content,
129
+ entry.display,
130
+ entry.details,
131
+ entry.timestamp,
132
+ entry.attribution,
133
+ );
134
+ }
135
+ if (entry.type === "branch_summary") {
136
+ return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
137
+ }
138
+ return undefined;
139
+ }
140
+
141
+ /** Result from compact() - SessionManager adds uuid/parentUuid when saving */
142
+ export interface CompactionResult<T = unknown> {
143
+ summary: string;
144
+ /** Short PR-style summary for display purposes. */
145
+ shortSummary?: string;
146
+ firstKeptEntryId: string;
147
+ tokensBefore: number;
148
+ /** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
149
+ details?: T;
150
+ /** Hook-provided data to persist alongside compaction entry. */
151
+ preserveData?: Record<string, unknown>;
152
+ }
153
+
154
+ // ============================================================================
155
+ // Types
156
+ // ============================================================================
157
+
158
+ export interface CompactionSettings {
159
+ enabled: boolean;
160
+ strategy?: "context-full" | "handoff" | "shake" | "snapcompact" | "off";
161
+ thresholdPercent?: number;
162
+ thresholdTokens?: number;
163
+ midTurnEnabled?: boolean;
164
+ /**
165
+ * Tokens reserved below the context window for the next prompt + response.
166
+ *
167
+ * Leave unset to use {@link DEFAULT_RESERVE_TOKENS}; the unset state is the
168
+ * provenance signal that lets small-window recovery replace the default with
169
+ * a proportional reserve (see {@link resolveBudgetReserveTokens}). An
170
+ * explicit value — even one equal to the default — is always honored.
171
+ */
172
+ reserveTokens?: number;
173
+ keepRecentTokens: number;
174
+ autoContinue?: boolean;
175
+ remoteEnabled?: boolean;
176
+ remoteEndpoint?: string;
177
+ remoteStreamingV2Enabled?: boolean;
178
+ v2RetainedMessageBudget?: number;
179
+ }
180
+
181
+ /** Reserve applied when {@link CompactionSettings.reserveTokens} is unset. */
182
+ export const DEFAULT_RESERVE_TOKENS = 16384;
183
+
184
+ // reserveTokens is deliberately absent: an unset reserve is what marks it as
185
+ // defaulted, which resolveBudgetReserveTokens needs to distinguish "user never
186
+ // chose a reserve" from "user explicitly configured the default value".
187
+ export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
188
+ enabled: true,
189
+ strategy: "context-full",
190
+ thresholdPercent: -1,
191
+ thresholdTokens: -1,
192
+ midTurnEnabled: true,
193
+ keepRecentTokens: 20000,
194
+ autoContinue: true,
195
+ remoteEnabled: true,
196
+ remoteStreamingV2Enabled: true,
197
+ v2RetainedMessageBudget: V2_RETAINED_MESSAGE_TOKEN_BUDGET,
198
+ };
199
+
200
+ // ============================================================================
201
+ // Token calculation
202
+ // ============================================================================
203
+
204
+ /**
205
+ * Calculate total context tokens from usage.
206
+ * Uses the native totalTokens field when available, falls back to computing from components.
207
+ */
208
+ export function calculateContextTokens(usage: Usage): number {
209
+ return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
210
+ }
211
+
212
+ export function calculatePromptTokens(usage: Usage): number {
213
+ const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
214
+ if (promptTokens > 0) {
215
+ return promptTokens;
216
+ }
217
+ return calculateContextTokens(usage);
218
+ }
219
+
220
+ /**
221
+ * Get usage from an assistant message if available.
222
+ * Skips aborted and error messages as they don't have valid usage data.
223
+ */
224
+ function getAssistantUsage(msg: AgentMessage): Usage | undefined {
225
+ if (msg.role === "assistant" && "usage" in msg) {
226
+ const assistantMsg = msg as AssistantMessage;
227
+ if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
228
+ return assistantMsg.usage;
229
+ }
230
+ }
231
+ return undefined;
232
+ }
233
+
234
+ /**
235
+ * Find the last non-aborted assistant message usage from session entries.
236
+ */
237
+ export function getLastAssistantUsage(entries: SessionEntry[]): Usage | undefined {
238
+ for (let i = entries.length - 1; i >= 0; i--) {
239
+ const entry = entries[i];
240
+ if (entry.type === "message") {
241
+ const usage = getAssistantUsage(entry.message);
242
+ if (usage) return usage;
243
+ }
244
+ }
245
+ return undefined;
246
+ }
247
+
248
+ /**
249
+ * Effective reserve: at least 15% of context window or the configured floor
250
+ * (defaulting to {@link DEFAULT_RESERVE_TOKENS} when unset), whichever is larger.
251
+ */
252
+ export function effectiveReserveTokens(contextWindow: number, settings: CompactionSettings): number {
253
+ return Math.max(Math.floor(contextWindow * 0.15), settings.reserveTokens ?? DEFAULT_RESERVE_TOKENS);
254
+ }
255
+
256
+ /**
257
+ * Reserve used when deciding whether a prompt still fits inside the model window.
258
+ *
259
+ * The default absolute reserve predates small bundled windows and can leave no
260
+ * practical budget there; recover a DEFAULTED reserve that is impossible for
261
+ * the window with the 15% proportional reserve (clamped to >= 1 so the derived
262
+ * threshold stays strictly below the window even for tiny test windows).
263
+ * Explicit valid reserves — including one that happens to equal the default —
264
+ * still win, because they intentionally shrink the usable prompt budget;
265
+ * provenance is carried by `settings.reserveTokens` being unset, never by
266
+ * comparing values against the default.
267
+ */
268
+ export function resolveBudgetReserveTokens(contextWindow: number, settings: CompactionSettings): number {
269
+ const reserveTokens = effectiveReserveTokens(contextWindow, settings);
270
+ const proportionalReserveTokens = Math.max(1, Math.floor(contextWindow * 0.15));
271
+ const reserveWasDefaulted = settings.reserveTokens === undefined;
272
+ const defaultReserveIsEffectivelyImpossible =
273
+ reserveWasDefaulted && reserveTokens >= contextWindow - proportionalReserveTokens;
274
+ const reserveExceedsWindow = reserveTokens >= contextWindow;
275
+
276
+ return defaultReserveIsEffectivelyImpossible || reserveExceedsWindow ? proportionalReserveTokens : reserveTokens;
277
+ }
278
+
279
+ /**
280
+ * Check if compaction should trigger based on context usage.
281
+ */
282
+ export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
283
+ if (!settings.enabled || settings.strategy === "off" || contextWindow <= 0) return false;
284
+ const thresholdTokens = resolveThresholdTokens(contextWindow, settings);
285
+ return contextTokens > thresholdTokens;
286
+ }
287
+
288
+ /**
289
+ * Context tokens to feed the compaction decision, floored by a local estimate of
290
+ * the stored conversation.
291
+ *
292
+ * The provider-reported usage is normally ground truth, but a
293
+ * `before_provider_request` payload transform — a compression extension (e.g.
294
+ * Headroom), an obfuscator, or inline snapcompact — can shrink the request below
295
+ * the real stored conversation. The provider then reports deflated prompt
296
+ * tokens, so anchoring compaction purely on that usage lets the real history
297
+ * grow unbounded until it overflows and native compaction can no longer run.
298
+ * Flooring by the agent's own estimate of the stored conversation keeps the
299
+ * compaction trigger honest regardless of on-wire compression. (Display/cost
300
+ * accounting still uses the exact provider usage; only the compaction decision
301
+ * takes the floor.)
302
+ */
303
+ export function compactionContextTokens(providerContextTokens: number, storedConversationEstimate: number): number {
304
+ return Math.max(Math.max(0, providerContextTokens), Math.max(0, storedConversationEstimate));
305
+ }
306
+
307
+ export function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings): number {
308
+ // Fixed token limit takes priority over percentage
309
+ const thresholdTokens = settings.thresholdTokens;
310
+ if (typeof thresholdTokens === "number" && Number.isFinite(thresholdTokens) && thresholdTokens > 0) {
311
+ // Clamp to [1, contextWindow - 1] so there's always room
312
+ return Math.min(contextWindow - 1, Math.max(1, thresholdTokens));
313
+ }
314
+
315
+ // Percentage-based threshold. The default absolute reserve can exceed bundled
316
+ // small-context windows, or nearly consume a 16k-class window; in those
317
+ // known-impossible default configurations, fall back to the proportional
318
+ // reserve so threshold/recovery-band checks stay usable. Explicit valid
319
+ // configured reserves still define the usable prompt budget. Cap at
320
+ // contextWindow - 1 (matching the fixed-token clamp above) so the threshold
321
+ // never reaches the whole window even when the reserve resolves to 0.
322
+ const thresholdPercent = settings.thresholdPercent;
323
+ if (typeof thresholdPercent !== "number" || !Number.isFinite(thresholdPercent) || thresholdPercent <= 0) {
324
+ return Math.max(
325
+ 0,
326
+ Math.min(contextWindow - 1, contextWindow - resolveBudgetReserveTokens(contextWindow, settings)),
327
+ );
328
+ }
329
+ const clampedThresholdPercent = Math.min(99, Math.max(1, thresholdPercent));
330
+ return Math.floor(contextWindow * (clampedThresholdPercent / 100));
331
+ }
332
+
333
+ // ============================================================================
334
+ // Cut point detection
335
+ // ============================================================================
336
+
337
+ /**
338
+ * Image content has no tokenizer representation; charge a fixed estimate
339
+ * matching what providers typically bill for inline images.
340
+ */
341
+ const IMAGE_TOKEN_ESTIMATE = 1200;
342
+
343
+ /**
344
+ * Estimate token count for a message using cl100k_base via the native
345
+ * tokenizer. This is not Claude's first-party tokenizer (Anthropic doesn't
346
+ * publish one) but is within ~5–10% across English/code text.
347
+ *
348
+ * `excludeEncryptedReasoning` drops opaque provider reasoning payloads
349
+ * (`thinkingSignature`, `redactedThinking`) from the estimate. Those are billed
350
+ * by the provider on replay, so the default counts them — but their *local*
351
+ * byte size can diverge wildly from what the provider charges, so the
352
+ * compaction floor (which only needs the reliably-countable, on-wire-compressible
353
+ * content) excludes them to avoid false triggers on thinking-heavy turns.
354
+ */
355
+ export function estimateTokens(message: AgentMessage, options?: { excludeEncryptedReasoning?: boolean }): number {
356
+ const fragments: string[] = [];
357
+ let extra = 0;
358
+ if ((message as { role?: string }).role === "bashExecution") {
359
+ const bash = message as { command?: unknown; output?: unknown };
360
+ if (typeof bash.command === "string") fragments.push(bash.command);
361
+ if (typeof bash.output === "string") fragments.push(bash.output);
362
+ return fragments.length === 0 ? 0 : countTokens(fragments);
363
+ }
364
+
365
+ switch (message.role) {
366
+ case "user": {
367
+ const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
368
+ if (typeof content === "string") {
369
+ fragments.push(content);
370
+ } else if (Array.isArray(content)) {
371
+ for (const block of content) {
372
+ if (block.type === "text" && block.text) {
373
+ fragments.push(block.text);
374
+ }
375
+ }
376
+ }
377
+ break;
378
+ }
379
+ case "assistant": {
380
+ const assistant = message as AssistantMessage;
381
+ for (const block of assistant.content) {
382
+ if (block.type === "text") {
383
+ fragments.push(block.text);
384
+ } else if (block.type === "thinking") {
385
+ fragments.push(block.thinking);
386
+ // Providers charge for the opaque signature/reasoning payload that
387
+ // rides alongside the thinking text (OpenAI Responses encrypted
388
+ // reasoning items, Anthropic signed thinking blocks, etc.). Without
389
+ // counting it, this estimator can read ~half of the provider-reported
390
+ // usage on thinking-heavy turns — see #2275 for the resulting
391
+ // compaction-trigger / post-check metric divergence. The compaction
392
+ // floor excludes it (its local byte size diverges from provider billing).
393
+ if (block.thinkingSignature && !options?.excludeEncryptedReasoning) {
394
+ fragments.push(block.thinkingSignature);
395
+ }
396
+ } else if (block.type === "toolCall") {
397
+ fragments.push(block.name);
398
+ fragments.push(JSON.stringify(block.arguments));
399
+ } else if (block.type === "redactedThinking") {
400
+ // Encrypted reasoning blob the provider still bills for on replay;
401
+ // excluded from the compaction floor for the same reason as above.
402
+ if (!options?.excludeEncryptedReasoning) fragments.push(block.data);
403
+ }
404
+ }
405
+ break;
406
+ }
407
+ case "hookMessage":
408
+ case "toolResult": {
409
+ if (typeof message.content === "string") {
410
+ fragments.push(message.content);
411
+ } else {
412
+ for (const block of message.content) {
413
+ if (block.type === "text" && block.text) {
414
+ fragments.push(block.text);
415
+ } else if (block.type === "image") {
416
+ extra += IMAGE_TOKEN_ESTIMATE;
417
+ }
418
+ }
419
+ }
420
+ break;
421
+ }
422
+ case "branchSummary":
423
+ case "compactionSummary": {
424
+ fragments.push(message.summary);
425
+ if (message.role === "compactionSummary") {
426
+ if (message.blocks) {
427
+ for (const block of message.blocks) {
428
+ if (block.type === "text") fragments.push(block.text);
429
+ else extra += snapcompact.FRAME_TOKEN_ESTIMATE;
430
+ }
431
+ } else if (message.images) {
432
+ // Snapcompact frames render at ≥1568px; providers bill the downscaled cap.
433
+ extra += message.images.length * snapcompact.FRAME_TOKEN_ESTIMATE;
434
+ }
435
+ }
436
+ break;
437
+ }
438
+ default:
439
+ return 0;
440
+ }
441
+
442
+ if (fragments.length === 0) return extra;
443
+ return extra + countTokens(fragments);
444
+ }
445
+
446
+ function estimateEntriesTokens(entries: SessionEntry[], startIndex: number, endIndex: number): number {
447
+ let total = 0;
448
+ for (let i = startIndex; i < endIndex; i++) {
449
+ const msg = getMessageFromEntry(entries[i]);
450
+ if (msg) {
451
+ total += estimateTokens(msg);
452
+ }
453
+ }
454
+ return total;
455
+ }
456
+
457
+ /**
458
+ * Find valid cut points: indices of user, assistant, custom, or bashExecution messages.
459
+ * Never cut at tool results (they must follow their tool call).
460
+ * When we cut at an assistant message with tool calls, its tool results follow it
461
+ * and will be kept.
462
+ * BashExecutionMessage is treated like a user message (user-initiated context).
463
+ */
464
+ function findValidCutPoints(entries: SessionEntry[], startIndex: number, endIndex: number): number[] {
465
+ const cutPoints: number[] = [];
466
+ for (let i = startIndex; i < endIndex; i++) {
467
+ const entry = entries[i];
468
+ switch (entry.type) {
469
+ case "message": {
470
+ const role = entry.message.role as string;
471
+ switch (role) {
472
+ case "bashExecution":
473
+ case "hookMessage":
474
+ case "branchSummary":
475
+ case "compactionSummary":
476
+ case "user":
477
+ case "assistant":
478
+ cutPoints.push(i);
479
+ break;
480
+ case "toolResult":
481
+ break;
482
+ }
483
+ break;
484
+ }
485
+ case "thinking_level_change":
486
+ case "model_change":
487
+ case "compaction":
488
+ case "branch_summary":
489
+ case "custom":
490
+ case "custom_message":
491
+ case "label":
492
+ }
493
+ // branch_summary and custom_message are user-role messages, valid cut points
494
+ if (entry.type === "branch_summary" || entry.type === "custom_message") {
495
+ cutPoints.push(i);
496
+ }
497
+ }
498
+ return cutPoints;
499
+ }
500
+
501
+ /**
502
+ * Find the user message (or bashExecution) that starts the turn containing the given entry index.
503
+ * Returns -1 if no turn start found before the index.
504
+ * BashExecutionMessage is treated like a user message for turn boundaries.
505
+ */
506
+ export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number {
507
+ for (let i = entryIndex; i >= startIndex; i--) {
508
+ const entry = entries[i];
509
+ // branch_summary and custom_message are user-role messages, can start a turn
510
+ if (entry.type === "branch_summary" || entry.type === "custom_message") {
511
+ return i;
512
+ }
513
+ if (entry.type === "message") {
514
+ const role = entry.message.role as string;
515
+ if (role === "user" || role === "bashExecution") {
516
+ return i;
517
+ }
518
+ }
519
+ }
520
+ return -1;
521
+ }
522
+
523
+ export interface CutPointResult {
524
+ /** Index of first entry to keep */
525
+ firstKeptEntryIndex: number;
526
+ /** Index of user message that starts the turn being split, or -1 if not splitting */
527
+ turnStartIndex: number;
528
+ /** Whether this cut splits a turn (cut point is not a user message) */
529
+ isSplitTurn: boolean;
530
+ }
531
+
532
+ /**
533
+ * Find the cut point in session entries that keeps approximately `keepRecentTokens`.
534
+ *
535
+ * Algorithm: Walk backwards from newest, accumulating estimated message sizes.
536
+ * Stop when we've accumulated >= keepRecentTokens. Cut at that point.
537
+ *
538
+ * Can cut at user OR assistant messages (never tool results). When cutting at an
539
+ * assistant message with tool calls, its tool results come after and will be kept.
540
+ *
541
+ * Returns CutPointResult with:
542
+ * - firstKeptEntryIndex: the entry index to start keeping from
543
+ * - turnStartIndex: if cutting mid-turn, the user message that started that turn
544
+ * - isSplitTurn: whether we're cutting in the middle of a turn
545
+ *
546
+ * Only considers entries between `startIndex` and `endIndex` (exclusive).
547
+ */
548
+ export function findCutPoint(
549
+ entries: SessionEntry[],
550
+ startIndex: number,
551
+ endIndex: number,
552
+ keepRecentTokens: number,
553
+ ): CutPointResult {
554
+ const cutPoints = findValidCutPoints(entries, startIndex, endIndex);
555
+
556
+ if (cutPoints.length === 0) {
557
+ return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
558
+ }
559
+
560
+ // Walk backwards from newest, accumulating estimated message sizes
561
+ let accumulatedTokens = 0;
562
+ let cutIndex = cutPoints[0]; // Default: keep from first message (not header)
563
+
564
+ for (let i = endIndex - 1; i >= startIndex; i--) {
565
+ const entry = entries[i];
566
+ if (entry.type !== "message") continue;
567
+
568
+ // Estimate this message's size
569
+ const messageTokens = estimateTokens(entry.message);
570
+ accumulatedTokens += messageTokens;
571
+
572
+ // Check if we've exceeded the budget
573
+ if (accumulatedTokens >= keepRecentTokens) {
574
+ // Find the closest valid cut point at or after this entry
575
+ for (let c = 0; c < cutPoints.length; c++) {
576
+ if (cutPoints[c] >= i) {
577
+ cutIndex = cutPoints[c];
578
+ break;
579
+ }
580
+ }
581
+ break;
582
+ }
583
+ }
584
+
585
+ // Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.)
586
+ while (cutIndex > startIndex) {
587
+ const prevEntry = entries[cutIndex - 1];
588
+ // Stop at session header or compaction boundaries
589
+ if (prevEntry.type === "compaction") {
590
+ break;
591
+ }
592
+ if (prevEntry.type === "message") {
593
+ // Stop if we hit any message
594
+ break;
595
+ }
596
+ // Include this non-message entry (bash, settings change, etc.)
597
+ cutIndex--;
598
+ }
599
+
600
+ // Determine if this is a split turn
601
+ const cutEntry = entries[cutIndex];
602
+ const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
603
+ const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
604
+
605
+ return {
606
+ firstKeptEntryIndex: cutIndex,
607
+ turnStartIndex,
608
+ isSplitTurn: !isUserMessage && turnStartIndex !== -1,
609
+ };
610
+ }
611
+
612
+ // ============================================================================
613
+ // Summarization
614
+ // ============================================================================
615
+
616
+ const SUMMARIZATION_PROMPT = prompt.render(compactionSummaryPrompt);
617
+
618
+ const UPDATE_SUMMARIZATION_PROMPT = prompt.render(compactionUpdateSummaryPrompt);
619
+
620
+ const SHORT_SUMMARY_PROMPT = prompt.render(compactionShortSummaryPrompt);
621
+
622
+ const HANDOFF_DOCUMENT_PROMPT = prompt.render(handoffDocumentPrompt);
623
+
624
+ export const AUTO_HANDOFF_THRESHOLD_FOCUS = prompt.render(autoHandoffThresholdFocusPrompt);
625
+
626
+ function formatAdditionalContext(context: string[] | undefined): string {
627
+ if (!context || context.length === 0) return "";
628
+ const lines = context.map(line => `- ${line}`).join("\n");
629
+ return `<additional-context>\n${lines}\n</additional-context>\n\n`;
630
+ }
631
+
632
+ /**
633
+ * Maps the non-special `ThinkingLevel` values to their `Effort` counterparts.
634
+ * Exhaustive over the union; throws for `Off`/`Inherit` to surface logic
635
+ * errors in callers that forgot to filter those out. Never use a TS cast for
636
+ * this — `ThinkingLevel` is a string-union over distinct concepts (Off /
637
+ * Inherit are not Efforts), and a cast hides the contract.
638
+ */
639
+ function effortFromThinkingLevel(level: ThinkingLevel): Effort {
640
+ switch (level) {
641
+ case ThinkingLevel.Minimal:
642
+ return Effort.Minimal;
643
+ case ThinkingLevel.Low:
644
+ return Effort.Low;
645
+ case ThinkingLevel.Medium:
646
+ return Effort.Medium;
647
+ case ThinkingLevel.High:
648
+ return Effort.High;
649
+ case ThinkingLevel.XHigh:
650
+ return Effort.XHigh;
651
+ case ThinkingLevel.Off:
652
+ case ThinkingLevel.Inherit:
653
+ throw new Error(`effortFromThinkingLevel: ${level} must be handled by caller`);
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Resolves the reasoning effort to send on a compaction LLM call.
659
+ *
660
+ * - Explicit `Off` → `undefined` (omit reasoning entirely; the user said no thinking).
661
+ * - `undefined` / `Inherit` → historical `Effort.High` default → clamped per model
662
+ * (preserves current behavior for users who never touched the dial).
663
+ * - Explicit effort → respect user choice → clamped per model.
664
+ *
665
+ * The clamp routes through `clampThinkingLevelForModel`, which returns
666
+ * `undefined` for reasoning models without a thinking config — the build-time
667
+ * encoding of `compat.supportsReasoningEffort: false` (e.g.
668
+ * `xai-oauth/grok-build`). That `undefined` then flows through to the
669
+ * openai-responses mapper, which omits the wire param — no
670
+ * `requireSupportedEffort` throw.
671
+ */
672
+ function resolveCompactionEffort(model: Model, level: ThinkingLevel | undefined): Effort | undefined {
673
+ if (level === ThinkingLevel.Off) return undefined;
674
+ const requested: Effort =
675
+ level === undefined || level === ThinkingLevel.Inherit ? Effort.High : effortFromThinkingLevel(level);
676
+ return clampThinkingLevelForModel(model, requested);
677
+ }
678
+
679
+ /**
680
+ * Build the error thrown when an LLM summarization call ends with
681
+ * `stopReason === "error"`. Carries the provider's HTTP `errorStatus`
682
+ * onto a top-level `.status` field so callers (notably
683
+ * `AgentSession.#isCompactionAuthFailure`) can branch on 401/403 without
684
+ * regex-scraping `error.message`. The `auth_unavailable` synthetic
685
+ * (pi-native gateway) does not populate `errorStatus`, hence the legacy
686
+ * message-based check is still required upstream — see issue #986.
687
+ */
688
+ function createSummarizationError(prefix: string, response: AssistantMessage): Error {
689
+ const text = `${prefix}: ${response.errorMessage || "Unknown error"}`;
690
+ return response.errorStatus === undefined ? new Error(text) : new ProviderHttpError(text, response.errorStatus);
691
+ }
692
+
693
+ /**
694
+ * Generate a summary of the conversation using the LLM.
695
+ * If previousSummary is provided, uses the update prompt to merge.
696
+ */
697
+ export interface SummaryOptions {
698
+ promptOverride?: string;
699
+ extraContext?: string[];
700
+ remoteEndpoint?: string;
701
+ remoteInstructions?: string;
702
+ initiatorOverride?: MessageAttribution;
703
+ metadata?: Record<string, unknown>;
704
+ convertToLlm?: ConvertToLlm;
705
+ /**
706
+ * Optional telemetry handle. When provided, every LLM call emitted during
707
+ * compaction is wrapped in an OTEL chat span tagged with
708
+ * `pi.gen_ai.oneshot.kind` (`compaction_summary`, `compaction_short_summary`,
709
+ * or `compaction_turn_prefix`). `undefined` keeps the call paths zero-cost.
710
+ */
711
+ telemetry?: AgentTelemetry;
712
+ /**
713
+ * Active session thinking level. Threaded from `agent-session.ts` so
714
+ * compaction honors the user's `/model` thinking selection instead of
715
+ * silently overriding it with `Effort.High` (the historical default).
716
+ * `undefined` / `ThinkingLevel.Inherit` falls back to that historical
717
+ * default; `ThinkingLevel.Off` omits reasoning entirely. See
718
+ * `resolveCompactionEffort` for the conversion contract.
719
+ */
720
+ thinkingLevel?: ThinkingLevel;
721
+ /** Session routing key for remote compaction transports with sticky provider sessions. */
722
+ sessionId?: string;
723
+ /** Prompt-cache key for remote compaction transports that support provider prefix caching. */
724
+ promptCacheKey?: string;
725
+ /** Provider-visible tools for remote compaction transports that replay native tool history. */
726
+ tools?: Tool[];
727
+ /** Optional fetch implementation threaded into remote compaction calls. */
728
+ fetch?: FetchImpl;
729
+ /**
730
+ * Optional completion transport override for host-level request wrappers
731
+ * (e.g. the coding-agent provider-concurrency limiter). When provided,
732
+ * every local summarization oneshot (`generateSummary`,
733
+ * `generateTurnPrefixSummary`, `generateShortSummary`) routes through it
734
+ * instead of the default `completeSimple`, so cap policies enforced on
735
+ * the live agent turn also bracket compaction HTTP requests.
736
+ */
737
+ completeImpl?: <TApi extends Api>(
738
+ model: Model<TApi>,
739
+ ctx: Context,
740
+ options: SimpleStreamOptions,
741
+ ) => Promise<AssistantMessage>;
742
+ }
743
+
744
+ function formatPreviousSnapcompactArchive(archiveText: string): string {
745
+ return prompt.render(snapcompactArchiveContextPrompt, { archiveText });
746
+ }
747
+
748
+ function mergePreviousSummaryWithSnapcompactArchive(
749
+ previousSummary: string | undefined,
750
+ archiveText: string | undefined,
751
+ ): string | undefined {
752
+ if (!archiveText) return previousSummary;
753
+ const archiveSummary = formatPreviousSnapcompactArchive(archiveText);
754
+ return previousSummary ? `${previousSummary}\n\n${archiveSummary}` : archiveSummary;
755
+ }
756
+
757
+ function createSnapcompactArchiveMigrationMessage(archiveText: string): Message {
758
+ return {
759
+ role: "user",
760
+ content: [{ type: "text", text: formatPreviousSnapcompactArchive(archiveText) }],
761
+ timestamp: Date.now(),
762
+ };
763
+ }
764
+
765
+ export async function generateSummary(
766
+ currentMessages: AgentMessage[],
767
+ model: Model,
768
+ reserveTokens: number,
769
+ apiKey: ApiKey,
770
+ signal?: AbortSignal,
771
+ customInstructions?: string,
772
+ previousSummary?: string,
773
+ options?: SummaryOptions,
774
+ ): Promise<string> {
775
+ const maxTokens = Math.floor(0.8 * reserveTokens);
776
+
777
+ // Use update prompt if we have a previous summary, otherwise initial prompt
778
+ let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
779
+ if (options?.promptOverride) {
780
+ basePrompt = options.promptOverride;
781
+ }
782
+ if (customInstructions) {
783
+ basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
784
+ }
785
+
786
+ // Serialize conversation to text so model doesn't try to continue it
787
+ // Convert to LLM messages first (handles custom app messages when caller provides a transformer).
788
+ const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(currentMessages);
789
+ const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
790
+
791
+ // Build the prompt with conversation wrapped in tags
792
+ let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
793
+ if (previousSummary) {
794
+ promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
795
+ }
796
+ promptText += formatAdditionalContext(options?.extraContext);
797
+ promptText += basePrompt;
798
+
799
+ const summarizationMessages = [
800
+ {
801
+ role: "user" as const,
802
+ content: [{ type: "text" as const, text: promptText }],
803
+ timestamp: Date.now(),
804
+ },
805
+ ];
806
+
807
+ if (options?.remoteEndpoint) {
808
+ const remote = await requestRemoteCompaction(
809
+ options.remoteEndpoint,
810
+ {
811
+ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
812
+ prompt: promptText,
813
+ },
814
+ signal,
815
+ { fetch: options.fetch },
816
+ );
817
+ return remote.summary;
818
+ }
819
+
820
+ const response = await instrumentedCompleteSimple(
821
+ model,
822
+ { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages },
823
+ {
824
+ maxTokens,
825
+ signal,
826
+ apiKey,
827
+ reasoning: resolveCompactionEffort(model, options?.thinkingLevel),
828
+ initiatorOverride: options?.initiatorOverride,
829
+ metadata: options?.metadata,
830
+ },
831
+ { telemetry: options?.telemetry, oneshotKind: "compaction_summary", completeImpl: options?.completeImpl },
832
+ );
833
+
834
+ if (response.stopReason === "error") {
835
+ throw createSummarizationError("Summarization failed", response);
836
+ }
837
+
838
+ const textContent = response.content
839
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
840
+ .map(c => c.text)
841
+ .join("\n");
842
+
843
+ return textContent;
844
+ }
845
+
846
+ // ============================================================================
847
+ // Handoff generation
848
+ // ============================================================================
849
+
850
+ export interface HandoffOptions {
851
+ /** Live agent system prompt — passed verbatim so providers hit the cached prefix. */
852
+ systemPrompt: string[];
853
+ /** Live agent tool list — same purpose. Forced to `toolChoice: "none"`. */
854
+ tools?: Tool[];
855
+ customInstructions?: string;
856
+ convertToLlm?: ConvertToLlm;
857
+ initiatorOverride?: MessageAttribution;
858
+ metadata?: Record<string, unknown>;
859
+ /**
860
+ * Optional telemetry handle. When provided, the handoff LLM call is
861
+ * wrapped in an OTEL chat span tagged with `pi.gen_ai.oneshot.kind = "handoff"`.
862
+ */
863
+ telemetry?: AgentTelemetry;
864
+ /**
865
+ * Active session thinking level. Threaded from `agent-session.ts` so
866
+ * handoff generation honors the user's `/model` thinking selection
867
+ * instead of silently overriding it with `Effort.High`. See
868
+ * `resolveCompactionEffort` for the conversion contract.
869
+ */
870
+ thinkingLevel?: ThinkingLevel;
871
+ }
872
+
873
+ export function renderHandoffPrompt(customInstructions?: string): string {
874
+ if (!customInstructions) return HANDOFF_DOCUMENT_PROMPT;
875
+ return prompt.render(handoffDocumentPrompt, {
876
+ additionalFocus: customInstructions,
877
+ });
878
+ }
879
+
880
+ export interface HandoffFromContextOptions {
881
+ /**
882
+ * Stream options mirrored from the live agent turn: `apiKey`, `signal`, the
883
+ * `sessionId`/`promptCacheKey` cache-routing pair, `serviceTier`, and the
884
+ * session's payload/response hooks. Sending the same routing + payload shape
885
+ * the main loop uses is what lets the handoff oneshot READ the provider
886
+ * prompt cache the live turn populated instead of cold-missing the whole
887
+ * prefix. `reasoning` and `toolChoice` are set internally and override
888
+ * anything provided here.
889
+ */
890
+ streamOptions: SimpleStreamOptions;
891
+ /** Optional completion transport override for host-level request wrappers. */
892
+ completeImpl?: <TApi extends Api>(
893
+ model: Model<TApi>,
894
+ ctx: Context,
895
+ options: SimpleStreamOptions,
896
+ ) => Promise<AssistantMessage>;
897
+ /** See {@link HandoffOptions.telemetry}. */
898
+ telemetry?: AgentTelemetry;
899
+ /** See {@link HandoffOptions.thinkingLevel}. */
900
+ thinkingLevel?: ThinkingLevel;
901
+ }
902
+
903
+ /**
904
+ * Run the handoff oneshot against a fully-built provider {@link Context}.
905
+ *
906
+ * The caller assembles `context` exactly like a live agent turn — same system
907
+ * prompt, normalized tools, transformed + obfuscated message history, with the
908
+ * trailing handoff-prompt message already appended — and supplies
909
+ * `streamOptions` that mirror the live turn's cache routing. That keeps the
910
+ * cache-preserving context construction in the host (which owns the transform
911
+ * pipeline) while this function centralizes the handoff request contract:
912
+ * `toolChoice: "none"`, clamped reasoning effort, oneshot telemetry, text-only
913
+ * extraction, and provider-error mapping.
914
+ */
915
+ export async function generateHandoffFromContext(
916
+ context: Context,
917
+ model: Model,
918
+ options: HandoffFromContextOptions,
919
+ ): Promise<string> {
920
+ const response = await instrumentedCompleteSimple(
921
+ model,
922
+ context,
923
+ {
924
+ ...options.streamOptions,
925
+ reasoning: resolveCompactionEffort(model, options.thinkingLevel),
926
+ toolChoice: "none",
927
+ },
928
+ { telemetry: options.telemetry, oneshotKind: "handoff", completeImpl: options.completeImpl },
929
+ );
930
+
931
+ if (response.stopReason === "error") {
932
+ throw createSummarizationError("Handoff generation failed", response);
933
+ }
934
+
935
+ return response.content
936
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
937
+ .map(c => c.text)
938
+ .join("\n");
939
+ }
940
+
941
+ export async function generateHandoff(
942
+ messages: AgentMessage[],
943
+ model: Model,
944
+ apiKey: ApiKey,
945
+ options: HandoffOptions,
946
+ signal?: AbortSignal,
947
+ ): Promise<string> {
948
+ const llmMessages = (options.convertToLlm ?? defaultConvertToLlm)(messages);
949
+ const requestMessages: Message[] = [
950
+ ...llmMessages,
951
+ {
952
+ role: "user",
953
+ content: [{ type: "text", text: renderHandoffPrompt(options.customInstructions) }],
954
+ attribution: "agent",
955
+ timestamp: Date.now(),
956
+ },
957
+ ];
958
+
959
+ return generateHandoffFromContext(
960
+ { systemPrompt: options.systemPrompt, messages: requestMessages, tools: options.tools },
961
+ model,
962
+ {
963
+ streamOptions: {
964
+ apiKey,
965
+ signal,
966
+ initiatorOverride: options.initiatorOverride,
967
+ metadata: options.metadata,
968
+ },
969
+ telemetry: options.telemetry,
970
+ thinkingLevel: options.thinkingLevel,
971
+ },
972
+ );
973
+ }
974
+
975
+ async function generateShortSummary(
976
+ recentMessages: AgentMessage[],
977
+ historySummary: string | undefined,
978
+ model: Model,
979
+ reserveTokens: number,
980
+ apiKey: ApiKey,
981
+ signal?: AbortSignal,
982
+ options?: SummaryOptions,
983
+ ): Promise<string> {
984
+ const maxTokens = Math.min(512, Math.floor(0.2 * reserveTokens));
985
+ const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(recentMessages);
986
+ const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
987
+
988
+ let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
989
+ if (historySummary) {
990
+ promptText += `<previous-summary>\n${historySummary}\n</previous-summary>\n\n`;
991
+ }
992
+ promptText += formatAdditionalContext(options?.extraContext);
993
+ promptText += SHORT_SUMMARY_PROMPT;
994
+
995
+ if (options?.remoteEndpoint) {
996
+ const remote = await requestRemoteCompaction(
997
+ options.remoteEndpoint,
998
+ {
999
+ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
1000
+ prompt: promptText,
1001
+ },
1002
+ signal,
1003
+ { fetch: options?.fetch },
1004
+ );
1005
+ return remote.summary;
1006
+ }
1007
+
1008
+ const response = await instrumentedCompleteSimple(
1009
+ model,
1010
+ {
1011
+ systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT],
1012
+ messages: [{ role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() }],
1013
+ },
1014
+ {
1015
+ maxTokens,
1016
+ signal,
1017
+ apiKey,
1018
+ reasoning: resolveCompactionEffort(model, options?.thinkingLevel),
1019
+ initiatorOverride: options?.initiatorOverride,
1020
+ metadata: options?.metadata,
1021
+ },
1022
+ { telemetry: options?.telemetry, oneshotKind: "compaction_short_summary", completeImpl: options?.completeImpl },
1023
+ );
1024
+
1025
+ if (response.stopReason === "error") {
1026
+ throw createSummarizationError("Short summary failed", response);
1027
+ }
1028
+
1029
+ return response.content
1030
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
1031
+ .map(c => c.text)
1032
+ .join("\n");
1033
+ }
1034
+
1035
+ // ============================================================================
1036
+ // Compaction Preparation (for hooks)
1037
+ // ============================================================================
1038
+
1039
+ export interface CompactionPreparation {
1040
+ /** UUID of first entry to keep */
1041
+ firstKeptEntryId: string;
1042
+ /** Messages that will be summarized and discarded */
1043
+ messagesToSummarize: AgentMessage[];
1044
+ /** Messages that will be turned into turn prefix summary (if splitting) */
1045
+ turnPrefixMessages: AgentMessage[];
1046
+ /** Messages kept in full after compaction (recent history) */
1047
+ recentMessages: AgentMessage[];
1048
+ /** Whether this is a split turn (cut point in middle of turn) */
1049
+ isSplitTurn: boolean;
1050
+ tokensBefore: number;
1051
+ /** Summary from previous compaction, for iterative update */
1052
+ previousSummary?: string;
1053
+ /** Preserved opaque compaction payload from the previous compaction, if any. */
1054
+ previousPreserveData?: Record<string, unknown>;
1055
+ /** File operations extracted from messagesToSummarize */
1056
+ fileOps: FileOperations;
1057
+ /** Compaction settions from settings.jsonl */
1058
+ settings: CompactionSettings;
1059
+ }
1060
+
1061
+ /**
1062
+ * Whether a prior compaction's preserve data can be carried forward by the
1063
+ * upcoming compaction. A local compaction (no remote preserve) always can — it
1064
+ * holds a real textual summary. A remote compaction (V2 or V1) only can when
1065
+ * some candidate model shares its provider AND remote replay is still enabled;
1066
+ * otherwise its provider-native replay is dead weight and only the opaque
1067
+ * placeholder summary survives, so the caller must re-expand the originals.
1068
+ */
1069
+ function remotePreserveReusableByAny(
1070
+ preserveData: Record<string, unknown> | undefined,
1071
+ models: readonly Model[],
1072
+ settings: CompactionSettings,
1073
+ ): boolean {
1074
+ const remote = getCompactionV2PreserveData(preserveData) ?? getPreservedOpenAiRemoteCompactionData(preserveData);
1075
+ if (!remote) return true;
1076
+ if (settings.remoteEnabled === false) return false;
1077
+ for (const model of models) {
1078
+ if (remote.provider !== model.provider) continue;
1079
+ const v2Ok = settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(model);
1080
+ if (v2Ok || shouldUseOpenAiRemoteCompaction(model)) return true;
1081
+ }
1082
+ return false;
1083
+ }
1084
+
1085
+ export function prepareCompaction(
1086
+ pathEntries: SessionEntry[],
1087
+ settings: CompactionSettings,
1088
+ compactionModels: readonly Model[] = [],
1089
+ ): CompactionPreparation | undefined {
1090
+ if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
1091
+ return undefined;
1092
+ }
1093
+
1094
+ let prevCompactionIndex = -1;
1095
+ for (let i = pathEntries.length - 1; i >= 0; i--) {
1096
+ if (pathEntries[i].type !== "compaction") continue;
1097
+ // Skip a prior remote compaction (V2 or V1) whose provider-native replay
1098
+ // none of the upcoming compaction candidates can reuse: its summary is only
1099
+ // an opaque placeholder, so re-expand its original messages and summarize
1100
+ // them locally rather than stranding that history. compact() still reuses it
1101
+ // when a candidate can (same provider, remote enabled).
1102
+ const entry = pathEntries[i] as CompactionEntry;
1103
+ if (compactionModels.length > 0 && !remotePreserveReusableByAny(entry.preserveData, compactionModels, settings)) {
1104
+ continue;
1105
+ }
1106
+ prevCompactionIndex = i;
1107
+ break;
1108
+ }
1109
+ const boundaryStart = prevCompactionIndex + 1;
1110
+ const boundaryEnd = pathEntries.length;
1111
+
1112
+ const lastUsage = getLastAssistantUsage(pathEntries);
1113
+ const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
1114
+ let keepRecentTokens = settings.keepRecentTokens;
1115
+ if (lastUsage) {
1116
+ const estimatedTokens = estimateEntriesTokens(pathEntries, boundaryStart, boundaryEnd);
1117
+ const promptTokens = calculatePromptTokens(lastUsage);
1118
+ const ratio = estimatedTokens > 0 ? promptTokens / estimatedTokens : 0;
1119
+ if (Number.isFinite(ratio) && ratio > 1) {
1120
+ keepRecentTokens = Math.max(1, Math.floor(keepRecentTokens / ratio));
1121
+ }
1122
+ }
1123
+
1124
+ const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, keepRecentTokens);
1125
+
1126
+ // Get ID of first kept entry
1127
+ const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex];
1128
+ if (!firstKeptEntry?.id) {
1129
+ return undefined; // Session needs migration
1130
+ }
1131
+ const firstKeptEntryId = firstKeptEntry.id;
1132
+
1133
+ const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
1134
+
1135
+ // Messages to summarize (will be discarded after summary)
1136
+ const messagesToSummarize: AgentMessage[] = [];
1137
+ for (let i = boundaryStart; i < historyEnd; i++) {
1138
+ const msg = getMessageFromEntry(pathEntries[i]);
1139
+ if (msg) messagesToSummarize.push(msg);
1140
+ }
1141
+
1142
+ // Messages for turn prefix summary (if splitting a turn)
1143
+ const turnPrefixMessages: AgentMessage[] = [];
1144
+ if (cutPoint.isSplitTurn) {
1145
+ for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) {
1146
+ const msg = getMessageFromEntry(pathEntries[i]);
1147
+ if (msg) turnPrefixMessages.push(msg);
1148
+ }
1149
+ }
1150
+
1151
+ // Messages kept after compaction (recent history)
1152
+ const recentMessages: AgentMessage[] = [];
1153
+ for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) {
1154
+ const msg = getMessageFromEntry(pathEntries[i]);
1155
+ if (msg) recentMessages.push(msg);
1156
+ }
1157
+ // Nothing to summarize means compaction would be a no-op.
1158
+ if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) {
1159
+ return undefined;
1160
+ }
1161
+
1162
+ // Get previous summary and preserved data for iterative updates
1163
+ let previousSummary: string | undefined;
1164
+ let previousPreserveData: Record<string, unknown> | undefined;
1165
+ if (prevCompactionIndex >= 0) {
1166
+ const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
1167
+ previousSummary = prevCompaction.summary;
1168
+ previousPreserveData = prevCompaction.preserveData;
1169
+ }
1170
+
1171
+ // Extract file operations from messages and previous compaction
1172
+ const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
1173
+
1174
+ // Also extract file ops from turn prefix if splitting
1175
+ if (cutPoint.isSplitTurn) {
1176
+ for (const msg of turnPrefixMessages) {
1177
+ extractFileOpsFromMessage(msg, fileOps);
1178
+ }
1179
+ }
1180
+
1181
+ return {
1182
+ firstKeptEntryId,
1183
+ messagesToSummarize,
1184
+ turnPrefixMessages,
1185
+ recentMessages,
1186
+ isSplitTurn: cutPoint.isSplitTurn,
1187
+ tokensBefore,
1188
+ previousSummary,
1189
+ previousPreserveData,
1190
+ fileOps,
1191
+ settings,
1192
+ };
1193
+ }
1194
+
1195
+ // ============================================================================
1196
+ // Main compaction function
1197
+ // ============================================================================
1198
+
1199
+ const TURN_PREFIX_SUMMARIZATION_PROMPT = prompt.render(compactionTurnPrefixPrompt);
1200
+
1201
+ function openAiCompatSupportsImageDetailOriginal(model: Model): boolean {
1202
+ const compat = model.compat;
1203
+ return !!compat && "supportsImageDetailOriginal" in compat && compat.supportsImageDetailOriginal === true;
1204
+ }
1205
+
1206
+ function buildOpenAiResponsesCompactionInput(
1207
+ messages: Message[],
1208
+ model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
1209
+ previousReplacementHistory: Array<Record<string, unknown>> | undefined,
1210
+ ): unknown[] {
1211
+ const input = buildResponsesInput({
1212
+ model,
1213
+ context: { messages },
1214
+ strictResponsesPairing: model.compat.strictResponsesPairing,
1215
+ supportsImageDetailOriginal: openAiCompatSupportsImageDetailOriginal(model),
1216
+ nativeHistory: { replay: true, filterReasoning: false },
1217
+ includeThinkingSignatures: true,
1218
+ repairOrphanOutputs: true,
1219
+ });
1220
+ return previousReplacementHistory ? [...previousReplacementHistory, ...input] : input;
1221
+ }
1222
+
1223
+ /**
1224
+ * Resolve the Responses `reasoning` param for a V2 compaction request the same
1225
+ * way a normal turn does — through {@link resolveOpenAICompatPolicy}, so it
1226
+ * honors per-model effort support, `omitReasoningEffort`, disable modes, and the
1227
+ * wire-effort mapping. Returns `undefined` for non-reasoning models or when the
1228
+ * user selected `Off` (matching the normal-turn omission, not a fabricated shape).
1229
+ */
1230
+ function buildCompactionV2Reasoning(
1231
+ model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
1232
+ thinkingLevel: ThinkingLevel | undefined,
1233
+ ): { effort: string; summary: string } | undefined {
1234
+ const policy = resolveOpenAICompatPolicy(model, {
1235
+ endpoint: "responses",
1236
+ reasoning: resolveCompactionEffort(model, thinkingLevel),
1237
+ });
1238
+ const reasoning = policy.reasoning;
1239
+ if (!reasoning.modelSupported || reasoning.disabled || reasoning.omitReasoningEffort) return undefined;
1240
+ if (reasoning.requestedEffort === undefined) return undefined;
1241
+ return { effort: reasoning.wireEffort ?? reasoning.requestedEffort, summary: "auto" };
1242
+ }
1243
+
1244
+ /**
1245
+ * Generate summaries for compaction using prepared data.
1246
+ * Returns CompactionResult - SessionManager adds id/parentId when saving.
1247
+ *
1248
+ * @param preparation - Pre-calculated preparation from prepareCompaction()
1249
+ * @param customInstructions - Optional custom focus for the summary
1250
+ */
1251
+ export async function compact(
1252
+ preparation: CompactionPreparation,
1253
+ model: Model,
1254
+ apiKey: ApiKey,
1255
+ customInstructions?: string,
1256
+ signal?: AbortSignal,
1257
+ options?: SummaryOptions,
1258
+ ): Promise<CompactionResult> {
1259
+ const {
1260
+ firstKeptEntryId,
1261
+ messagesToSummarize,
1262
+ turnPrefixMessages,
1263
+ recentMessages,
1264
+ isSplitTurn,
1265
+ tokensBefore,
1266
+ previousSummary,
1267
+ previousPreserveData,
1268
+ fileOps,
1269
+ settings,
1270
+ } = preparation;
1271
+
1272
+ const reserveTokens = settings.reserveTokens ?? DEFAULT_RESERVE_TOKENS;
1273
+
1274
+ const summaryOptions: SummaryOptions = {
1275
+ promptOverride: options?.promptOverride,
1276
+ extraContext: options?.extraContext,
1277
+ remoteEndpoint: settings.remoteEnabled === false ? undefined : settings.remoteEndpoint,
1278
+ remoteInstructions: options?.remoteInstructions,
1279
+ initiatorOverride: options?.initiatorOverride,
1280
+ metadata: options?.metadata,
1281
+ convertToLlm: options?.convertToLlm,
1282
+ telemetry: options?.telemetry,
1283
+ // Honor /model thinking selection on every fan-out summarizer.
1284
+ // Without this propagation, generateSummary / generateTurnPrefixSummary
1285
+ // see options?.thinkingLevel === undefined and resolveCompactionEffort
1286
+ // silently falls back to Effort.High — the same defect e07b47ee4 fixed
1287
+ // at the call sites, leaked back in here. See resolveCompactionEffort.
1288
+ thinkingLevel: options?.thinkingLevel,
1289
+ sessionId: options?.sessionId,
1290
+ promptCacheKey: options?.promptCacheKey,
1291
+ tools: options?.tools,
1292
+ fetch: options?.fetch,
1293
+ completeImpl: options?.completeImpl,
1294
+ };
1295
+
1296
+ const previousSnapcompactArchive = snapcompact.getPreservedArchive(previousPreserveData);
1297
+ const previousSnapcompactArchiveText = previousSnapcompactArchive
1298
+ ? snapcompact.archiveSourceText(previousSnapcompactArchive)
1299
+ : undefined;
1300
+ const previousSummaryForCompaction = mergePreviousSummaryWithSnapcompactArchive(
1301
+ previousSummary,
1302
+ previousSnapcompactArchiveText,
1303
+ );
1304
+ const snapcompactArchiveMigrationMessage = previousSnapcompactArchiveText
1305
+ ? createSnapcompactArchiveMigrationMessage(previousSnapcompactArchiveText)
1306
+ : undefined;
1307
+
1308
+ let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
1309
+ const remoteMessages: AgentMessage[] = [
1310
+ ...(snapcompactArchiveMigrationMessage ? [snapcompactArchiveMigrationMessage] : []),
1311
+ ...messagesToSummarize,
1312
+ ...turnPrefixMessages,
1313
+ ...recentMessages,
1314
+ ];
1315
+ let usedRemoteCompaction = false;
1316
+ if (
1317
+ settings.remoteEnabled !== false &&
1318
+ settings.remoteStreamingV2Enabled !== false &&
1319
+ shouldUseCompactionV2Streaming(model)
1320
+ ) {
1321
+ const previousRemoteCompaction = getCompactionV2PreserveData(previousPreserveData);
1322
+ const previousReplacementHistory =
1323
+ previousRemoteCompaction?.provider === model.provider
1324
+ ? previousRemoteCompaction.replacementHistory
1325
+ : undefined;
1326
+ const remoteHistory = buildOpenAiResponsesCompactionInput(
1327
+ (summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages),
1328
+ model,
1329
+ previousReplacementHistory,
1330
+ );
1331
+ if (remoteHistory.length > 0) {
1332
+ try {
1333
+ const request = buildCompactionV2Request(
1334
+ model,
1335
+ remoteHistory,
1336
+ summaryOptions.remoteInstructions ?? SUMMARIZATION_SYSTEM_PROMPT,
1337
+ {
1338
+ tools: summaryOptions.tools
1339
+ ? convertTools(summaryOptions.tools, model.compat.supportsStrictMode, model)
1340
+ : undefined,
1341
+ reasoning: buildCompactionV2Reasoning(model, summaryOptions.thinkingLevel),
1342
+ sessionId: summaryOptions.sessionId,
1343
+ promptCacheKey: summaryOptions.promptCacheKey,
1344
+ retainedMessageBudget: settings.v2RetainedMessageBudget,
1345
+ },
1346
+ );
1347
+ const remote = await withAuth(
1348
+ apiKey,
1349
+ key => requestCompactionV2Streaming(model, key, request, signal, { fetch: summaryOptions.fetch }),
1350
+ { signal },
1351
+ );
1352
+ preserveData = { ...(preserveData ?? {}), ...storeCompactionV2PreserveData(remote, model) };
1353
+ usedRemoteCompaction = true;
1354
+ } catch (err) {
1355
+ // A user/session abort is a cancellation, not a remote failure —
1356
+ // swallowing it here would downgrade Esc into "fall back to local
1357
+ // summarization" and keep compaction running on an aborted signal.
1358
+ if (signal?.aborted) throw err;
1359
+ logger.warn("OpenAI V2 remote compaction failed, falling back to V1/local summarization", {
1360
+ error: err instanceof Error ? err.message : String(err),
1361
+ model: model.id,
1362
+ provider: model.provider,
1363
+ });
1364
+ }
1365
+ }
1366
+ }
1367
+
1368
+ if (!usedRemoteCompaction && settings.remoteEnabled !== false && shouldUseOpenAiRemoteCompaction(model)) {
1369
+ const previousRemoteCompaction = getPreservedOpenAiRemoteCompactionData(previousPreserveData);
1370
+ const previousV2Compaction = getCompactionV2PreserveData(previousPreserveData);
1371
+ const previousReplacementHistory =
1372
+ previousRemoteCompaction?.provider === model.provider
1373
+ ? previousRemoteCompaction.replacementHistory
1374
+ : previousV2Compaction?.provider === model.provider
1375
+ ? previousV2Compaction.replacementHistory
1376
+ : undefined;
1377
+ const remoteHistory = buildOpenAiNativeHistory(
1378
+ (summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages),
1379
+ model,
1380
+ previousReplacementHistory,
1381
+ );
1382
+ if (remoteHistory.length > 0) {
1383
+ try {
1384
+ const remote = await withAuth(
1385
+ apiKey,
1386
+ key =>
1387
+ requestOpenAiRemoteCompaction(
1388
+ model,
1389
+ key,
1390
+ remoteHistory,
1391
+ summaryOptions.remoteInstructions ?? SUMMARIZATION_SYSTEM_PROMPT,
1392
+ signal,
1393
+ { fetch: summaryOptions.fetch },
1394
+ ),
1395
+ { signal },
1396
+ );
1397
+ preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, remote);
1398
+ usedRemoteCompaction = true;
1399
+ } catch (err) {
1400
+ // A user/session abort is a cancellation, not a remote failure —
1401
+ // swallowing it here would downgrade Esc into "fall back to local
1402
+ // summarization" and keep compaction running on an aborted signal.
1403
+ if (signal?.aborted) throw err;
1404
+ logger.warn("OpenAI remote compaction failed, falling back to local summarization", {
1405
+ error: err instanceof Error ? err.message : String(err),
1406
+ model: model.id,
1407
+ provider: model.provider,
1408
+ });
1409
+ }
1410
+ }
1411
+ }
1412
+
1413
+ // Generate summaries (can be parallel if both needed) and merge into one
1414
+ let summary: string;
1415
+
1416
+ if (usedRemoteCompaction) {
1417
+ // Remote compaction (V2 or V1) already compacted remotely; the durable
1418
+ // history lives in the provider replay payload (preserveData). Skip local
1419
+ // summarization so a successful remote compaction never pays for a second,
1420
+ // redundant LLM round. If a LATER compaction cannot reuse this payload,
1421
+ // prepareCompaction re-expands the original messages and summarizes them
1422
+ // locally then (see remotePreserveReusableByAny).
1423
+ const usedTokens = getCompactionV2PreserveData(preserveData)?.usedTokens ?? 0;
1424
+ summary =
1425
+ "Remote compaction preserved provider-native history for this session." +
1426
+ (usedTokens > 0 ? ` Retained ${usedTokens} tokens in the provider replay payload.` : "");
1427
+ } else if (isSplitTurn && turnPrefixMessages.length > 0) {
1428
+ // Generate both summaries in parallel
1429
+ const [historyResult, turnPrefixResult] = await Promise.all([
1430
+ messagesToSummarize.length > 0 || previousSummaryForCompaction
1431
+ ? generateSummary(
1432
+ messagesToSummarize,
1433
+ model,
1434
+ reserveTokens,
1435
+ apiKey,
1436
+ signal,
1437
+ customInstructions,
1438
+ previousSummaryForCompaction,
1439
+ summaryOptions,
1440
+ )
1441
+ : Promise.resolve("No prior history."),
1442
+ generateTurnPrefixSummary(turnPrefixMessages, model, reserveTokens, apiKey, signal, summaryOptions),
1443
+ ]);
1444
+ // Merge into single summary
1445
+ summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
1446
+ } else if (messagesToSummarize.length > 0) {
1447
+ // Generate history summary from messages to summarize
1448
+ summary = await generateSummary(
1449
+ messagesToSummarize,
1450
+ model,
1451
+ reserveTokens,
1452
+ apiKey,
1453
+ signal,
1454
+ customInstructions,
1455
+ previousSummaryForCompaction,
1456
+ summaryOptions,
1457
+ );
1458
+ } else if (previousSummaryForCompaction) {
1459
+ // No new messages to summarize, preserve previous summary
1460
+ summary = previousSummaryForCompaction;
1461
+ } else {
1462
+ // No messages and no previous summary
1463
+ summary = "No prior history.";
1464
+ }
1465
+
1466
+ const shortSummary = usedRemoteCompaction
1467
+ ? "Remote compaction"
1468
+ : await generateShortSummary(recentMessages, summary, model, reserveTokens, apiKey, signal, {
1469
+ extraContext: options?.extraContext,
1470
+ remoteEndpoint: summaryOptions.remoteEndpoint,
1471
+ initiatorOverride: summaryOptions.initiatorOverride,
1472
+ metadata: summaryOptions.metadata,
1473
+ telemetry: summaryOptions.telemetry,
1474
+ // Same propagation as summaryOptions above — generateShortSummary
1475
+ // resolves its own reasoning via resolveCompactionEffort.
1476
+ thinkingLevel: options?.thinkingLevel,
1477
+ fetch: summaryOptions.fetch,
1478
+ completeImpl: summaryOptions.completeImpl,
1479
+ });
1480
+
1481
+ // Compute file lists and append to summary
1482
+ const { readFiles, modifiedFiles } = computeFileLists(fileOps);
1483
+ summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
1484
+
1485
+ if (!firstKeptEntryId) {
1486
+ throw new Error("First kept entry has no ID - session may need migration");
1487
+ }
1488
+
1489
+ // This LLM-summary path migrated any prior snapcompact frames into the summary
1490
+ // text above; strip the now-stale frame archive from preserveData so it cannot
1491
+ // re-attach to the rebuilt context. Only the legacy-frame case needs stripping —
1492
+ // when there was no previous archive, preserveData carries no frames to drop.
1493
+ const finalPreserveData = previousSnapcompactArchive
1494
+ ? snapcompact.stripPreservedArchive(preserveData)
1495
+ : preserveData;
1496
+
1497
+ return {
1498
+ summary,
1499
+ shortSummary,
1500
+ firstKeptEntryId,
1501
+ tokensBefore,
1502
+ details: { readFiles, modifiedFiles } as CompactionDetails,
1503
+ preserveData: finalPreserveData,
1504
+ };
1505
+ }
1506
+
1507
+ /**
1508
+ * Generate a summary for a turn prefix (when splitting a turn).
1509
+ */
1510
+ async function generateTurnPrefixSummary(
1511
+ messages: AgentMessage[],
1512
+ model: Model,
1513
+ reserveTokens: number,
1514
+ apiKey: ApiKey,
1515
+ signal?: AbortSignal,
1516
+ options?: SummaryOptions,
1517
+ ): Promise<string> {
1518
+ const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
1519
+
1520
+ const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
1521
+ const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
1522
+ const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
1523
+ const summarizationMessages = [
1524
+ {
1525
+ role: "user" as const,
1526
+ content: [{ type: "text" as const, text: promptText }],
1527
+ timestamp: Date.now(),
1528
+ },
1529
+ ];
1530
+
1531
+ const response = await instrumentedCompleteSimple(
1532
+ model,
1533
+ { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages },
1534
+ {
1535
+ maxTokens,
1536
+ signal,
1537
+ apiKey,
1538
+ reasoning: resolveCompactionEffort(model, options?.thinkingLevel),
1539
+ initiatorOverride: options?.initiatorOverride,
1540
+ metadata: options?.metadata,
1541
+ },
1542
+ { telemetry: options?.telemetry, oneshotKind: "compaction_turn_prefix", completeImpl: options?.completeImpl },
1543
+ );
1544
+
1545
+ if (response.stopReason === "error") {
1546
+ throw createSummarizationError("Turn prefix summarization failed", response);
1547
+ }
1548
+
1549
+ return response.content
1550
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
1551
+ .map(c => c.text)
1552
+ .join("\n");
1553
+ }