@linxiraos/pi-agent-core 1.1.14 → 1.1.16

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.
@@ -42,6 +42,15 @@ import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
42
42
  import { ThinkingLevel } from "../thinking";
43
43
  import { Tokenizer } from "../tokenizer";
44
44
  import type { AgentMessage } from "../types";
45
+ import {
46
+ ANTHROPIC_COMPACTION_MIN_CONTEXT_TOKENS,
47
+ buildAnthropicCompactionInstructions,
48
+ describeRetainedTail,
49
+ getPreservedAnthropicCompactionData,
50
+ requestAnthropicNativeCompaction,
51
+ shouldUseAnthropicNativeCompaction,
52
+ withAnthropicCompactionPreserveData,
53
+ } from "./anthropic";
45
54
  import {
46
55
  buildCompactionV2Request,
47
56
  buildCompactionV2RequestFromBody,
@@ -53,10 +62,17 @@ import {
53
62
  } from "./compaction-v2-streaming";
54
63
  import type { CompactionEntry, SessionEntry } from "./entries";
55
64
  import { NativeCompactionError } from "./errors";
56
- import { type ConvertToLlm, createBranchSummaryMessage, createCustomMessage, defaultConvertToLlm } from "./messages";
65
+ import {
66
+ type ConvertToLlm,
67
+ createBranchSummaryMessage,
68
+ createCompactionSummaryMessage,
69
+ createCustomMessage,
70
+ defaultConvertToLlm,
71
+ } from "./messages";
57
72
  import {
58
73
  buildOpenAiNativeHistory,
59
74
  getPreservedOpenAiRemoteCompactionData,
75
+ isOpenAiRemoteCompactionApi,
60
76
  requestOpenAiRemoteCompaction,
61
77
  requestRemoteCompaction,
62
78
  shouldUseOpenAiRemoteCompaction,
@@ -224,7 +240,11 @@ export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
224
240
  v2RetainedMessageBudget: V2_RETAINED_MESSAGE_TOKEN_BUDGET,
225
241
  };
226
242
 
227
- /** Whether a compaction candidate preserves provider-native transport under the effective settings. */
243
+ /**
244
+ * Whether a compaction candidate preserves provider-native transport under the
245
+ * effective settings: an OpenAI Responses compact route (V1 or streamed V2) or
246
+ * the Anthropic compaction beta.
247
+ */
228
248
  export function shouldUseProviderNativeCompaction(
229
249
  model: Model,
230
250
  settings: Pick<CompactionSettings, "remoteEnabled" | "remoteStreamingV2Enabled">,
@@ -232,7 +252,8 @@ export function shouldUseProviderNativeCompaction(
232
252
  if (settings.remoteEnabled === false) return false;
233
253
  return (
234
254
  shouldUseOpenAiRemoteCompaction(model) ||
235
- (settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(model))
255
+ (settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(model)) ||
256
+ shouldUseAnthropicNativeCompaction(model)
236
257
  );
237
258
  }
238
259
 
@@ -395,22 +416,6 @@ export function resolveThresholdTokens(contextWindow: number, settings: Compacti
395
416
  // Cut point detection
396
417
  // ============================================================================
397
418
 
398
- function estimateEntriesTokens(
399
- entries: SessionEntry[],
400
- tokenizer: Tokenizer,
401
- startIndex: number,
402
- endIndex: number,
403
- ): number {
404
- let total = 0;
405
- for (let i = startIndex; i < endIndex; i++) {
406
- const msg = getMessageFromEntry(entries[i]);
407
- if (msg) {
408
- total += tokenizer.countMessage(msg);
409
- }
410
- }
411
- return total;
412
- }
413
-
414
419
  /**
415
420
  * Find valid cut points: indices of user, assistant, custom, or bashExecution messages.
416
421
  * Never cut at tool results (they must follow their tool call).
@@ -455,6 +460,23 @@ function findValidCutPoints(entries: SessionEntry[], startIndex: number, endInde
455
460
  return cutPoints;
456
461
  }
457
462
 
463
+ /**
464
+ * True for entries that begin a conversational turn (a user request, a
465
+ * bash-execution card, a branch summary, or a custom user-role message).
466
+ * Compaction cut alignment, turn discovery, and the collapsed display
467
+ * transcript's orphan-head trim all share this boundary definition.
468
+ */
469
+ export function isTurnStartEntry(entry: SessionEntry): boolean {
470
+ if (entry.type === "branch_summary" || entry.type === "custom_message") {
471
+ return true;
472
+ }
473
+ if (entry.type === "message") {
474
+ const role = entry.message.role as string;
475
+ return role === "user" || role === "bashExecution";
476
+ }
477
+ return false;
478
+ }
479
+
458
480
  /**
459
481
  * Find the user message (or bashExecution) that starts the turn containing the given entry index.
460
482
  * Returns -1 if no turn start found before the index.
@@ -462,17 +484,9 @@ function findValidCutPoints(entries: SessionEntry[], startIndex: number, endInde
462
484
  */
463
485
  export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number {
464
486
  for (let i = entryIndex; i >= startIndex; i--) {
465
- const entry = entries[i];
466
- // branch_summary and custom_message are user-role messages, can start a turn
467
- if (entry.type === "branch_summary" || entry.type === "custom_message") {
487
+ if (isTurnStartEntry(entries[i])) {
468
488
  return i;
469
489
  }
470
- if (entry.type === "message") {
471
- const role = entry.message.role as string;
472
- if (role === "user" || role === "bashExecution") {
473
- return i;
474
- }
475
- }
476
490
  }
477
491
  return -1;
478
492
  }
@@ -487,10 +501,11 @@ export interface CutPointResult {
487
501
  }
488
502
 
489
503
  /**
490
- * Find the cut point in session entries that keeps approximately `keepRecentTokens`.
504
+ * Find the oldest complete recent-history suffix that fits `keepRecentTokens`.
491
505
  *
492
- * Algorithm: Walk backwards from newest, accumulating estimated message sizes.
493
- * Stop when we've accumulated >= keepRecentTokens. Cut at that point.
506
+ * Walk backwards by valid cut points, measuring whole assistant/tool groups.
507
+ * Keep the newest group even when it alone exceeds the budget; never retain
508
+ * an additional older group that would push an otherwise fitting suffix over.
494
509
  *
495
510
  * Can cut at user OR assistant messages (never tool results). When cutting at an
496
511
  * assistant message with tool calls, its tool results come after and will be kept.
@@ -515,55 +530,45 @@ export function findCutPoint(
515
530
  return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
516
531
  }
517
532
 
518
- // Walk backwards from newest, accumulating estimated message sizes
533
+ // Evaluate the budget only at valid boundaries, after counting all results
534
+ // belonging to an assistant. Checking individual messages can either retain
535
+ // the oversized older assistant or miss its boundary and retain all history.
519
536
  let accumulatedTokens = 0;
520
- let cutIndex = cutPoints[0]; // Default: keep from first message (not header)
537
+ let cutPointIndex = cutPoints.length - 1;
538
+ let cutIndex = cutPoints[cutPointIndex];
521
539
 
522
540
  for (let i = endIndex - 1; i >= startIndex; i--) {
523
541
  const entry = entries[i];
524
- if (entry.type !== "message") continue;
525
-
526
- // Estimate this message's size
527
- const messageTokens = tokenizer.countMessage(entry.message);
528
- accumulatedTokens += messageTokens;
529
-
530
- // Check if we've exceeded the budget
531
- if (accumulatedTokens >= keepRecentTokens) {
532
- // Find the closest valid cut point at or after this entry
533
- for (let c = 0; c < cutPoints.length; c++) {
534
- if (cutPoints[c] >= i) {
535
- cutIndex = cutPoints[c];
536
- break;
537
- }
538
- }
539
- break;
540
- }
542
+ const message = getMessageFromEntry(entry);
543
+ if (message) accumulatedTokens += tokenizer.countMessage(message);
544
+ if (i !== cutPoints[cutPointIndex]) continue;
545
+ if (accumulatedTokens > keepRecentTokens) break;
546
+ cutIndex = i;
547
+ cutPointIndex--;
541
548
  }
542
549
 
543
- // Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.)
550
+ const isTurnStart = isTurnStartEntry(entries[cutIndex]);
551
+ const turnStartIndex = isTurnStart ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
552
+
553
+ // Scan backwards from cutIndex to include any non-message entries (settings changes, etc.)
544
554
  while (cutIndex > startIndex) {
545
555
  const prevEntry = entries[cutIndex - 1];
546
- // Stop at session header or compaction boundaries
547
- if (prevEntry.type === "compaction") {
556
+ // Stop at session header, compaction, or reset boundaries
557
+ if (prevEntry.type === "compaction" || prevEntry.type === "reset_boundary") {
548
558
  break;
549
559
  }
550
- if (prevEntry.type === "message") {
551
- // Stop if we hit any message
560
+ if (getMessageFromEntry(prevEntry)) {
561
+ // Stop if we hit any entry that contributes a message
552
562
  break;
553
563
  }
554
- // Include this non-message entry (bash, settings change, etc.)
564
+ // Include this non-message entry (settings change, label, etc.)
555
565
  cutIndex--;
556
566
  }
557
567
 
558
- // Determine if this is a split turn
559
- const cutEntry = entries[cutIndex];
560
- const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
561
- const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
562
-
563
568
  return {
564
569
  firstKeptEntryIndex: cutIndex,
565
570
  turnStartIndex,
566
- isSplitTurn: !isUserMessage && turnStartIndex !== -1,
571
+ isSplitTurn: !isTurnStart && turnStartIndex !== -1,
567
572
  };
568
573
  }
569
574
 
@@ -1252,6 +1257,8 @@ export interface CompactionPreparation {
1252
1257
  tokensBefore: number;
1253
1258
  /** Summary from previous compaction, for iterative update */
1254
1259
  previousSummary?: string;
1260
+ /** ISO timestamp of the previous compaction entry, for iterative update */
1261
+ previousSummaryTimestamp?: string;
1255
1262
  /** Preserved opaque compaction payload from the previous compaction, if any. */
1256
1263
  previousPreserveData?: Record<string, unknown>;
1257
1264
  /** File operations extracted from messagesToSummarize */
@@ -1261,19 +1268,27 @@ export interface CompactionPreparation {
1261
1268
  }
1262
1269
 
1263
1270
  /**
1264
- * Whether a prior remote compaction's provider-native replay can still be read
1265
- * by the active model — the model that assembles the request context on every
1266
- * turn. A local compaction (no remote preserve) always can: it holds a real
1267
- * textual summary. A remote compaction (V2 or V1) only can when the active model
1268
- * shares the blob's provider AND remote replay is still enabled; otherwise the
1269
- * active model's encoder drops the payload (see `getOpenAIResponsesHistoryPayload`)
1270
- * and only the opaque placeholder summary survives, so the caller must re-expand
1271
- * the originals into a portable local summary rather than strand that history.
1271
+ * Whether the active model's normal encoder can consume stored native history.
1272
+ * Creating future compactions is a separate policy: disabling it does not disable
1273
+ * Responses-family replay. Local summaries have no provider restriction.
1274
+ */
1275
+ export function canReplayRemoteCompaction(
1276
+ preserveData: Record<string, unknown> | undefined,
1277
+ activeModel: Model,
1278
+ ): boolean {
1279
+ const remote = getCompactionV2PreserveData(preserveData) ?? getPreservedOpenAiRemoteCompactionData(preserveData);
1280
+ return !remote || (remote.provider === activeModel.provider && isOpenAiRemoteCompactionApi(activeModel.api));
1281
+ }
1282
+
1283
+ /**
1284
+ * Whether compaction preparation may reuse a native boundary instead of
1285
+ * re-expanding its original messages. This is deliberately stricter than normal
1286
+ * replay: the active model must both read the payload and remain eligible for
1287
+ * native compaction under the current settings. Otherwise local summarization
1288
+ * needs the originals, not an opaque placeholder.
1272
1289
  *
1273
- * Judged against the ACTIVE model, not the compaction candidate set: a role
1274
- * model (e.g. `modelRoles.smol`) that still maps to the blob's provider does not
1275
- * let the active model replay it, so keying reuse on "any candidate shares the
1276
- * provider" left a provider-switched session permanently context-less (#6343).
1290
+ * Main-session preparation is judged against the active model, not any role
1291
+ * candidate, so a provider switch cannot strand the original history (#6343).
1277
1292
  */
1278
1293
  export function remotePreserveReusable(
1279
1294
  preserveData: Record<string, unknown> | undefined,
@@ -1282,15 +1297,16 @@ export function remotePreserveReusable(
1282
1297
  ): boolean {
1283
1298
  const remote = getCompactionV2PreserveData(preserveData) ?? getPreservedOpenAiRemoteCompactionData(preserveData);
1284
1299
  if (!remote) return true;
1285
- if (settings.remoteEnabled === false) return false;
1286
- if (remote.provider !== activeModel.provider) return false;
1287
- const v2Ok = settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(activeModel);
1288
- return v2Ok || shouldUseOpenAiRemoteCompaction(activeModel);
1300
+ return (
1301
+ remote.provider === activeModel.provider &&
1302
+ isOpenAiRemoteCompactionApi(activeModel.api) &&
1303
+ shouldUseProviderNativeCompaction(activeModel, settings)
1304
+ );
1289
1305
  }
1290
1306
 
1291
1307
  /**
1292
- * Index of the newest compaction entry the active model can actually read, or
1293
- * `-1` when none can.
1308
+ * Index of the newest compaction boundary reusable under preparation policy,
1309
+ * or `-1` when none can be reused (see {@link remotePreserveReusable}).
1294
1310
  *
1295
1311
  * A provider-native remote compaction (V2 or V1) stores an opaque replay payload
1296
1312
  * and only a placeholder summary, so for any OTHER provider that entry
@@ -1325,22 +1341,18 @@ export function prepareCompaction(
1325
1341
  activeModel?: Model,
1326
1342
  tokenizer: Tokenizer = new Tokenizer(activeModel),
1327
1343
  ): CompactionPreparation | undefined {
1328
- if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
1344
+ const lastEntry = pathEntries[pathEntries.length - 1];
1345
+ // A speculative native record may leave uncovered messages before the record.
1346
+ if (lastEntry?.type === "compaction" && !lastEntry.providerReplayThroughEntryId) {
1329
1347
  return undefined;
1330
1348
  }
1331
1349
 
1332
1350
  let prevCompactionIndex = findReadableCompactionIndex(pathEntries, settings, activeModel);
1333
1351
 
1334
- // Honor the latest `/clear` reset boundary. `/clear` records a
1335
- // `reset_boundary` marker and reports the model context empty, so compaction
1336
- // must not resurrect the dropped pre-clear turns into its summary — matching
1337
- // how buildSessionContext starts the model-context rebuild after the boundary.
1338
- // A boundary after the last reusable compaction supersedes it: the pre-reset
1339
- // summary was cleared too, so drop the previous-compaction reuse and start
1340
- // fresh after the boundary. A boundary at or before that compaction is already
1341
- // superseded by it, so only scan newer entries.
1352
+ // A newer reset clears the previous summary. An older reset bounds both
1353
+ // the local retained tail and the native snapshot-to-commit interval.
1342
1354
  let resetBoundaryIndex = -1;
1343
- for (let i = pathEntries.length - 1; i > prevCompactionIndex; i--) {
1355
+ for (let i = pathEntries.length - 1; i >= 0; i--) {
1344
1356
  if (pathEntries[i].type === "reset_boundary") {
1345
1357
  resetBoundaryIndex = i;
1346
1358
  break;
@@ -1349,14 +1361,54 @@ export function prepareCompaction(
1349
1361
  if (resetBoundaryIndex > prevCompactionIndex) {
1350
1362
  prevCompactionIndex = -1;
1351
1363
  }
1352
- const boundaryStart = Math.max(prevCompactionIndex, resetBoundaryIndex) + 1;
1353
- const boundaryEnd = pathEntries.length;
1364
+ const previousCompaction =
1365
+ prevCompactionIndex >= 0 ? (pathEntries[prevCompactionIndex] as CompactionEntry) : undefined;
1366
+ let boundaryStart = Math.max(prevCompactionIndex, resetBoundaryIndex) + 1;
1367
+ if (
1368
+ previousCompaction &&
1369
+ (getCompactionV2PreserveData(previousCompaction.preserveData) ||
1370
+ getPreservedOpenAiRemoteCompactionData(previousCompaction.preserveData))
1371
+ ) {
1372
+ if (previousCompaction.providerReplayThroughEntryId) {
1373
+ const replayThroughIndex = pathEntries.findIndex(
1374
+ entry => entry.id === previousCompaction.providerReplayThroughEntryId,
1375
+ );
1376
+ if (replayThroughIndex >= 0 && replayThroughIndex < prevCompactionIndex) {
1377
+ // Native replay covers the snapshot, not messages appended while the
1378
+ // request was running. Include that interval in the next preparation.
1379
+ boundaryStart = Math.max(replayThroughIndex, resetBoundaryIndex) + 1;
1380
+ }
1381
+ }
1382
+ } else if (previousCompaction) {
1383
+ // Local summaries exclude the retained tail, whose original entries precede
1384
+ // the compaction record. Native replay already carries that tail. Only look
1385
+ // backwards: advisor snapshots put all retained messages after the summary
1386
+ // and may carry a keep ID from their previous, differently indexed snapshot.
1387
+ for (let i = resetBoundaryIndex + 1; i < prevCompactionIndex; i++) {
1388
+ if (pathEntries[i].id === previousCompaction.firstKeptEntryId) {
1389
+ boundaryStart = i;
1390
+ break;
1391
+ }
1392
+ }
1393
+ }
1394
+
1395
+ // Keep original IDs beside the converted messages so estimation, cutting,
1396
+ // and all three output regions share one sequence without journal metadata.
1397
+ const compactionEntries: SessionEntry[] = [];
1398
+ const compactionMessages: AgentMessage[] = [];
1399
+ for (let i = boundaryStart; i < pathEntries.length; i++) {
1400
+ const entry = pathEntries[i];
1401
+ const message = getMessageFromEntry(entry);
1402
+ if (!message) continue;
1403
+ compactionEntries.push(entry);
1404
+ compactionMessages.push(message);
1405
+ }
1354
1406
 
1355
1407
  const lastUsage = getLastAssistantUsage(pathEntries);
1356
1408
  const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
1357
1409
  let keepRecentTokens = settings.keepRecentTokens;
1358
1410
  if (lastUsage) {
1359
- const estimatedTokens = estimateEntriesTokens(pathEntries, tokenizer, boundaryStart, boundaryEnd);
1411
+ const estimatedTokens = tokenizer.countMessages(compactionMessages);
1360
1412
  const promptTokens = calculatePromptTokens(lastUsage);
1361
1413
  const ratio = estimatedTokens > 0 ? promptTokens / estimatedTokens : 0;
1362
1414
  if (Number.isFinite(ratio) && ratio > 1) {
@@ -1364,10 +1416,10 @@ export function prepareCompaction(
1364
1416
  }
1365
1417
  }
1366
1418
 
1367
- const cutPoint = findCutPoint(pathEntries, tokenizer, boundaryStart, boundaryEnd, keepRecentTokens);
1419
+ const cutPoint = findCutPoint(compactionEntries, tokenizer, 0, compactionEntries.length, keepRecentTokens);
1368
1420
 
1369
1421
  // Get ID of first kept entry
1370
- const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex];
1422
+ const firstKeptEntry = compactionEntries[cutPoint.firstKeptEntryIndex];
1371
1423
  if (!firstKeptEntry?.id) {
1372
1424
  return undefined; // Session needs migration
1373
1425
  }
@@ -1375,42 +1427,16 @@ export function prepareCompaction(
1375
1427
 
1376
1428
  const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
1377
1429
 
1378
- // Messages to summarize (will be discarded after summary)
1379
- const messagesToSummarize: AgentMessage[] = [];
1380
- for (let i = boundaryStart; i < historyEnd; i++) {
1381
- const msg = getMessageFromEntry(pathEntries[i]);
1382
- if (msg) messagesToSummarize.push(msg);
1383
- }
1384
-
1385
- // Messages for turn prefix summary (if splitting a turn)
1386
- const turnPrefixMessages: AgentMessage[] = [];
1387
- if (cutPoint.isSplitTurn) {
1388
- for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) {
1389
- const msg = getMessageFromEntry(pathEntries[i]);
1390
- if (msg) turnPrefixMessages.push(msg);
1391
- }
1392
- }
1393
-
1394
- // Messages kept after compaction (recent history)
1395
- const recentMessages: AgentMessage[] = [];
1396
- for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) {
1397
- const msg = getMessageFromEntry(pathEntries[i]);
1398
- if (msg) recentMessages.push(msg);
1399
- }
1430
+ const messagesToSummarize = compactionMessages.slice(0, historyEnd);
1431
+ const turnPrefixMessages = cutPoint.isSplitTurn
1432
+ ? compactionMessages.slice(cutPoint.turnStartIndex, cutPoint.firstKeptEntryIndex)
1433
+ : [];
1434
+ const recentMessages = compactionMessages.slice(cutPoint.firstKeptEntryIndex);
1400
1435
  // Nothing to summarize means compaction would be a no-op.
1401
1436
  if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) {
1402
1437
  return undefined;
1403
1438
  }
1404
1439
 
1405
- // Get previous summary and preserved data for iterative updates
1406
- let previousSummary: string | undefined;
1407
- let previousPreserveData: Record<string, unknown> | undefined;
1408
- if (prevCompactionIndex >= 0) {
1409
- const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
1410
- previousSummary = prevCompaction.summary;
1411
- previousPreserveData = prevCompaction.preserveData;
1412
- }
1413
-
1414
1440
  // Extract file operations from messages and previous compaction
1415
1441
  const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
1416
1442
 
@@ -1428,8 +1454,9 @@ export function prepareCompaction(
1428
1454
  recentMessages,
1429
1455
  isSplitTurn: cutPoint.isSplitTurn,
1430
1456
  tokensBefore,
1431
- previousSummary,
1432
- previousPreserveData,
1457
+ previousSummary: previousCompaction?.summary,
1458
+ previousSummaryTimestamp: previousCompaction?.timestamp,
1459
+ previousPreserveData: previousCompaction?.preserveData,
1433
1460
  fileOps,
1434
1461
  settings,
1435
1462
  };
@@ -1585,6 +1612,9 @@ export async function compact(
1585
1612
  tools: options?.tools,
1586
1613
  fetch: options?.fetch,
1587
1614
  completeImpl: options?.completeImpl,
1615
+ // The caller's opt-out must reach every summarization oneshot, otherwise
1616
+ // an outer retry loop multiplies with the inner one (see SummaryOptions).
1617
+ oneshotRetry: options?.oneshotRetry,
1588
1618
  };
1589
1619
 
1590
1620
  const previousSnapcompactArchive = snapcompact.getPreservedArchive(previousPreserveData);
@@ -1598,9 +1628,21 @@ export async function compact(
1598
1628
  const snapcompactArchiveMigrationMessage = previousSnapcompactArchiveText
1599
1629
  ? createSnapcompactArchiveMigrationMessage(previousSnapcompactArchiveText)
1600
1630
  : undefined;
1601
-
1602
- let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
1631
+ const previousNativeHistory =
1632
+ getCompactionV2PreserveData(previousPreserveData) ?? getPreservedOpenAiRemoteCompactionData(previousPreserveData);
1633
+ // A local summary has no native payload to carry it into the first remote
1634
+ // request. Encode it as history; do not resend opaque native placeholders.
1635
+ const previousSummaryMigrationMessage =
1636
+ settings.remoteEnabled !== false && previousSummary && !previousNativeHistory
1637
+ ? createCompactionSummaryMessage(previousSummary, tokensBefore, new Date().toISOString())
1638
+ : undefined;
1639
+
1640
+ let preserveData = withAnthropicCompactionPreserveData(
1641
+ withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined),
1642
+ undefined,
1643
+ );
1603
1644
  const remoteMessages: AgentMessage[] = [
1645
+ ...(previousSummaryMigrationMessage ? [previousSummaryMigrationMessage] : []),
1604
1646
  ...(snapcompactArchiveMigrationMessage ? [snapcompactArchiveMigrationMessage] : []),
1605
1647
  ...messagesToSummarize,
1606
1648
  ...turnPrefixMessages,
@@ -1784,6 +1826,112 @@ export async function compact(
1784
1826
  }
1785
1827
  }
1786
1828
 
1829
+ // Anthropic server-side compaction: the live turn's request shape plus the
1830
+ // compact edit, so the API summarizes from its cached prefix. The summary is
1831
+ // real text, persisted both as the entry summary and as the native replay
1832
+ // payload. A context below the API's trigger floor cannot compact remotely
1833
+ // and takes the local summarizer instead — an eligibility boundary, not a
1834
+ // failure.
1835
+ let nativeSummary: string | undefined;
1836
+ let nativeEncryptedContent: string | undefined;
1837
+ let nativeUsedTokens: number | undefined;
1838
+ if (
1839
+ !usedRemoteCompaction &&
1840
+ settings.remoteEnabled !== false &&
1841
+ shouldUseAnthropicNativeCompaction(model) &&
1842
+ tokensBefore >= ANTHROPIC_COMPACTION_MIN_CONTEXT_TOKENS
1843
+ ) {
1844
+ const previousNative = getPreservedAnthropicCompactionData(previousPreserveData);
1845
+ // Lead with the previous summary exactly as the live context renders it:
1846
+ // natively when this provider wrote it, as text otherwise. The request
1847
+ // then shares the live turn's prefix byte-for-byte. A prior snapcompact
1848
+ // archive is already merged into that summary text, so the archive
1849
+ // migration message the OpenAI lanes carry is omitted here.
1850
+ // The rewrite marker must precede every message this request replays —
1851
+ // summarized history and retained tail alike — exactly like the live
1852
+ // context rebuild predates its tail. A previous compaction's commit
1853
+ // timestamp is newer than re-retained or re-summarized turns, so
1854
+ // reusing it would strip their bound thinking only in this request,
1855
+ // diverging from the cached live prefix (and possibly dropping below
1856
+ // the trigger). Manually built preparations with no input at all fall
1857
+ // back to the current time.
1858
+ const firstReplayed = messagesToSummarize[0] ?? turnPrefixMessages[0] ?? recentMessages[0];
1859
+ const previousSummaryAt =
1860
+ firstReplayed !== undefined ? new Date(firstReplayed.timestamp - 1).toISOString() : new Date().toISOString();
1861
+ const previousSummaryMessage = previousSummaryForCompaction
1862
+ ? createCompactionSummaryMessage(previousSummaryForCompaction, tokensBefore, previousSummaryAt, {
1863
+ providerPayload:
1864
+ previousNative?.provider === model.provider
1865
+ ? {
1866
+ type: "anthropicCompaction",
1867
+ provider: previousNative.provider,
1868
+ content: previousNative.content,
1869
+ ...(previousNative.encryptedContent
1870
+ ? { encryptedContent: previousNative.encryptedContent }
1871
+ : {}),
1872
+ ...(previousNative.filesText ? { filesText: previousNative.filesText } : {}),
1873
+ }
1874
+ : undefined,
1875
+ })
1876
+ : undefined;
1877
+ const convertToLlm = summaryOptions.convertToLlm ?? defaultConvertToLlm;
1878
+ const retainedTail = convertToLlm(recentMessages);
1879
+ const messages = [
1880
+ ...convertToLlm([
1881
+ ...(previousSummaryMessage ? [previousSummaryMessage] : []),
1882
+ ...messagesToSummarize,
1883
+ ...turnPrefixMessages,
1884
+ ]),
1885
+ ...retainedTail,
1886
+ ];
1887
+ try {
1888
+ const remote = await requestAnthropicNativeCompaction(
1889
+ model,
1890
+ apiKey,
1891
+ {
1892
+ systemPrompt: summaryOptions.remoteSystemPrompt ?? [SUMMARIZATION_SYSTEM_PROMPT],
1893
+ messages,
1894
+ tools: summaryOptions.tools,
1895
+ instructions: buildAnthropicCompactionInstructions(
1896
+ summaryOptions.promptOverride ?? SUMMARIZATION_PROMPT,
1897
+ customInstructions,
1898
+ formatAdditionalContext(summaryOptions.extraContext).trim() || undefined,
1899
+ describeRetainedTail(retainedTail),
1900
+ ),
1901
+ maxTokens: Math.min(Math.floor(0.8 * reserveTokens), MAX_SUMMARY_TOKENS),
1902
+ reasoning: resolveCompactionEffort(model, summaryOptions.thinkingLevel),
1903
+ },
1904
+ signal,
1905
+ {
1906
+ initiatorOverride: summaryOptions.initiatorOverride,
1907
+ metadata: summaryOptions.metadata,
1908
+ fetch: summaryOptions.fetch,
1909
+ sessionId: summaryOptions.sessionId,
1910
+ promptCacheKey: summaryOptions.promptCacheKey,
1911
+ providerSessionState: summaryOptions.providerSessionState,
1912
+ completeImpl: summaryOptions.completeImpl,
1913
+ telemetry: summaryOptions.telemetry,
1914
+ retry: summaryOneshotRetry(summaryOptions),
1915
+ },
1916
+ );
1917
+ nativeSummary = remote.content;
1918
+ nativeEncryptedContent = remote.encryptedContent;
1919
+ nativeUsedTokens = calculatePromptTokens(remote.usage);
1920
+ usedRemoteCompaction = true;
1921
+ } catch (err) {
1922
+ // A user/session abort is a cancellation, not a remote failure —
1923
+ // swallowing it here would downgrade Esc into "fall back to local
1924
+ // summarization" and keep compaction running on an aborted signal.
1925
+ if (signal?.aborted) throw err;
1926
+ nativeCompactionError = selectNativeCompactionError(nativeCompactionError, err);
1927
+ logger.warn("Anthropic server-side compaction failed", {
1928
+ error: err instanceof Error ? err.message : String(err),
1929
+ model: model.id,
1930
+ provider: model.provider,
1931
+ });
1932
+ }
1933
+ }
1934
+
1787
1935
  if (!usedRemoteCompaction && nativeCompactionError !== undefined && !summaryOptions.remoteEndpoint) {
1788
1936
  throw new NativeCompactionError(nativeCompactionError);
1789
1937
  }
@@ -1791,7 +1939,11 @@ export async function compact(
1791
1939
  // Generate summaries (can be parallel if both needed) and merge into one
1792
1940
  let summary: string;
1793
1941
 
1794
- if (usedRemoteCompaction) {
1942
+ if (nativeSummary !== undefined) {
1943
+ // The API wrote a real summary; it is the entry text. The replayed
1944
+ // block below stays verbatim so it matches the opaque state.
1945
+ summary = nativeSummary;
1946
+ } else if (usedRemoteCompaction) {
1795
1947
  // Remote compaction (V2 or V1) already compacted remotely; the durable
1796
1948
  // history lives in the provider replay payload (preserveData). Skip local
1797
1949
  // summarization so a successful remote compaction never pays for a second,
@@ -1850,6 +2002,22 @@ export async function compact(
1850
2002
  // Compute file lists and append to summary
1851
2003
  const { readFiles, modifiedFiles } = computeFileLists(fileOps);
1852
2004
  summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
2005
+ if (nativeSummary !== undefined) {
2006
+ // The replayed block stays byte-identical to the API's summary so it
2007
+ // matches `encryptedContent`. The harness file lists above travel
2008
+ // separately: the converter replaces the summary message with the
2009
+ // block and skips its text, so they would otherwise be invisible to
2010
+ // this provider. Every other provider keeps reading the entry text.
2011
+ const filesText = upsertFileOperations("", readFiles, modifiedFiles, fileOps.read) || undefined;
2012
+ preserveData = withAnthropicCompactionPreserveData(preserveData, {
2013
+ provider: model.provider,
2014
+ content: nativeSummary,
2015
+ ...(nativeEncryptedContent ? { encryptedContent: nativeEncryptedContent } : {}),
2016
+ ...(filesText ? { filesText } : {}),
2017
+ model: model.id,
2018
+ usedTokens: nativeUsedTokens,
2019
+ });
2020
+ }
1853
2021
 
1854
2022
  if (!firstKeptEntryId) {
1855
2023
  throw new Error("First kept entry has no ID - session may need migration");
@@ -37,6 +37,8 @@ export interface CompactionEntry<T = unknown> extends SessionEntryBase {
37
37
  shortSummary?: string;
38
38
  firstKeptEntryId: string;
39
39
  tokensBefore: number;
40
+ /** Last entry covered by native replay; later entries may precede the compaction record. */
41
+ providerReplayThroughEntryId?: string;
40
42
  /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
41
43
  details?: T;
42
44
  /** Hook-provided data to persist across compaction */
@@ -2,6 +2,7 @@
2
2
  * Compaction and summarization utilities.
3
3
  */
4
4
 
5
+ export * from "./anthropic";
5
6
  export * from "./branch-summarization";
6
7
  export * from "./compaction";
7
8
  export * from "./entries";