@animalabs/context-manager 0.5.4 → 0.5.9

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 (128) hide show
  1. package/dist/src/adaptive/kv-control.d.ts +84 -33
  2. package/dist/src/adaptive/kv-control.d.ts.map +1 -1
  3. package/dist/src/adaptive/kv-control.js +510 -145
  4. package/dist/src/adaptive/kv-control.js.map +1 -1
  5. package/dist/src/adaptive/picker.d.ts +21 -0
  6. package/dist/src/adaptive/picker.d.ts.map +1 -1
  7. package/dist/src/adaptive/picker.js.map +1 -1
  8. package/dist/src/adaptive/strategies/kv-stable.d.ts +12 -4
  9. package/dist/src/adaptive/strategies/kv-stable.d.ts.map +1 -1
  10. package/dist/src/adaptive/strategies/kv-stable.js +67 -7
  11. package/dist/src/adaptive/strategies/kv-stable.js.map +1 -1
  12. package/dist/src/context-log.d.ts.map +1 -1
  13. package/dist/src/context-log.js +11 -0
  14. package/dist/src/context-log.js.map +1 -1
  15. package/dist/src/context-manager.d.ts +38 -6
  16. package/dist/src/context-manager.d.ts.map +1 -1
  17. package/dist/src/context-manager.js +51 -0
  18. package/dist/src/context-manager.js.map +1 -1
  19. package/dist/src/index.d.ts +4 -2
  20. package/dist/src/index.d.ts.map +1 -1
  21. package/dist/src/index.js +3 -0
  22. package/dist/src/index.js.map +1 -1
  23. package/dist/src/message-store.d.ts +60 -0
  24. package/dist/src/message-store.d.ts.map +1 -1
  25. package/dist/src/message-store.js +165 -10
  26. package/dist/src/message-store.js.map +1 -1
  27. package/dist/src/phase-channel.d.ts +13 -0
  28. package/dist/src/phase-channel.d.ts.map +1 -0
  29. package/dist/src/phase-channel.js +55 -0
  30. package/dist/src/phase-channel.js.map +1 -0
  31. package/dist/src/strategies/autobiographical.d.ts +247 -8
  32. package/dist/src/strategies/autobiographical.d.ts.map +1 -1
  33. package/dist/src/strategies/autobiographical.js +1419 -141
  34. package/dist/src/strategies/autobiographical.js.map +1 -1
  35. package/dist/src/strategies/index.d.ts +1 -1
  36. package/dist/src/strategies/index.d.ts.map +1 -1
  37. package/dist/src/strategies/index.js.map +1 -1
  38. package/dist/src/strategies/knowledge.d.ts +8 -0
  39. package/dist/src/strategies/knowledge.d.ts.map +1 -1
  40. package/dist/src/strategies/knowledge.js +23 -1
  41. package/dist/src/strategies/knowledge.js.map +1 -1
  42. package/dist/src/types/index.d.ts +1 -1
  43. package/dist/src/types/index.d.ts.map +1 -1
  44. package/dist/src/types/index.js.map +1 -1
  45. package/dist/src/types/strategy.d.ts +112 -11
  46. package/dist/src/types/strategy.d.ts.map +1 -1
  47. package/dist/src/types/strategy.js.map +1 -1
  48. package/dist/test/adaptive/doc-plus-chat.test.js +7 -3
  49. package/dist/test/adaptive/doc-plus-chat.test.js.map +1 -1
  50. package/dist/test/adaptive/hard-fail.test.js +22 -0
  51. package/dist/test/adaptive/hard-fail.test.js.map +1 -1
  52. package/dist/test/adaptive/ingestion.test.js +3 -1
  53. package/dist/test/adaptive/ingestion.test.js.map +1 -1
  54. package/dist/test/adaptive/kv-control.test.js +17 -11
  55. package/dist/test/adaptive/kv-control.test.js.map +1 -1
  56. package/dist/test/adaptive/pin-at-level.test.d.ts +16 -0
  57. package/dist/test/adaptive/pin-at-level.test.d.ts.map +1 -0
  58. package/dist/test/adaptive/pin-at-level.test.js +133 -0
  59. package/dist/test/adaptive/pin-at-level.test.js.map +1 -0
  60. package/dist/test/adaptive/single-path-solve.test.d.ts +16 -0
  61. package/dist/test/adaptive/single-path-solve.test.d.ts.map +1 -0
  62. package/dist/test/adaptive/single-path-solve.test.js +204 -0
  63. package/dist/test/adaptive/single-path-solve.test.js.map +1 -0
  64. package/dist/test/anti-redundancy-budget.test.d.ts +17 -0
  65. package/dist/test/anti-redundancy-budget.test.d.ts.map +1 -0
  66. package/dist/test/anti-redundancy-budget.test.js +197 -0
  67. package/dist/test/anti-redundancy-budget.test.js.map +1 -0
  68. package/dist/test/chunk-persistence.test.d.ts +29 -0
  69. package/dist/test/chunk-persistence.test.d.ts.map +1 -0
  70. package/dist/test/chunk-persistence.test.js +290 -0
  71. package/dist/test/chunk-persistence.test.js.map +1 -0
  72. package/dist/test/compression-dedup.test.d.ts +20 -0
  73. package/dist/test/compression-dedup.test.d.ts.map +1 -0
  74. package/dist/test/compression-dedup.test.js +92 -0
  75. package/dist/test/compression-dedup.test.js.map +1 -0
  76. package/dist/test/compression-shape-invariants.test.js +40 -3
  77. package/dist/test/compression-shape-invariants.test.js.map +1 -1
  78. package/dist/test/head-window-order.test.d.ts +19 -0
  79. package/dist/test/head-window-order.test.d.ts.map +1 -0
  80. package/dist/test/head-window-order.test.js +159 -0
  81. package/dist/test/head-window-order.test.js.map +1 -0
  82. package/dist/test/image-strip-render-stats.test.js +25 -3
  83. package/dist/test/image-strip-render-stats.test.js.map +1 -1
  84. package/dist/test/knowledge-tool-boundary.test.d.ts +14 -0
  85. package/dist/test/knowledge-tool-boundary.test.d.ts.map +1 -0
  86. package/dist/test/knowledge-tool-boundary.test.js +93 -0
  87. package/dist/test/knowledge-tool-boundary.test.js.map +1 -0
  88. package/dist/test/l1-holdback.test.d.ts +15 -0
  89. package/dist/test/l1-holdback.test.d.ts.map +1 -0
  90. package/dist/test/l1-holdback.test.js +119 -0
  91. package/dist/test/l1-holdback.test.js.map +1 -0
  92. package/dist/test/merge-contiguity.test.d.ts +11 -0
  93. package/dist/test/merge-contiguity.test.d.ts.map +1 -0
  94. package/dist/test/merge-contiguity.test.js +74 -0
  95. package/dist/test/merge-contiguity.test.js.map +1 -0
  96. package/dist/test/message-store-window.test.d.ts +13 -0
  97. package/dist/test/message-store-window.test.d.ts.map +1 -0
  98. package/dist/test/message-store-window.test.js +177 -0
  99. package/dist/test/message-store-window.test.js.map +1 -0
  100. package/dist/test/pins.test.js +40 -0
  101. package/dist/test/pins.test.js.map +1 -1
  102. package/dist/test/speculation-cap.test.d.ts +9 -3
  103. package/dist/test/speculation-cap.test.d.ts.map +1 -1
  104. package/dist/test/speculation-cap.test.js +179 -3
  105. package/dist/test/speculation-cap.test.js.map +1 -1
  106. package/dist/test/summary-log-consistency.test.d.ts +18 -0
  107. package/dist/test/summary-log-consistency.test.d.ts.map +1 -0
  108. package/dist/test/summary-log-consistency.test.js +97 -0
  109. package/dist/test/summary-log-consistency.test.js.map +1 -0
  110. package/dist/test/tool-pairing-invariant.test.d.ts +15 -0
  111. package/dist/test/tool-pairing-invariant.test.d.ts.map +1 -0
  112. package/dist/test/tool-pairing-invariant.test.js +225 -0
  113. package/dist/test/tool-pairing-invariant.test.js.map +1 -0
  114. package/dist/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +11 -3
  116. package/src/adaptive/kv-control.ts +554 -152
  117. package/src/adaptive/picker.ts +21 -0
  118. package/src/adaptive/strategies/kv-stable.ts +62 -12
  119. package/src/context-log.ts +11 -0
  120. package/src/context-manager.ts +61 -4
  121. package/src/index.ts +6 -2
  122. package/src/message-store.ts +202 -9
  123. package/src/phase-channel.ts +57 -0
  124. package/src/strategies/autobiographical.ts +1482 -167
  125. package/src/strategies/index.ts +1 -1
  126. package/src/strategies/knowledge.ts +25 -1
  127. package/src/types/index.ts +1 -0
  128. package/src/types/strategy.ts +120 -7
@@ -1,6 +1,7 @@
1
1
  import type { JsStore } from '@animalabs/chronicle';
2
2
  import type { Membrane, NormalizedRequest, ContentBlock, CompleteOptions } from '@animalabs/membrane';
3
3
  import { NativeFormatter } from '@animalabs/membrane';
4
+ import { phaseChannel } from '../phase-channel.js';
4
5
  import type {
5
6
  ContextStrategy,
6
7
  ResettableStrategy,
@@ -15,6 +16,7 @@ import type {
15
16
  SummaryLevel,
16
17
  SummaryEntry,
17
18
  ProtectedRange,
19
+ PinLevelOptions,
18
20
  SearchQuery,
19
21
  SearchResult,
20
22
  RenderStats,
@@ -85,7 +87,12 @@ function formatInstruction(targetTokens: number): string {
85
87
  'Preserve concrete details — file paths, exact values, decisions, ' +
86
88
  `unresolved questions, the user\'s active asks. Target ~${targetTokens} ` +
87
89
  'tokens. Output only the memory body — no preamble, no section headers ' +
88
- 'unless they help preservation, no meta-commentary about summarizing.'
90
+ 'unless they help preservation, no meta-commentary about summarizing. ' +
91
+ 'Memorize only what actually happened in that slice: if it holds little ' +
92
+ 'beyond routine system traffic (heartbeats, empty turns, failure ' +
93
+ 'notices), a short memory saying so is correct — do not pad it by ' +
94
+ 're-narrating events you already remember from earlier as if they had ' +
95
+ 'just happened again.'
89
96
  );
90
97
  }
91
98
 
@@ -229,6 +236,36 @@ export interface Chunk {
229
236
  summaryId?: string;
230
237
  /** Phase type tag (set by KnowledgeStrategy for semantic chunking) */
231
238
  phaseType?: string;
239
+ /** ID of the persisted ChunkRecord backing this chunk (chunk persistence). */
240
+ recordId?: string;
241
+ }
242
+
243
+ /**
244
+ * Persisted chunk boundary, one per CLOSED chunk, stored in the
245
+ * `autobio:chunks` chronicle state slot (append_log, branch-aware).
246
+ *
247
+ * Records OWN the past: once a chunk closes, its membership is a persisted
248
+ * fact — rebuilds and restarts materialize chunks from records instead of
249
+ * recomputing boundaries from the running token sum. This is the fix for the
250
+ * 2026-07 re-consolidation storms: boundary inputs (config knobs, head
251
+ * window, token estimates, message mutations) could shift across restarts,
252
+ * the old exact-sourceIds-match recovery then failed, and whole stretches of
253
+ * already-summarized history were re-compressed into duplicate L1s.
254
+ *
255
+ * Membership is by message ID (never index), so edits/redactions degrade a
256
+ * record gracefully instead of re-keying its neighbors.
257
+ */
258
+ export interface ChunkRecord {
259
+ /** Stable record id ("c-<n>"). */
260
+ id: string;
261
+ /** Exact message IDs of the closed chunk, in order. */
262
+ sourceIds: string[];
263
+ /** Whether the chunk's L1 summary has been produced. */
264
+ compressed: boolean;
265
+ /** ID of the L1 SummaryEntry, once compressed. */
266
+ summaryId?: string;
267
+ /** Phase type tag (KnowledgeStrategy semantic chunking). */
268
+ phaseType?: string;
232
269
  }
233
270
 
234
271
  /**
@@ -252,6 +289,55 @@ export interface AutobiographicalProgressSnapshot {
252
289
  pending: boolean;
253
290
  }
254
291
 
292
+ /**
293
+ * Validate + normalize the optional V2 pin fold-depth bounds. Returns only the
294
+ * fields that are present and valid (non-negative integers), so a classic pin
295
+ * with no bounds persists exactly as before. `level` takes precedence over
296
+ * `maxLevel` (pin-at-k is stronger than a cap), so they're never both emitted.
297
+ */
298
+ function normalizePinLevels(opts?: PinLevelOptions): { level?: number; maxLevel?: number } {
299
+ const clean = (v: number | undefined): number | undefined =>
300
+ typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : undefined;
301
+ const level = clean(opts?.level);
302
+ if (level !== undefined) return { level };
303
+ const maxLevel = clean(opts?.maxLevel);
304
+ return maxLevel !== undefined ? { maxLevel } : {};
305
+ }
306
+
307
+ /**
308
+ * Drop empty text blocks (`{type:'text', text:''}` or whitespace-only). The
309
+ * Anthropic API rejects them with 400 "text content blocks must be non-empty",
310
+ * which — thrown inside the speculative-compression drain — halts ALL
311
+ * compression. Non-text blocks (tool_use/tool_result/image) pass through
312
+ * unchanged, so tool pairing is preserved; callers drop any message left with
313
+ * an empty content array.
314
+ */
315
+ function stripEmptyTextBlocks(content: ContentBlock[]): ContentBlock[] {
316
+ return content.filter((b) => {
317
+ if (b.type !== 'text') return true;
318
+ const text = (b as { text?: unknown }).text;
319
+ return typeof text === 'string' && text.trim().length > 0;
320
+ });
321
+ }
322
+
323
+ /**
324
+ * Strip `thinking` / `redacted_thinking` blocks from compression INPUT.
325
+ *
326
+ * The summarizer must never be handed the agent's own reasoning:
327
+ * (a) signed thinking is only valid verbatim in the turn that produced it —
328
+ * replaying it into a rewritten summarize request corrupts the signature;
329
+ * (b) asking the model to summarize its own reasoning reads as reproducing /
330
+ * duplicating model output → `reasoning_extraction` refusal, which returns
331
+ * empty (→ "empty L1 summary, chunk left raw") or, worse, produces a
332
+ * summary that reproduces the reasoning as text — which then trips the
333
+ * SAME classifier on the MAIN thread once that summary is rendered.
334
+ * Thinking is scratch work, not history (the same rationale already applied to
335
+ * the summarizer's OUTPUT). Drop it from the input too.
336
+ */
337
+ function stripThinkingBlocks(content: ContentBlock[]): ContentBlock[] {
338
+ return content.filter((b) => b.type !== 'thinking' && b.type !== 'redacted_thinking');
339
+ }
340
+
255
341
  /**
256
342
  * Autobiographical chunking strategy.
257
343
  * Compresses old conversation chunks into summaries in the model's own words.
@@ -271,6 +357,40 @@ export class AutobiographicalStrategy implements ResettableStrategy {
271
357
  protected pendingCompression: Promise<void> | null = null;
272
358
  protected compressionQueue: number[] = [];
273
359
  protected _compressionCount = 0;
360
+ /**
361
+ * Monotonic counter of tick() operations that actually processed a queue item
362
+ * (compressed a chunk or executed a merge). `driveSpeculativeDrain` recurses
363
+ * while this advances — a length-delta check would falsely read "no progress"
364
+ * when a productive tick also enqueues a follow-on item (net queue length
365
+ * unchanged), halting the drain with work still queued.
366
+ */
367
+ protected _drainProgress = 0;
368
+
369
+ /**
370
+ * Chunks (keyed by their LAST message id — chunk membership is immutable
371
+ * once closed) whose L1 was explicitly demanded by a picker `produce` op.
372
+ * Demanded chunks bypass the `l1HoldbackChunks` window in `rebuildChunks`:
373
+ * speculation waits, demand doesn't. Entries become inert once the chunk
374
+ * compresses (compressed chunks are never re-queued), so no cleanup needed.
375
+ */
376
+ protected _demandedL1Chunks = new Set<MessageId>();
377
+
378
+ /**
379
+ * In-memory mirror of the persisted chunk records (autobio:chunks slot).
380
+ * Loaded in `loadPersistedState`, appended to when a chunk closes,
381
+ * updated by ID when its L1 lands.
382
+ */
383
+ protected chunkRecords: ChunkRecord[] = [];
384
+ protected chunkIdCounter = 0;
385
+ /**
386
+ * Fail-closed latch: set when most persisted records resolve to zero live
387
+ * messages (the messages-chain-break signature). While set, NO compression
388
+ * runs — duplicate memories are strictly worse than delayed compression.
389
+ */
390
+ protected chunkRecordsOrphaned = false;
391
+ private _orphanWarned = false;
392
+ /** Record ids whose compression was refused by the L1 overlap guard. */
393
+ private _overlapBlocked = new Set<string>();
274
394
 
275
395
  // Hierarchical state
276
396
  protected summaries: SummaryEntry[] = [];
@@ -288,11 +408,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
288
408
  /** Namespace for state-id scoping. Set in `initialize()`. */
289
409
  protected ns: string = '';
290
410
  protected get summariesStateId(): string { return `${this.ns}/autobio:summaries`; }
411
+ protected get chunksStateId(): string { return `${this.ns}/autobio:chunks`; }
291
412
  protected get counterStateId(): string { return `${this.ns}/autobio:counter`; }
292
413
  protected get mergeQueueStateId(): string { return `${this.ns}/autobio:mergeQueue`; }
293
414
  protected get pinsStateId(): string { return `${this.ns}/autobio:pins`; }
294
415
  protected get resolutionsStateId(): string { return `${this.ns}/autobio:resolutions`; }
295
416
  protected get locksStateId(): string { return `${this.ns}/autobio:locks`; }
417
+ protected get calibrationStateId(): string { return `${this.ns}/autobio:calibration`; }
296
418
 
297
419
  /** Protected ranges (pins + documents). Loaded from chronicle in initialize. */
298
420
  protected pins: ProtectedRange[] = [];
@@ -440,7 +562,117 @@ export class AutobiographicalStrategy implements ResettableStrategy {
440
562
  break;
441
563
  }
442
564
  }
565
+ // Legacy stores (pre chunk-persistence) have L1 summaries but no chunk
566
+ // records — synthesize records from L1 sourceIds before the first
567
+ // rebuild, so covered ground is owned and never re-compressed.
568
+ this.migrateChunkRecords(ctx.messageStore);
443
569
  this.rebuildChunks(ctx.messageStore);
570
+ // Kick the merge ladder for pre-existing unmerged summaries. Normally a
571
+ // compression/merge completion does this, but a store that boots with a
572
+ // backlog above threshold and an empty queue (e.g. after a pyramid
573
+ // repair pruned duplicates and un-merged survivors) would otherwise
574
+ // never start consolidating. Idempotent: already-queued/merged sources
575
+ // are skipped.
576
+ if (this.config.hierarchical && !this.chunkRecordsOrphaned) {
577
+ this.checkMergeThreshold();
578
+ }
579
+ }
580
+
581
+ /**
582
+ * Whether chunk boundaries are persisted to the `autobio:chunks` slot and
583
+ * own the past. Subclasses with their own chunking (KnowledgeStrategy's
584
+ * semantic phases) opt out and keep the legacy recompute-every-rebuild
585
+ * behavior.
586
+ */
587
+ protected get chunkPersistenceEnabled(): boolean {
588
+ return true;
589
+ }
590
+
591
+ /**
592
+ * One-time lazy migration: a store with L1 summaries but an empty chunks
593
+ * slot predates chunk persistence. Each L1's sourceIds ARE the historical
594
+ * chunk boundary — synthesize a compressed record per L1, in message
595
+ * order. Stale generations from the old partial-tail compression bug
596
+ * (an L1 whose messages are ALL already covered by records synthesized so
597
+ * far — prefix families, same-range duplicates) get NO record; coverage is
598
+ * what blocks re-compression, and the repair tooling prunes their content
599
+ * separately.
600
+ */
601
+ protected migrateChunkRecords(store: MessageStoreView): void {
602
+ if (!this.chunkPersistenceEnabled || !this.store) return;
603
+ if (this.chunkRecords.length > 0) return;
604
+ const l1s = this.summaries.filter(s => s.level === 1 && Array.isArray(s.sourceIds) && s.sourceIds.length > 0);
605
+ if (l1s.length === 0) return;
606
+
607
+ const msgIndex = new Map<string, number>();
608
+ store.getAll().forEach((m, i) => msgIndex.set(m.id, i));
609
+
610
+ // Sort by (start position asc, span length desc) so at each starting
611
+ // point the LONGEST generation claims the ground first.
612
+ const sorted = [...l1s].sort((a, b) => {
613
+ const sa = msgIndex.get(a.sourceIds[0]) ?? Number.MAX_SAFE_INTEGER;
614
+ const sb = msgIndex.get(b.sourceIds[0]) ?? Number.MAX_SAFE_INTEGER;
615
+ return sa - sb || b.sourceIds.length - a.sourceIds.length;
616
+ });
617
+
618
+ const covered = new Set<string>();
619
+ let skippedStale = 0;
620
+ let skippedGhost = 0;
621
+ for (const s of sorted) {
622
+ // An L1 none of whose messages exist anymore can't own live ground.
623
+ if (!s.sourceIds.some(id => msgIndex.has(id))) { skippedGhost++; continue; }
624
+ // Fully-covered = stale generation / contained duplicate.
625
+ if (s.sourceIds.every(id => covered.has(id))) { skippedStale++; continue; }
626
+ for (const id of s.sourceIds) covered.add(id);
627
+ this.appendChunkRecord({
628
+ id: `c-${this.chunkIdCounter++}`,
629
+ sourceIds: [...s.sourceIds],
630
+ compressed: true,
631
+ summaryId: s.id,
632
+ ...(s.phaseType ? { phaseType: s.phaseType } : {}),
633
+ });
634
+ }
635
+ console.warn(
636
+ `[autobiographical] chunk-persistence migration: ${l1s.length} L1s → ` +
637
+ `${this.chunkRecords.length} records (${skippedStale} stale generations, ` +
638
+ `${skippedGhost} fully-orphaned L1s skipped)`,
639
+ );
640
+ }
641
+
642
+ /** Append a record to the chunks slot + in-memory mirror. */
643
+ protected appendChunkRecord(record: ChunkRecord): void {
644
+ this.chunkRecords.push(record);
645
+ this.store?.appendToStateJson(this.chunksStateId, record);
646
+ }
647
+
648
+ /**
649
+ * Mark a chunk's record compressed, linking its L1. Resolves the log slot
650
+ * by ID against the persisted array — never by in-memory index (see
651
+ * setMergedInto for the clobber this avoids).
652
+ */
653
+ protected markChunkRecordCompressed(recordId: string | undefined, summaryId: string): void {
654
+ if (!this.chunkPersistenceEnabled || !recordId) return;
655
+ const rec = this.chunkRecords.find(r => r.id === recordId);
656
+ if (!rec) return;
657
+ rec.compressed = true;
658
+ rec.summaryId = summaryId;
659
+ if (!this.store) return;
660
+ const stored = this.store.getStateJson(this.chunksStateId);
661
+ if (!Array.isArray(stored)) return;
662
+ const payload = Buffer.from(JSON.stringify(rec));
663
+ let found = false;
664
+ for (let i = 0; i < stored.length; i++) {
665
+ const item = stored[i] as ChunkRecord | null;
666
+ if (item && item.id === recordId) {
667
+ this.store.editStateItem(this.chunksStateId, i, payload);
668
+ found = true;
669
+ }
670
+ }
671
+ if (!found) {
672
+ console.warn(
673
+ `[autobiographical] markChunkRecordCompressed: ${recordId} not found in persisted chunk log`,
674
+ );
675
+ }
444
676
  }
445
677
 
446
678
  /**
@@ -458,6 +690,16 @@ export class AutobiographicalStrategy implements ResettableStrategy {
458
690
  fullSnapshotEvery: 10,
459
691
  });
460
692
  } catch { /* already registered */ }
693
+ if (this.chunkPersistenceEnabled) {
694
+ try {
695
+ this.store.registerState({
696
+ id: this.chunksStateId,
697
+ strategy: 'append_log',
698
+ deltaSnapshotEvery: 50,
699
+ fullSnapshotEvery: 10,
700
+ });
701
+ } catch { /* already registered */ }
702
+ }
461
703
  try {
462
704
  this.store.registerState({
463
705
  id: this.counterStateId,
@@ -476,6 +718,12 @@ export class AutobiographicalStrategy implements ResettableStrategy {
476
718
  strategy: 'snapshot',
477
719
  });
478
720
  } catch { /* already registered */ }
721
+ try {
722
+ this.store.registerState({
723
+ id: this.calibrationStateId,
724
+ strategy: 'snapshot',
725
+ });
726
+ } catch { /* already registered */ }
479
727
  // Adaptive-resolution state slots — only registered when the flag is on
480
728
  // so chronicles without the flag don't accumulate unused slots.
481
729
  if (this.config.adaptiveResolution) {
@@ -506,10 +754,45 @@ export class AutobiographicalStrategy implements ResettableStrategy {
506
754
  this.mergeQueue = [];
507
755
  this.pins = [];
508
756
  this.pinIdCounter = 0;
757
+ this.chunkRecords = [];
758
+ this.chunkIdCounter = 0;
509
759
  return;
510
760
  }
761
+
762
+ if (this.chunkPersistenceEnabled) {
763
+ const records = this.store.getStateJson(this.chunksStateId);
764
+ this.chunkRecords = (Array.isArray(records) ? (records as ChunkRecord[]) : [])
765
+ .filter(r => r && typeof r.id === 'string' && Array.isArray(r.sourceIds) && r.sourceIds.length > 0);
766
+ this.chunkIdCounter = this.chunkRecords.reduce((max, r) => {
767
+ const n = Number(r.id.replace(/^c-/, ''));
768
+ return Number.isFinite(n) && n >= max ? n + 1 : max;
769
+ }, 0);
770
+ this.chunkRecordsOrphaned = false;
771
+ this._orphanWarned = false;
772
+ this._overlapBlocked.clear();
773
+ }
511
774
  const summaries = this.store.getStateJson(this.summariesStateId);
512
- this.summaries = Array.isArray(summaries) ? (summaries as SummaryEntry[]) : [];
775
+ const loaded = Array.isArray(summaries) ? (summaries as SummaryEntry[]) : [];
776
+ // Drop empty-content summaries (bugged/empty generations from before the
777
+ // production guards). Recalling or merging one yields an empty text block →
778
+ // Anthropic 400 "content must be non-empty". Never let them re-enter memory.
779
+ const nonEmpty = loaded.filter(s => s && typeof s.content === 'string' && s.content.trim().length > 0);
780
+ const droppedEmpty = loaded.length - nonEmpty.length;
781
+ if (droppedEmpty > 0) console.warn(`[autobiographical] dropped ${droppedEmpty} empty summary(ies) on load`);
782
+ // Dedupe by id, keeping the copy with mergedInto set (position of first
783
+ // occurrence preserved). Duplicate-id copies with diverging merge state
784
+ // exist in stores touched by the pre-fix setMergedInto index-desync bug;
785
+ // without dedupe, the plain copy stays on the unmerged frontier and its
786
+ // content renders twice (once itself, once via its parent's merge).
787
+ const byId = new Map<string, SummaryEntry>();
788
+ for (const s of nonEmpty) {
789
+ const prev = byId.get(s.id);
790
+ if (!prev) byId.set(s.id, s);
791
+ else if (!prev.mergedInto && s.mergedInto) byId.set(s.id, s);
792
+ }
793
+ const dupes = nonEmpty.length - byId.size;
794
+ if (dupes > 0) console.warn(`[autobiographical] deduped ${dupes} duplicate summary id(s) on load`);
795
+ this.summaries = [...byId.values()];
513
796
 
514
797
  const counter = this.store.getStateJson(this.counterStateId);
515
798
  this.summaryIdCounter = typeof counter === 'number' ? counter : 0;
@@ -583,7 +866,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
583
866
  * Pin a range of messages so they aren't compressed and render raw at
584
867
  * their original position. Returns the pin id.
585
868
  */
586
- pinRange(firstMessageId: string, lastMessageId: string, opts?: { name?: string }): string {
869
+ pinRange(firstMessageId: string, lastMessageId: string, opts?: PinLevelOptions): string {
587
870
  const id = `pin-${this.pinIdCounter++}`;
588
871
  this.pins.push({
589
872
  id,
@@ -592,6 +875,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
592
875
  kind: 'pin',
593
876
  name: opts?.name,
594
877
  created: Date.now(),
878
+ ...normalizePinLevels(opts),
595
879
  });
596
880
  this.persistPins();
597
881
  return id;
@@ -602,7 +886,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
602
886
  * information the agent wants to retain in full. Functionally a
603
887
  * single-message pin with `kind: 'document'`.
604
888
  */
605
- markDocument(messageId: string, opts?: { name?: string }): string {
889
+ markDocument(messageId: string, opts?: PinLevelOptions): string {
606
890
  const id = `pin-${this.pinIdCounter++}`;
607
891
  this.pins.push({
608
892
  id,
@@ -611,11 +895,22 @@ export class AutobiographicalStrategy implements ResettableStrategy {
611
895
  kind: 'document',
612
896
  name: opts?.name,
613
897
  created: Date.now(),
898
+ ...normalizePinLevels(opts),
614
899
  });
615
900
  this.persistPins();
616
901
  return id;
617
902
  }
618
903
 
904
+ /**
905
+ * V2 dynamic pin-at-level-k convenience: fix a range to render at EXACTLY
906
+ * fold level `level` (0 = raw). Honored only by `foldingStrategy: 'kv-stable'`;
907
+ * other strategies fall back to treating the range as raw. Equivalent to
908
+ * `pinRange(first, last, { level })`.
909
+ */
910
+ pinAtLevel(firstMessageId: string, lastMessageId: string, level: number, opts?: { name?: string }): string {
911
+ return this.pinRange(firstMessageId, lastMessageId, { name: opts?.name, level });
912
+ }
913
+
619
914
  /** Remove a pin or document mark by id. Returns true if removed. */
620
915
  unpin(pinId: string): boolean {
621
916
  const before = this.pins.length;
@@ -735,6 +1030,42 @@ export class AutobiographicalStrategy implements ResettableStrategy {
735
1030
  return out;
736
1031
  }
737
1032
 
1033
+ /**
1034
+ * Resolve the V2 dynamic-pin fold-depth bounds (`ProtectedRange.level` /
1035
+ * `maxLevel`) to message positions. Only pins that carry a bound appear; a
1036
+ * classic raw pin (no bound) is absent here and handled by `pinnedPositions`.
1037
+ * When ranges overlap, the FINEST requirement wins (lowest effective level):
1038
+ * a fixed `level` clamps both ends; a `maxLevel` only caps depth. Honored
1039
+ * solely by the KV-stable controller — see `ProtectedRange`.
1040
+ */
1041
+ protected pinLevelBounds(messages: StoredMessage[]): Map<number, { level?: number; maxLevel?: number }> {
1042
+ const out = new Map<number, { level?: number; maxLevel?: number }>();
1043
+ if (this.pins.length === 0) return out;
1044
+ const positionOf = new Map<string, number>();
1045
+ for (let i = 0; i < messages.length; i++) positionOf.set(messages[i].id, i);
1046
+
1047
+ for (const pin of this.pins) {
1048
+ if (pin.level === undefined && pin.maxLevel === undefined) continue;
1049
+ const first = positionOf.get(pin.firstMessageId);
1050
+ const last = positionOf.get(pin.lastMessageId);
1051
+ if (first === undefined || last === undefined) continue;
1052
+ const lo = Math.min(first, last);
1053
+ const hi = Math.max(first, last);
1054
+ for (let i = lo; i <= hi; i++) {
1055
+ const prev = out.get(i) ?? {};
1056
+ // A fixed level is the strongest constraint; when two pins fix the same
1057
+ // position, the shallower (lower) level wins (finest requirement).
1058
+ if (pin.level !== undefined) {
1059
+ prev.level = prev.level === undefined ? pin.level : Math.min(prev.level, pin.level);
1060
+ } else if (pin.maxLevel !== undefined) {
1061
+ prev.maxLevel = prev.maxLevel === undefined ? pin.maxLevel : Math.min(prev.maxLevel, pin.maxLevel);
1062
+ }
1063
+ out.set(i, prev);
1064
+ }
1065
+ }
1066
+ return out;
1067
+ }
1068
+
738
1069
  /**
739
1070
  * Append a summary to the in-memory list and to the chronicle AppendLog.
740
1071
  * Single point so subclasses inherit persistence.
@@ -784,13 +1115,30 @@ export class AutobiographicalStrategy implements ResettableStrategy {
784
1115
  protected setMergedInto(entry: SummaryEntry, mergedIntoId: string): void {
785
1116
  entry.mergedInto = mergedIntoId;
786
1117
  if (!this.store) return;
787
- const index = this.summaries.indexOf(entry);
788
- if (index < 0) return;
789
- this.store.editStateItem(
790
- this.summariesStateId,
791
- index,
792
- Buffer.from(JSON.stringify(entry)),
793
- );
1118
+ // Resolve the log position by ID against the PERSISTED array — never by
1119
+ // in-memory index. `loadPersistedState` filters empty-content summaries
1120
+ // out of `this.summaries` while they remain in the log, so after a reload
1121
+ // the in-memory index is shifted relative to the log slot. Editing by
1122
+ // in-memory index wrote merge-updates onto NEIGHBORING entries, silently
1123
+ // clobbering them (4 summaries lost in the 2026-07 Lena incident, leaving
1124
+ // duplicate-id copies with diverging mergedInto). Update every stored
1125
+ // copy with this id so past duplicates converge too.
1126
+ const stored = this.store.getStateJson(this.summariesStateId);
1127
+ if (!Array.isArray(stored)) return;
1128
+ let found = false;
1129
+ const payload = Buffer.from(JSON.stringify(entry));
1130
+ for (let i = 0; i < stored.length; i++) {
1131
+ const item = stored[i] as SummaryEntry | null;
1132
+ if (item && item.id === entry.id) {
1133
+ this.store.editStateItem(this.summariesStateId, i, payload);
1134
+ found = true;
1135
+ }
1136
+ }
1137
+ if (!found) {
1138
+ console.warn(
1139
+ `[autobiographical] setMergedInto: ${entry.id} not found in persisted summary log — merge state not persisted`,
1140
+ );
1141
+ }
794
1142
  }
795
1143
 
796
1144
  /**
@@ -886,6 +1234,10 @@ export class AutobiographicalStrategy implements ResettableStrategy {
886
1234
  }
887
1235
  for (const chunk of candidates) {
888
1236
  if (chunk.compressed) continue;
1237
+ // Demand path: mark the chunk so the l1HoldbackChunks window in
1238
+ // rebuildChunks never filters it back out of the queue.
1239
+ const lastId = chunk.messages[chunk.messages.length - 1]?.id;
1240
+ if (lastId !== undefined) this._demandedL1Chunks.add(lastId);
889
1241
  if (this.compressionQueue.includes(chunk.index)) continue;
890
1242
  this.compressionQueue.push(chunk.index);
891
1243
  }
@@ -1004,20 +1356,31 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1004
1356
  */
1005
1357
  protected driveSpeculativeDrain(ctx: StrategyContext): void {
1006
1358
  if (this.pendingCompression) return;
1007
- if (this.compressionQueue.length === 0 && this.mergeQueue.length === 0) return;
1008
- if (this.isAtSpeculativeCap()) return;
1009
- if (!this.shouldCompressPreflight()) return;
1010
-
1011
- const beforeChunks = this.compressionQueue.length;
1012
- const beforeMerges = this.mergeQueue.length;
1359
+ // Merges consolidate existing L_k summaries into L_{k+1} and REDUCE the
1360
+ // unmerged-L1 count; L1 compression PRODUCES new unmerged L1s. The
1361
+ // speculation cap / preflight throttle *production* only — they must never
1362
+ // gate merges, otherwise exceeding the cap (e.g. after a manual backfill)
1363
+ // permanently deadlocks the drain: too many unmerged L1s trips the cap,
1364
+ // which blocks the very merges that would bring the count back down.
1365
+ const hasMerges = this.config.hierarchical === true && this.mergeQueue.length > 0;
1366
+ const hasCompression = this.compressionQueue.length > 0;
1367
+ if (!hasCompression && !hasMerges) return;
1368
+ // Only bail when the *sole* available work is L1 compression that the cap
1369
+ // or preflight currently forbids. Merge work always proceeds.
1370
+ if (!hasMerges && (this.isAtSpeculativeCap() || !this.shouldCompressPreflight())) return;
1371
+
1372
+ const progressBefore = this._drainProgress;
1013
1373
 
1014
1374
  this.tick(ctx)
1015
1375
  .then(() => {
1016
- const afterChunks = this.compressionQueue.length;
1017
- const afterMerges = this.mergeQueue.length;
1018
- // Made progress if either queue shrank.
1019
- const progressed = afterChunks < beforeChunks || afterMerges < beforeMerges;
1020
- if (!progressed) return;
1376
+ // Progress = the tick actually processed a queue item (compress or
1377
+ // merge), tracked by `_drainProgress`. A queue-length delta is the
1378
+ // wrong signal: a productive merge tick can also enqueue a follow-on
1379
+ // merge, leaving the length unchanged which the old check misread as
1380
+ // "no progress" and halted the drain mid-backlog. A genuine no-op tick
1381
+ // (empty queues, at-cap with no merges, no membrane) doesn't advance
1382
+ // the counter, so this still stops cleanly (no runaway recursion).
1383
+ if (this._drainProgress === progressBefore) return;
1021
1384
  // Recurse to drain more. queueMicrotask defers until the current
1022
1385
  // task is done, letting other code (the agent's stream consumer)
1023
1386
  // interleave.
@@ -1029,14 +1392,23 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1029
1392
  }
1030
1393
 
1031
1394
  /**
1032
- * Whether the strategy's pending+queued L1 budget has reached the cap
1395
+ * Whether the count of *produced, unmerged* L1 summaries has reached the cap
1033
1396
  * configured by `maxSpeculativeL1s`. If no cap is set, always false.
1397
+ *
1398
+ * The cap bounds how many L1 summaries may sit un-consolidated before the
1399
+ * strategy must merge them into L_{k+1} (bounding prefix churn / merge debt).
1400
+ * It deliberately does NOT count the pending `compressionQueue`: that queue is
1401
+ * the backlog of work to be *drained*, not produced summaries. Counting it
1402
+ * here would let a large backlog permanently trip the cap and block the very
1403
+ * compression that would clear it — a deadlock (merges relieve the cap, but
1404
+ * compression of the backlog never resumes). The throttle is on produced L1s;
1405
+ * the queue drains freely, with merges keeping the unmerged count under the cap.
1034
1406
  */
1035
1407
  protected isAtSpeculativeCap(): boolean {
1036
1408
  const cap = this.config.maxSpeculativeL1s;
1037
1409
  if (cap === undefined || cap < 0) return false;
1038
1410
  const unmergedL1s = this.summaries.filter(s => s.level === 1 && !s.mergedInto).length;
1039
- return unmergedL1s + this.compressionQueue.length > cap;
1411
+ return unmergedL1s > cap;
1040
1412
  }
1041
1413
 
1042
1414
  /**
@@ -1050,6 +1422,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1050
1422
  }
1051
1423
 
1052
1424
  async tick(ctx: StrategyContext): Promise<void> {
1425
+ phaseChannel.report('compress-tick'); // liveness-watchdog phase
1053
1426
  if (this.pendingCompression) return;
1054
1427
 
1055
1428
  if (!ctx.membrane) {
@@ -1057,9 +1430,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1057
1430
  return;
1058
1431
  }
1059
1432
 
1060
- // Priority 1: Compress raw chunks → L1
1061
- if (this.compressionQueue.length > 0) {
1433
+ // Priority 1: Compress raw chunks → L1. Skipped while at the speculative
1434
+ // cap (maxSpeculativeL1s) so we don't pile up more unmerged L1s; the merge
1435
+ // priority below still runs to consolidate existing L1s and relieve the cap.
1436
+ // No cap configured → isAtSpeculativeCap() is always false → unchanged.
1437
+ if (this.compressionQueue.length > 0 && !this.isAtSpeculativeCap()) {
1062
1438
  const chunkIndex = this.compressionQueue.shift()!;
1439
+ this._drainProgress++; // consumed a queue item (real work or stale-cleanup)
1063
1440
  const chunk = this.chunks[chunkIndex];
1064
1441
 
1065
1442
  if (!chunk || chunk.compressed) return;
@@ -1085,6 +1462,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1085
1462
  // and the next tick() retries it.
1086
1463
  if (this.config.hierarchical && this.mergeQueue.length > 0) {
1087
1464
  const merge = this.mergeQueue[0]!;
1465
+ this._drainProgress++; // executing a merge is real work, even if a
1466
+ // follow-on merge gets enqueued and the queue length nets out unchanged
1088
1467
  this.pendingCompression = this.executeMerge(merge.level, merge.sourceIds, ctx);
1089
1468
 
1090
1469
  try {
@@ -1180,6 +1559,14 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1180
1559
  // snapshot, so it reflects what the last compile actually rendered rather than
1181
1560
  // re-deriving the full pyramid (which, under adaptive resolution, bears little
1182
1561
  // resemblance to the folded output).
1562
+ //
1563
+ // CAVEAT: the final structural passes (`trimOrphanedToolUse`,
1564
+ // `enforceToolPairing`) run AFTER the per-entry tallies and BEFORE `rsEnd()`,
1565
+ // and are NOT reflected in the snapshot. Trims only remove entries (total
1566
+ // over-counts by at most the trimmed tail); the pairing validator can also
1567
+ // ADD stub tool_result entries (total then under-counts by those stubs).
1568
+ // Both deltas are a handful of tokens — the stats describe the selection, not
1569
+ // the byte-exact wire payload. Do not treat `total` as an exact wire count.
1183
1570
  // ===========================================================================
1184
1571
  private _rs: RenderStats | null = null;
1185
1572
  private _lastRenderStats: RenderStats | null = null;
@@ -1313,16 +1700,20 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1313
1700
  msgCap: number,
1314
1701
  maxTokens: number,
1315
1702
  totalTokensBefore: number,
1703
+ // Post-strip per-message estimates (2026-07-12): eviction must price a
1704
+ // message the way the stripped render will. Raw pricing counted every
1705
+ // stripped image at full cost and evicted most of an image-era tail the
1706
+ // recent-window walk had correctly admitted (mythos: 601-message tail
1707
+ // admitted, 228 survived eviction, 45k rendered of a 120k window).
1708
+ pse?: number[],
1316
1709
  ): { messages: number; tokens: number } {
1317
1710
  if (recentStart >= messages.length) return { messages: 0, tokens: 0 };
1318
1711
 
1712
+ const est = (i: number): number => pse?.[i] ?? store.estimateTokens(messages[i]);
1319
1713
  const accepted: number[] = [];
1320
1714
  let acceptedTokens = 0;
1321
1715
  for (let i = messages.length - 1; i >= recentStart; i--) {
1322
- const msg = messages[i];
1323
- const tokens = msgCap > 0
1324
- ? Math.min(store.estimateTokens(msg), msgCap + 50)
1325
- : store.estimateTokens(msg);
1716
+ const tokens = msgCap > 0 ? Math.min(est(i), msgCap + 50) : est(i);
1326
1717
  if (this.isOverBudget(totalTokensBefore + acceptedTokens + tokens, maxTokens)) break;
1327
1718
  accepted.push(i);
1328
1719
  acceptedTokens += tokens;
@@ -1342,9 +1733,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1342
1733
  for (const i of accepted) {
1343
1734
  const msg = messages[i];
1344
1735
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1345
- const tokens = msgCap > 0
1346
- ? Math.min(store.estimateTokens(msg), msgCap + 50)
1347
- : store.estimateTokens(msg);
1736
+ const tokens = msgCap > 0 ? Math.min(est(i), msgCap + 50) : est(i);
1348
1737
  entries.push({
1349
1738
  index: entries.length,
1350
1739
  sourceMessageId: msg.id,
@@ -1403,34 +1792,199 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1403
1792
  * No system prompt — framing via message structure only.
1404
1793
  */
1405
1794
  protected async compressChunkHierarchical(chunk: Chunk, ctx: StrategyContext): Promise<void> {
1795
+ phaseChannel.report('compress-chunk'); // liveness-watchdog phase
1406
1796
  if (!ctx.membrane) {
1407
1797
  throw new Error('No membrane instance for compression');
1408
1798
  }
1409
1799
 
1800
+ // ---- Duplicate-formation guards (layered) ----
1801
+ // Merged from two independent fixes for the same disease:
1802
+ //
1803
+ // 1. EXACT MATCH → adopt (bug 6.10, Tengro). rebuildChunks (fired by
1804
+ // onNewMessage / select) can re-queue a span whose compression already
1805
+ // completed against a stale chunk object — or, under chunk
1806
+ // persistence, a crash between the L1 append and the record edit
1807
+ // leaves a record uncompressed with its summary already in the log.
1808
+ // Adopt the existing summary, skip the LLM call, and heal the record.
1809
+ const chunkIdKey = this.chunkKey(chunk);
1810
+ const exactExisting = this.summaries.find(
1811
+ s => s.level === 1 && s.sourceIds.join(':') === chunkIdKey
1812
+ );
1813
+ if (exactExisting) {
1814
+ chunk.compressed = true;
1815
+ chunk.summaryId = exactExisting.id;
1816
+ this.markChunkRecordCompressed(chunk.recordId, exactExisting.id);
1817
+ return;
1818
+ }
1819
+
1820
+ const coveredByL1 = new Set<string>();
1821
+ for (const s of this.summaries) {
1822
+ if (s.level === 1 && Array.isArray(s.sourceIds)) {
1823
+ for (const id of s.sourceIds) coveredByL1.add(id);
1824
+ }
1825
+ }
1826
+
1827
+ // 2. FULLY COVERED (non-exact) → drop rather than duplicate history
1828
+ // (bug 6.10, Tengro): boundaries shifted across a rebuild and every
1829
+ // message is already inside some L1. Marking compressed WITHOUT a
1830
+ // summaryId means the uncompressed-middle fallback skips these
1831
+ // messages — they render only via the covering L1s. Safe while
1832
+ // `recentStart` advances monotonically (a fully-covered OLD chunk
1833
+ // can't intersect the recent-exclusion window); if that assumption is
1834
+ // ever weakened, reinstate a raw fallback (chunk-level `coveredBy`)
1835
+ // rather than dropping. The chunk record (if any) is deliberately
1836
+ // left uncompressed as an operator breadcrumb.
1837
+ if (chunk.messages.length > 0 && chunk.messages.every(m => coveredByL1.has(m.id))) {
1838
+ console.warn(
1839
+ `[autobiographical] dedup guard: chunk ${chunk.recordId ?? `#${chunk.index}`} ` +
1840
+ `is fully covered by existing L1s under different boundaries — dropped, not re-compressed.`,
1841
+ );
1842
+ chunk.compressed = true;
1843
+ return;
1844
+ }
1845
+
1846
+ // 3. PARTIAL OVERLAP → refuse (strict; chunk persistence). With
1847
+ // close-then-compress there is NO legitimate way for a chunk to
1848
+ // partially overlap an existing L1's span. If it happens anyway
1849
+ // (bookkeeping bug, store surgery gone wrong), refuse to produce:
1850
+ // a warning in the log is strictly better than a duplicate memory
1851
+ // in an agent's head.
1852
+ const overlapIds = chunk.messages.filter(m => coveredByL1.has(m.id)).map(m => m.id);
1853
+ if (overlapIds.length > 0) {
1854
+ const key = chunk.recordId ?? chunkIdKey;
1855
+ if (!this._overlapBlocked.has(key)) {
1856
+ this._overlapBlocked.add(key);
1857
+ console.error(
1858
+ `[autobiographical] OVERLAP GUARD: refusing to compress chunk ` +
1859
+ `${chunk.recordId ?? `#${chunk.index}`} — ${overlapIds.length}/${chunk.messages.length} ` +
1860
+ `of its messages are already covered by existing L1 summaries ` +
1861
+ `(first: ${overlapIds[0]}). Duplicate-memory formation blocked; ` +
1862
+ `investigate before resuming this span.`,
1863
+ );
1864
+ }
1865
+ return;
1866
+ }
1867
+
1410
1868
  const targetTokens = this.config.summaryTargetTokens ?? 2000;
1411
1869
  const agentParticipant = this.config.summaryParticipant ?? 'Claude';
1412
1870
 
1871
+ // ---- 0. Thin-chunk guard ----
1872
+ // A chunk of silent/skip turns and bare system traffic gives the
1873
+ // summarizer nothing to remember. Asked anyway, it confabulates: it
1874
+ // reaches for the nearest salient content (head window, prior recall
1875
+ // pairs) and narrates it as if it just happened — each such L1 then
1876
+ // compounds through merges (the "68 initiations" incident). Store a
1877
+ // mechanical stub without an LLM call instead. Chunks with any
1878
+ // non-text blocks (tool cycles, images) are never stubbed.
1879
+ const minChunkChars = this.config.minChunkCharsForLLM ?? 200;
1880
+ if (minChunkChars > 0) {
1881
+ let substantiveChars = 0;
1882
+ let hasNonText = false;
1883
+ for (const m of chunk.messages) {
1884
+ for (const b of m.content) {
1885
+ if (b.type === 'text') substantiveChars += b.text.trim().length;
1886
+ else hasNonText = true;
1887
+ }
1888
+ }
1889
+ if (!hasNonText && substantiveChars < minChunkChars) {
1890
+ const messageIds = chunk.messages.map(m => m.id);
1891
+ const stub: SummaryEntry = {
1892
+ id: `L1-${this.nextSummaryIdCounter()}`,
1893
+ level: 1,
1894
+ content:
1895
+ `(A quiet stretch: ${chunk.messages.length} messages of routine ` +
1896
+ `system traffic — heartbeats, empty turns, notices. Nothing ` +
1897
+ `happened worth remembering.)`,
1898
+ tokens: 40,
1899
+ sourceLevel: 0,
1900
+ sourceIds: messageIds,
1901
+ sourceRange: { first: messageIds[0], last: messageIds[messageIds.length - 1] },
1902
+ created: Date.now(),
1903
+ phaseType: chunk.phaseType,
1904
+ };
1905
+ this.pushSummary(stub);
1906
+ chunk.compressed = true;
1907
+ chunk.summaryId = stub.id;
1908
+ this.markChunkRecordCompressed(chunk.recordId, stub.id);
1909
+ this._compressionCount++;
1910
+ logCompressionCall({
1911
+ operation: 'compress_l1',
1912
+ system: null,
1913
+ messages: [],
1914
+ metadata: {
1915
+ stub: true,
1916
+ chunk_message_ids: messageIds,
1917
+ chunk_size: chunk.messages.length,
1918
+ substantive_chars: substantiveChars,
1919
+ min_chunk_chars: minChunkChars,
1920
+ summary_id: stub.id,
1921
+ },
1922
+ response: stub.content,
1923
+ });
1924
+ this.checkMergeThreshold();
1925
+ return;
1926
+ }
1927
+ }
1928
+
1413
1929
  // Build the KV-preserving prompt per hermes-autobio spec:
1414
1930
  //
1415
- // 1. Prior summaries narrativized as CM-asks / agent-recalls
1931
+ // 1. Headthe raw chronicle opening (identity anchor), FIRST,
1932
+ // exactly where the original instance saw it. It MUST precede
1933
+ // the recall pairs: when it followed them (pre-2026-07 order),
1934
+ // it read as the most recent live conversation, and for thin
1935
+ // chunks the summarizer narrated the head as fresh events
1936
+ // ("Antra came to me to explore the transformation story
1937
+ // again…"), compounding across merges into runaway false
1938
+ // memories (the "68 initiations" incident). Chronological
1939
+ // order is also the KV-stable order — the head never changes.
1940
+ // (executeMerge always had head-first; this site was the odd
1941
+ // one out.)
1942
+ // 2. Prior summaries — narrativized as CM-asks / agent-recalls
1416
1943
  // pairs, in source order. The unmerged frontier of the
1417
1944
  // summary forest: any summary that has not yet been merged
1418
1945
  // into a higher level. After merges run, the L_{k+1} replaces
1419
1946
  // its L_k children — using the children plus their parent
1420
1947
  // doubles the prompt size unboundedly.
1421
- // 2. Headraw messages before the chunk that aren't already
1422
- // represented by a prior summary.
1423
- // 3. Marker — in-band signal that a memory is about to form.
1424
- // 4. Chunk — raw messages being compressed, as the agent
1948
+ // 3. Raw middle — messages between head and chunk not covered by
1949
+ // any summary (usually empty).
1950
+ // 4. Marker — in-band signal that a memory is about to form.
1951
+ // 5. Chunk — raw messages being compressed, as the agent
1425
1952
  // experienced them.
1426
- // 5. Instruction — doc-aware if the chunk is part of a bodyGroup.
1953
+ // 6. Instruction — doc-aware if the chunk is part of a bodyGroup.
1427
1954
  //
1428
1955
  // There is intentionally NO tail_after_chunk: that would leak
1429
1956
  // future information into the model's KV state and corrupt the
1430
1957
  // as-of framing of memory formation.
1431
1958
  const llmMessages: Array<{ participant: string; content: ContentBlock[] }> = [];
1432
1959
 
1433
- // ---- 1. Prior recall pairs ----
1960
+ // ---- 1. Head window (raw, ALWAYS present, FIRST) ----
1961
+ //
1962
+ // The head is the foundational identity anchor: the actual opening
1963
+ // of the chronicle (the user's first message, the agent's first
1964
+ // reply, the system context if any). It establishes WHO is speaking
1965
+ // to WHOM. Without it, when the chunk content is heavily first-person
1966
+ // from someone other than the agent (e.g., a user-shared document),
1967
+ // the agent loses its first-person grounding and drifts into the
1968
+ // content author's voice.
1969
+ //
1970
+ // The head is the configured head window — not "everything before
1971
+ // the chunk." For doc-heavy chronicles, "everything before" would
1972
+ // be hundreds of thousands of tokens; the recall pairs below
1973
+ // represent that intermediate content. The head is just the
1974
+ // permanent prefix that the original instance always saw.
1975
+ //
1976
+ // Head messages are excluded from compression by `getCompressibleMessages`
1977
+ // (they're outside the chunking range), so they won't appear in
1978
+ // any L1's sourceIds — no overlap with the recall pairs below.
1979
+ const allMessages = ctx.messageStore.getAll();
1980
+ const headStartIdx = this.getHeadWindowStartIndex(ctx.messageStore);
1981
+ const headEndIdx = this.getHeadWindowEnd(ctx.messageStore);
1982
+ for (let i = headStartIdx; i < headEndIdx && i < allMessages.length; i++) {
1983
+ const m = allMessages[i];
1984
+ llmMessages.push({ participant: m.participant, content: m.content });
1985
+ }
1986
+
1987
+ // ---- 2. Prior recall pairs ----
1434
1988
  // Filter to the unmerged frontier: any summary whose `mergedInto`
1435
1989
  // is unset. After merge, the children's mergedInto points at the
1436
1990
  // parent and the parent stands alone with that source range. The
@@ -1439,7 +1993,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1439
1993
  // converged to ~500 L1s that never aged out, blowing the 200k
1440
1994
  // window around chunk 118.
1441
1995
  const priorSummaries = this.summaries
1442
- .filter((s) => !s.mergedInto)
1996
+ // Skip empty-content summaries: emitting `{type:'text', text:''}` as a
1997
+ // recall pair triggers Anthropic 400 "text content blocks must be
1998
+ // non-empty", which stalls ALL compression (mirrors the render-path guard
1999
+ // + load-drop). A single empty summary otherwise poisons every compression.
2000
+ .filter((s) => !s.mergedInto && !!s.content && s.content.trim().length > 0)
1443
2001
  .sort((a, b) => a.sourceRange.first.localeCompare(b.sourceRange.first));
1444
2002
 
1445
2003
  // Token-budget cap (see capRecallPairs). Defense-in-depth: even with
@@ -1476,33 +2034,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1476
2034
  });
1477
2035
  }
1478
2036
 
1479
- // ---- 2. Head window (raw, ALWAYS present) ----
1480
- //
1481
- // The head is the foundational identity anchor: the actual opening
1482
- // of the chronicle (the user's first message, the agent's first
1483
- // reply, the system context if any). It establishes WHO is speaking
1484
- // to WHOM. Without it, when the chunk content is heavily first-person
1485
- // from someone other than the agent (e.g., a user-shared document),
1486
- // the agent loses its first-person grounding and drifts into the
1487
- // content author's voice.
1488
- //
1489
- // The head is the configured head window — not "everything before
1490
- // the chunk." For doc-heavy chronicles, "everything before" would
1491
- // be hundreds of thousands of tokens; the recall pairs already
1492
- // represent that intermediate content. The head is just the
1493
- // permanent prefix that the original instance always saw.
1494
- //
1495
- // Head messages are excluded from compression by `getCompressibleMessages`
1496
- // (they're outside the chunking range), so they won't appear in
1497
- // any L1's sourceIds — no overlap with the recall pairs above.
1498
- const allMessages = ctx.messageStore.getAll();
1499
- const headStartIdx = this.getHeadWindowStartIndex(ctx.messageStore);
1500
- const headEndIdx = this.getHeadWindowEnd(ctx.messageStore);
1501
- for (let i = headStartIdx; i < headEndIdx && i < allMessages.length; i++) {
1502
- const m = allMessages[i];
1503
- llmMessages.push({ participant: m.participant, content: m.content });
1504
- }
1505
-
2037
+ // ---- 3. Raw middle ----
1506
2038
  // Any raw messages between the head and the chunk that aren't yet
1507
2039
  // represented by any summary — usually empty in adaptive-resolution
1508
2040
  // mode, since chunking proceeds contiguously and summaries cover
@@ -1534,18 +2066,18 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1534
2066
  }
1535
2067
  }
1536
2068
 
1537
- // ---- 3. In-band marker ----
2069
+ // ---- 4. In-band marker ----
1538
2070
  llmMessages.push({
1539
2071
  participant: 'Context Manager',
1540
2072
  content: [{ type: 'text', text: COMPRESSION_MARKER }],
1541
2073
  });
1542
2074
 
1543
- // ---- 4. Chunk messages raw ----
2075
+ // ---- 5. Chunk messages raw ----
1544
2076
  for (const m of chunk.messages) {
1545
2077
  llmMessages.push({ participant: m.participant, content: m.content });
1546
2078
  }
1547
2079
 
1548
- // ---- 5. Instruction (reading-mode aware) ----
2080
+ // ---- 6. Instruction (reading-mode aware) ----
1549
2081
  //
1550
2082
  // When the chunk is a portion of a substantially larger sharded message
1551
2083
  // (≥ 2× chunk size), use the reading-mode instruction. It avoids the
@@ -1579,6 +2111,18 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1579
2111
  // by its tool_result, and any tool_result that doesn't follow a use.
1580
2112
  const cleaned = stripUnpairedToolBlocks(collapsed);
1581
2113
 
2114
+ // Without the agent's live tool definitions, a request that replays
2115
+ // tool-block-bearing history is deterministically refused
2116
+ // (reasoning_extraction -- see the `tools` comment below). Tools are
2117
+ // pushed by the host on every activation; before the first activation
2118
+ // of a session, defer rather than burn a doomed full-window call.
2119
+ const chunkHasToolBlocks = cleaned.some(m =>
2120
+ m.content.some((b: ContentBlock) => b.type === 'tool_use' || b.type === 'tool_result'));
2121
+ if (chunkHasToolBlocks && !(ctx.tools && ctx.tools.length > 0)) {
2122
+ console.warn('[autobiographical] deferring chunk compression: history contains tool blocks but host has not provided tool definitions yet (ctx.tools empty) — will retry after next activation');
2123
+ return;
2124
+ }
2125
+
1582
2126
  // NO system prompt. The agent's identity is established by the head
1583
2127
  // (the actual conversation opening — user message + agent reply that
1584
2128
  // grounded the original instance). A system prompt would (a) add a
@@ -1588,11 +2132,41 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1588
2132
  // structural one carried by the conversation itself. Anchoring
1589
2133
  // identity by the chronicle's actual head is more honest.
1590
2134
  const request: NormalizedRequest = {
1591
- messages: cleaned.map(m => ({ participant: m.participant, content: m.content })),
2135
+ // EXPLICIT image-loss opt-in (2026-07-12): summarizer prompts replay
2136
+ // raw history that can carry more inline image bytes than the API's
2137
+ // request cap. Dropping the OLDEST images from the summarizer's view is
2138
+ // acceptable policy here — the summary describes the span, it does not
2139
+ // preserve pixels — and membrane error-logs every exercised shed. All
2140
+ // other callers fail loudly instead (no silent transport mutation).
2141
+ shedOversizeImages: true,
2142
+ // Sanitize: strip empty text blocks (`{type:'text',text:''}`) and drop any
2143
+ // message left with no content. An empty-content turn (e.g. a silent/skip
2144
+ // turn that produced no text) otherwise reaches the API as an empty text
2145
+ // block → 400 "text content blocks must be non-empty", which throws in the
2146
+ // speculative drain and stalls ALL compression. (Twin of the empty-summary
2147
+ // recall-header guard — together they cover every source of the 400.)
2148
+ messages: cleaned
2149
+ .map(m => ({ participant: m.participant, content: stripThinkingBlocks(stripEmptyTextBlocks(m.content)) }))
2150
+ .filter(m => m.content.length > 0),
1592
2151
  config: {
1593
2152
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
1594
- maxTokens: Math.round(targetTokens * 1.5),
2153
+ // Generous output ceiling so a memory-write is never truncated mid-thought:
2154
+ // targetTokens is a *target*, not a cap, and adaptive models routinely
2155
+ // overshoot a ~2k target. Was `* 1.5` (=3000 at the 2k default), which cut
2156
+ // off rich memories (stop=max_tokens).
2157
+ maxTokens: Math.max(16000, Math.round(targetTokens * 1.5)),
1595
2158
  },
2159
+ // Declare the agent's live tools. A summarizer request that replays
2160
+ // tool_use/tool_result history with NO tools param reads to Anthropic's
2161
+ // safety classifier as a foreign agent trace being duplicated ->
2162
+ // deterministic reasoning_extraction refusal of every memory-write
2163
+ // (labclaude incident, 2026-07-09; 268 refusals). Declaring the same
2164
+ // tools the live instance runs with is also strictly MORE faithful to
2165
+ // the original context, so it is the KV-honest choice, not a
2166
+ // workaround. Undefined before the first activation of a session --
2167
+ // acceptable: those chunks stay raw and are retried after the agent's
2168
+ // first turn (see the defer guard in compressChunkHierarchical).
2169
+ tools: ctx.tools,
1596
2170
  };
1597
2171
 
1598
2172
  const callStart = Date.now();
@@ -1612,12 +2186,45 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1612
2186
  .join('\n');
1613
2187
  logResponse = summaryText;
1614
2188
 
2189
+ // A bugged/empty generation (summarizer returned no text — spent budget on
2190
+ // thinking, truncated, etc.) must NOT be stored: recalled later it becomes
2191
+ // an empty assistant text block → Anthropic 400 "content must be non-empty".
2192
+ // Leave the chunk raw rather than poisoning memory with an empty summary.
2193
+ if (!summaryText.trim()) {
2194
+ console.warn(`[autobiographical] empty L1 summary for chunk of ${chunk.messages.length} msgs — skipping (chunk left raw)`);
2195
+ return;
2196
+ }
2197
+
2198
+ // Re-check the dedup guard AFTER the await: summary state may have
2199
+ // changed while the LLM call was in flight (persisted-state reload,
2200
+ // or any future concurrent producer). Discarding a paid-for result
2201
+ // is cheaper than storing a duplicate L1 over the same messages.
2202
+ const postExisting = this.summaries.find(
2203
+ s => s.level === 1 && s.sourceIds.join(':') === chunkIdKey
2204
+ );
2205
+ if (postExisting) {
2206
+ chunk.compressed = true;
2207
+ chunk.summaryId = postExisting.id;
2208
+ return;
2209
+ }
2210
+
1615
2211
  const messageIds = chunk.messages.map(m => m.id);
1616
2212
  const entry: SummaryEntry = {
1617
2213
  id: `L1-${this.nextSummaryIdCounter()}`,
1618
2214
  level: 1,
1619
2215
  content: summaryText,
1620
- tokens: Math.ceil(summaryText.length / 4),
2216
+ // Exact when available (2026-07-12): the compression response's own
2217
+ // usage.outputTokens IS the true token count of the text it just
2218
+ // wrote — the single most-reused number in the pyramid (fold floor,
2219
+ // middle budget, recall caps). Estimate only as fallback.
2220
+ tokens:
2221
+ response.usage?.outputTokens &&
2222
+ response.usage.outputTokens > 0 &&
2223
+ // outputTokens includes scratch thinking when present — only exact
2224
+ // when the whole response is the summary text itself.
2225
+ !response.content.some(b => b.type === 'thinking' || b.type === 'redacted_thinking')
2226
+ ? response.usage.outputTokens
2227
+ : Math.ceil(summaryText.length / 3),
1621
2228
  sourceLevel: 0,
1622
2229
  sourceIds: messageIds,
1623
2230
  sourceRange: {
@@ -1631,6 +2238,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1631
2238
  this.pushSummary(entry);
1632
2239
  chunk.compressed = true;
1633
2240
  chunk.summaryId = entry.id;
2241
+ this.markChunkRecordCompressed(chunk.recordId, entry.id);
1634
2242
  this._compressionCount++;
1635
2243
  logSummaryId = entry.id;
1636
2244
 
@@ -1680,7 +2288,61 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1680
2288
  * already-eligible siblings, producing N near-identical higher-level
1681
2289
  * summaries when the queue eventually drains.
1682
2290
  */
2291
+ /**
2292
+ * Pick merge sources as the oldest CONTIGUOUS run (2026-07-12 fix).
2293
+ *
2294
+ * The old rule merged "whatever N are unmerged" in creation order. On a
2295
+ * store whose frontier held both June L2s and a July L2, that minted an
2296
+ * L3 spanning months of already-merged history (mythos L3-415, 0-3853 of
2297
+ * 3995 messages) — a group whose range straddles the recent window can
2298
+ * never fold (group-atomicity vs the raw zone), so the whole deep lineage
2299
+ * above it went unusable and the fold floor stopped fitting the budget.
2300
+ *
2301
+ * Rules: order candidates by live source position; break runs where the
2302
+ * positional gap exceeds `mergeContiguityGapLimit` (holes from wiped/
2303
+ * pruned nodes are fine, cross-era bridges are not); exclude candidates
2304
+ * whose OWN span exceeds `mergeMaxSourceSpanMessages` (replay-era wide-
2305
+ * span summaries would bridge anything they join); merge the oldest run
2306
+ * that still has `threshold` members.
2307
+ */
2308
+ protected contiguousMergeCandidates(
2309
+ unmerged: SummaryEntry[],
2310
+ threshold: number,
2311
+ ): SummaryEntry[] | null {
2312
+ if (unmerged.length < threshold) return null;
2313
+ const messageOrder = new Map<MessageId, number>();
2314
+ let seq = 0;
2315
+ for (const ch of this.chunks) {
2316
+ for (const m of ch.messages) messageOrder.set(m.id, seq++);
2317
+ }
2318
+ const gapLimit = this.config.mergeContiguityGapLimit ?? 300;
2319
+ const spanLimit = this.config.mergeMaxSourceSpanMessages ?? 1500;
2320
+ const withPos: Array<{ s: SummaryEntry; first: number; last: number }> = [];
2321
+ for (const s of unmerged) {
2322
+ const first = messageOrder.get(s.sourceRange.first);
2323
+ const last = messageOrder.get(s.sourceRange.last);
2324
+ if (first === undefined || last === undefined) continue;
2325
+ if (Math.abs(last - first) > spanLimit) continue; // wide-span quarantine
2326
+ withPos.push({ s, first: Math.min(first, last), last: Math.max(first, last) });
2327
+ }
2328
+ withPos.sort((a, b) => a.first - b.first);
2329
+ let run: typeof withPos = [];
2330
+ let runEnd = -Infinity;
2331
+ for (const x of withPos) {
2332
+ if (run.length > 0 && x.first - runEnd > gapLimit) {
2333
+ if (run.length >= threshold) break; // oldest qualifying run wins
2334
+ run = [];
2335
+ runEnd = -Infinity;
2336
+ }
2337
+ run.push(x);
2338
+ runEnd = Math.max(runEnd, x.last);
2339
+ }
2340
+ if (run.length < threshold) return null;
2341
+ return run.slice(0, threshold).map((x) => x.s);
2342
+ }
2343
+
1683
2344
  protected checkMergeThreshold(): void {
2345
+ phaseChannel.report('merge-threshold'); // liveness-watchdog phase
1684
2346
  if (this.config.speculativeProduction) {
1685
2347
  this.checkMergeThresholdRecursive();
1686
2348
  return;
@@ -1701,11 +2363,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1701
2363
  const unmergedL1 = this.summaries.filter(
1702
2364
  s => s.level === 1 && !s.mergedInto && !queuedL1.has(s.id),
1703
2365
  );
1704
- if (unmergedL1.length >= threshold) {
1705
- const toMerge = unmergedL1.slice(0, threshold);
2366
+ const l1Run = this.contiguousMergeCandidates(unmergedL1, threshold);
2367
+ if (l1Run) {
1706
2368
  this.enqueueMerge({
1707
2369
  level: 2,
1708
- sourceIds: toMerge.map(s => s.id),
2370
+ sourceIds: l1Run.map(s => s.id),
1709
2371
  });
1710
2372
  }
1711
2373
 
@@ -1713,11 +2375,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1713
2375
  const unmergedL2 = this.summaries.filter(
1714
2376
  s => s.level === 2 && !s.mergedInto && !queuedL2.has(s.id),
1715
2377
  );
1716
- if (unmergedL2.length >= threshold) {
1717
- const toMerge = unmergedL2.slice(0, threshold);
2378
+ const l2Run = this.contiguousMergeCandidates(unmergedL2, threshold);
2379
+ if (l2Run) {
1718
2380
  this.enqueueMerge({
1719
2381
  level: 3,
1720
- sourceIds: toMerge.map(s => s.id),
2382
+ sourceIds: l2Run.map(s => s.id),
1721
2383
  });
1722
2384
  }
1723
2385
  }
@@ -1761,11 +2423,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1761
2423
  const unmerged = this.summaries.filter(
1762
2424
  s => s.level === level && !getSummaryParentId(s) && !queued.has(s.id),
1763
2425
  );
1764
- if (unmerged.length >= threshold) {
1765
- const toMerge = unmerged.slice(0, threshold);
2426
+ const run = this.contiguousMergeCandidates(unmerged, threshold);
2427
+ if (run) {
1766
2428
  this.enqueueMerge({
1767
2429
  level: level + 1,
1768
- sourceIds: toMerge.map(s => s.id),
2430
+ sourceIds: run.map(s => s.id),
1769
2431
  });
1770
2432
  }
1771
2433
  }
@@ -1885,7 +2547,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1885
2547
  // parent — the same rule used everywhere else and now in
1886
2548
  // `compressChunkHierarchical`. The cap below is the defense-in-depth.
1887
2549
  const priorSummariesAll = this.summaries
1888
- .filter((s) => !s.mergedInto)
2550
+ // Skip empty-content summaries (see compressChunkHierarchical): an empty
2551
+ // text block in the merge recall header 400s and stalls compression.
2552
+ .filter((s) => !s.mergedInto && !!s.content && s.content.trim().length > 0)
1889
2553
  .filter((s) => {
1890
2554
  for (const lid of s.sourceIds) if (sourceLeafIds.has(lid)) return false;
1891
2555
  const firstIdx = allMessages.findIndex((m) => m.id === s.sourceRange.first);
@@ -2049,11 +2713,41 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2049
2713
  // (present at the start of llmMessages above) and by the prior
2050
2714
  // recall pairs. Same rationale as compressChunkHierarchical.
2051
2715
  const request: NormalizedRequest = {
2052
- messages: cleaned.map(m => ({ participant: m.participant, content: m.content })),
2716
+ // EXPLICIT image-loss opt-in (2026-07-12): summarizer prompts replay
2717
+ // raw history that can carry more inline image bytes than the API's
2718
+ // request cap. Dropping the OLDEST images from the summarizer's view is
2719
+ // acceptable policy here — the summary describes the span, it does not
2720
+ // preserve pixels — and membrane error-logs every exercised shed. All
2721
+ // other callers fail loudly instead (no silent transport mutation).
2722
+ shedOversizeImages: true,
2723
+ // Sanitize: strip empty text blocks (`{type:'text',text:''}`) and drop any
2724
+ // message left with no content. An empty-content turn (e.g. a silent/skip
2725
+ // turn that produced no text) otherwise reaches the API as an empty text
2726
+ // block → 400 "text content blocks must be non-empty", which throws in the
2727
+ // speculative drain and stalls ALL compression. (Twin of the empty-summary
2728
+ // recall-header guard — together they cover every source of the 400.)
2729
+ messages: cleaned
2730
+ .map(m => ({ participant: m.participant, content: stripThinkingBlocks(stripEmptyTextBlocks(m.content)) }))
2731
+ .filter(m => m.content.length > 0),
2053
2732
  config: {
2054
2733
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
2055
- maxTokens: Math.round(targetTokens * 1.5),
2734
+ // Generous output ceiling so a memory-write is never truncated mid-thought:
2735
+ // targetTokens is a *target*, not a cap, and adaptive models routinely
2736
+ // overshoot a ~2k target. Was `* 1.5` (=3000 at the 2k default), which cut
2737
+ // off rich memories (stop=max_tokens).
2738
+ maxTokens: Math.max(16000, Math.round(targetTokens * 1.5)),
2056
2739
  },
2740
+ // Declare the agent's live tools. A summarizer request that replays
2741
+ // tool_use/tool_result history with NO tools param reads to Anthropic's
2742
+ // safety classifier as a foreign agent trace being duplicated ->
2743
+ // deterministic reasoning_extraction refusal of every memory-write
2744
+ // (labclaude incident, 2026-07-09; 268 refusals). Declaring the same
2745
+ // tools the live instance runs with is also strictly MORE faithful to
2746
+ // the original context, so it is the KV-honest choice, not a
2747
+ // workaround. Undefined before the first activation of a session --
2748
+ // acceptable: those chunks stay raw and are retried after the agent's
2749
+ // first turn (see the defer guard in compressChunkHierarchical).
2750
+ tools: ctx.tools,
2057
2751
  };
2058
2752
 
2059
2753
  const callStart = Date.now();
@@ -2070,6 +2764,14 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2070
2764
  .join('\n');
2071
2765
  logResponse = mergedText;
2072
2766
 
2767
+ // Empty merged generation: skip the merge entirely (do NOT push or mark
2768
+ // sources merged) so we never store/recall an empty summary. Sources stay
2769
+ // unmerged and can be retried.
2770
+ if (!mergedText.trim()) {
2771
+ console.warn(`[autobiographical] empty merged L${targetLevel} summary (${sources.length} sources) — skipping merge`);
2772
+ return;
2773
+ }
2774
+
2073
2775
  // Compute source range from constituent summaries
2074
2776
  const sourceRange = {
2075
2777
  first: sources[0].sourceRange.first,
@@ -2081,7 +2783,15 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2081
2783
  id: `L${targetLevel}-${this.nextSummaryIdCounter()}`,
2082
2784
  level: targetLevel,
2083
2785
  content: mergedText,
2084
- tokens: Math.ceil(mergedText.length / 4),
2786
+ // Exact when available — see the L1 site.
2787
+ tokens:
2788
+ response.usage?.outputTokens &&
2789
+ response.usage.outputTokens > 0 &&
2790
+ // outputTokens includes scratch thinking when present — only exact
2791
+ // when the whole response is the summary text itself.
2792
+ !response.content.some(b => b.type === 'thinking' || b.type === 'redacted_thinking')
2793
+ ? response.usage.outputTokens
2794
+ : Math.ceil(mergedText.length / 3),
2085
2795
  sourceLevel,
2086
2796
  sourceIds,
2087
2797
  sourceRange,
@@ -2148,11 +2858,20 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2148
2858
  * See `docs/adaptive-resolution-design.md` §3, §5.
2149
2859
  */
2150
2860
  protected selectAdaptive(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
2861
+ phaseChannel.report('context-build'); // liveness-watchdog phase
2151
2862
  this.rsBegin();
2152
2863
  const entries: ContextEntry[] = [];
2153
2864
  const maxTokens = budget.maxTokens - budget.reserveForResponse;
2865
+ const overBudgetGraceRatio = Math.max(0, this.config.overBudgetGraceRatio ?? 0);
2866
+ const rejectionBudget = Math.floor(maxTokens * (1 + overBudgetGraceRatio));
2867
+ // Closed-loop calibration: apply the persisted multiplier BEFORE any
2868
+ // estimate is taken this compile.
2869
+ this.loadCalibration(store);
2154
2870
  const messages = store.getAll();
2155
2871
  const msgCap = this.config.maxMessageTokens;
2872
+ // Post-strip estimates (see postStripEstimates): every budgeting site in
2873
+ // this method prices a message the way the stripped render will cost it.
2874
+ const pse = this.postStripEstimates(store);
2156
2875
 
2157
2876
  // ----- 1. Build head/tail sets and emit head entries -----
2158
2877
  const headStart = this.getHeadWindowStartIndex(store);
@@ -2170,8 +2889,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2170
2889
  for (let i = headStart; i < headEnd && i < messages.length; i++) {
2171
2890
  const msg = messages[i];
2172
2891
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
2173
- const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
2174
- if (totalTokens + tokens > maxTokens) break;
2892
+ const tokens = msgCap > 0 ? Math.min(pse[i], msgCap + 50) : pse[i];
2893
+ if (totalTokens + tokens > rejectionBudget) break;
2175
2894
  entries.push({
2176
2895
  index: entries.length,
2177
2896
  sourceMessageId: msg.id,
@@ -2192,7 +2911,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2192
2911
  const effectiveRecentStart = Math.max(recentStart, headEnd);
2193
2912
  for (let i = effectiveRecentStart; i < messages.length; i++) {
2194
2913
  const msg = messages[i];
2195
- const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
2914
+ const tokens = msgCap > 0 ? Math.min(pse[i], msgCap + 50) : pse[i];
2196
2915
  tailMessageIds.add(msg.id);
2197
2916
  tailTokens += tokens;
2198
2917
  }
@@ -2211,6 +2930,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2211
2930
  // Pinned-position set so the picker doesn't fold messages the user
2212
2931
  // explicitly marked as keep-raw. Built once and reused.
2213
2932
  const pinnedSet = this.pinnedPositions(messages);
2933
+ // V2 dynamic-pin fold-depth bounds (level / maxLevel). A position with a
2934
+ // bound is NOT a classic force-raw pin — the KV-stable controller must be
2935
+ // able to move it to/within its bound — so it renders as `pinned: false`
2936
+ // carrying `pinLevel` / `pinMaxLevel` instead.
2937
+ const pinBounds = this.pinLevelBounds(messages);
2214
2938
 
2215
2939
  // O(1) summary lookup for findAncestorAt — avoids O(summaries) find()
2216
2940
  // calls during emission.
@@ -2222,16 +2946,22 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2222
2946
  const msg = messages[i];
2223
2947
  const ch = chunksByMessageId.get(msg.id);
2224
2948
  const tokens = msgCap > 0
2225
- ? Math.min(store.estimateTokens(msg), msgCap + 50)
2226
- : store.estimateTokens(msg);
2949
+ ? Math.min(pse[i], msgCap + 50)
2950
+ : pse[i];
2951
+ const bound = pinBounds.get(i);
2227
2952
  pickerChunks.push({
2228
2953
  id: msg.id,
2229
2954
  sequence: i,
2230
2955
  rawTokens: tokens,
2231
2956
  currentResolution: this.resolutions.get(msg.id) ?? 0,
2232
2957
  lockedByAgent: this.locked.has(msg.id),
2233
- pinned: pinnedSet.has(i),
2958
+ // A classic pin (in pinnedSet with no level bound) stays force-raw. A
2959
+ // leveled pin is not force-raw; it carries its bound instead.
2960
+ pinned: pinnedSet.has(i) && bound === undefined,
2961
+ pinLevel: bound?.level,
2962
+ pinMaxLevel: bound?.maxLevel,
2234
2963
  l1Id: ch?.summaryId,
2964
+ salience: AutobiographicalStrategy.staticSalience(msg),
2235
2965
  });
2236
2966
  }
2237
2967
 
@@ -2240,8 +2970,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2240
2970
  for (let i = headStart; i < headEnd && i < messages.length; i++) {
2241
2971
  const msg = messages[i];
2242
2972
  const tokens = msgCap > 0
2243
- ? Math.min(store.estimateTokens(msg), msgCap + 50)
2244
- : store.estimateTokens(msg);
2973
+ ? Math.min(pse[i], msgCap + 50)
2974
+ : pse[i];
2245
2975
  pickerChunks.push({
2246
2976
  id: msg.id,
2247
2977
  sequence: i,
@@ -2255,8 +2985,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2255
2985
  for (let i = effectiveRecentStart; i < messages.length; i++) {
2256
2986
  const msg = messages[i];
2257
2987
  const tokens = msgCap > 0
2258
- ? Math.min(store.estimateTokens(msg), msgCap + 50)
2259
- : store.estimateTokens(msg);
2988
+ ? Math.min(pse[i], msgCap + 50)
2989
+ : pse[i];
2260
2990
  pickerChunks.push({
2261
2991
  id: msg.id,
2262
2992
  sequence: i,
@@ -2279,7 +3009,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2279
3009
  }
2280
3010
 
2281
3011
  // ----- 4. Run the picker -----
2282
- const totalBudget = maxTokens - totalTokens; // tokens left after head
3012
+ // The picker's token count ALREADY includes the pinned head+tail (it gets
3013
+ // headTokens/tailTokens in pickerInputs and result.finalTokens covers them).
3014
+ // So the budget it folds against is the full maxTokens — NOT maxTokens-head,
3015
+ // which double-counts the head (reserves it twice: once here, once because
3016
+ // finalTokens already includes it). The old form threw ~head-tokens early at
3017
+ // tight budgets and quietly under-used the budget by ~head everywhere.
3018
+ const totalBudget = maxTokens;
2283
3019
  const slack = this.config.compressionSlackRatio ?? 0.1;
2284
3020
  const foldingBudget: FoldingBudget = {
2285
3021
  totalBudget,
@@ -2302,6 +3038,17 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2302
3038
  const picker = this.buildPicker(pickerInputs);
2303
3039
  const result = picker.run(pickerInputs, foldingBudget);
2304
3040
 
3041
+ // Every trust-region override is loud (design §13.4) — silence was half
3042
+ // of the 2026-07-12 incident.
3043
+ const plan = this._lastKvStable?.lastPlan();
3044
+ if (plan?.override) {
3045
+ console.error(
3046
+ `[kv-escalation] override=${plan.override} perturbation=${plan.perturbation}` +
3047
+ ` tokens=${plan.tokens} budget=${foldingBudget.totalBudget}` +
3048
+ ` (see adaptive-resolution-design.md §13.4)`,
3049
+ );
3050
+ }
3051
+
2305
3052
  // Commit the new resolutions back to strategy state for next compile.
2306
3053
  // Persist to chronicle only if anything actually changed — avoids
2307
3054
  // unnecessary state-slot writes on no-op compiles (which is the common
@@ -2342,9 +3089,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2342
3089
  // an OverBudgetError to the host rather than silently dropping entries.
2343
3090
  // The strategy has done all it can; the application has to decide what
2344
3091
  // to do next (raise budget, switch model, drop windows, etc.).
2345
- if (result.exhausted && result.finalTokens > totalBudget) {
3092
+ if (result.exhausted && result.finalTokens > rejectionBudget) {
2346
3093
  throw new OverBudgetError({
2347
- budget: totalBudget,
3094
+ budget: rejectionBudget,
2348
3095
  actual: result.finalTokens,
2349
3096
  diagnostics: {
2350
3097
  headTokens,
@@ -2416,7 +3163,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2416
3163
  // (`maxMessageTokens` is for per-message caps on chat / tool
2417
3164
  // results, not for sharded bodyGroup composites.)
2418
3165
  const tokens = this.estimateTokens(content);
2419
- if (totalTokens + tokens > maxTokens) {
3166
+ if (totalTokens + tokens > rejectionBudget) {
2420
3167
  currentRun = null;
2421
3168
  return false;
2422
3169
  }
@@ -2448,7 +3195,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2448
3195
  sourceRelation: 'derived',
2449
3196
  };
2450
3197
  const pairTokens = this.estimateTokens(questionEntry.content) + this.estimateTokens(answerEntry.content);
2451
- if (totalTokens + pairTokens > maxTokens) {
3198
+ if (totalTokens + pairTokens > rejectionBudget) {
2452
3199
  currentRun = null;
2453
3200
  return false;
2454
3201
  }
@@ -2506,8 +3253,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2506
3253
  const resolution = result.finalResolutions.get(msg.id) ?? 0;
2507
3254
  if (resolution === 0) {
2508
3255
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
2509
- const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
2510
- if (totalTokens + tokens > maxTokens) break;
3256
+ const tokens = msgCap > 0 ? Math.min(pse[i], msgCap + 50) : pse[i];
3257
+ if (totalTokens + tokens > rejectionBudget) break;
2511
3258
  entries.push({
2512
3259
  index: entries.length,
2513
3260
  sourceMessageId: msg.id,
@@ -2522,8 +3269,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2522
3269
  const ancestor = this.findAncestorAt(msg.id, resolution, chunksByMessageId, summariesById);
2523
3270
  if (!ancestor) {
2524
3271
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
2525
- const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
2526
- if (totalTokens + tokens > maxTokens) break;
3272
+ const tokens = msgCap > 0 ? Math.min(pse[i], msgCap + 50) : pse[i];
3273
+ if (totalTokens + tokens > rejectionBudget) break;
2527
3274
  entries.push({
2528
3275
  index: entries.length,
2529
3276
  sourceMessageId: msg.id,
@@ -2555,7 +3302,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2555
3302
  sourceRelation: 'derived',
2556
3303
  };
2557
3304
  const pairTokens = this.estimateTokens(questionEntry.content) + this.estimateTokens(answerEntry.content);
2558
- if (totalTokens + pairTokens > maxTokens) break;
3305
+ if (totalTokens + pairTokens > rejectionBudget) break;
2559
3306
  entries.push(questionEntry);
2560
3307
  entries.push(answerEntry);
2561
3308
  totalTokens += pairTokens;
@@ -2565,7 +3312,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2565
3312
  }
2566
3313
 
2567
3314
  // ----- 6. Emit tail entries newest-first eviction (matches existing behavior) -----
2568
- const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
3315
+ const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, rejectionBudget, totalTokens, pse);
2569
3316
  this.rsRaw('tail', tailStats.tokens, tailStats.messages);
2570
3317
 
2571
3318
  // ----- 7. Post-process: merge consecutive raw entries from the same bodyGroup -----
@@ -2578,6 +3325,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2578
3325
 
2579
3326
  this.pruneToolEntries(merged);
2580
3327
  this.trimOrphanedToolUse(merged);
3328
+ // Full pairing invariant over the final rendered context — catches the
3329
+ // mid-list orphans the trailing/leading trims can't (bug 6.7). The adaptive
3330
+ // path has the same mid-list orphan producers as hierarchical (budget
3331
+ // `break`s between pair members, raw emission interleaved with recall
3332
+ // pairs), and FKM defaults autobiographical strategies onto this path — so
3333
+ // the guard has to run here too. It's a no-op on already-valid output.
3334
+ this.enforceToolPairing(merged);
2581
3335
  // Strip stale images BEFORE placing markers and committing stats, so both
2582
3336
  // describe the post-strip context the agent actually receives.
2583
3337
  this.applyImageStripping(merged, store);
@@ -2588,9 +3342,100 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2588
3342
  // placement is the dominant KV lever).
2589
3343
  this.placeCacheMarkers(merged, headMessageIds, tailMessageIds);
2590
3344
  this.rsEnd();
3345
+ // Closed-loop calibration bookkeeping: the committed render stats total
3346
+ // (in CURRENT calibrated units) is what this compile claims the request
3347
+ // will cost — reportRealInputTokens compares provider usage against it.
3348
+ this._storeView = store;
3349
+ const rs = this.getRenderStats(store);
3350
+ this._lastCompileEstimate =
3351
+ rs.head.tokens + rs.tail.tokens + rs.middleRaw.tokens +
3352
+ rs.summaries.l1.tokens + rs.summaries.l2.tokens + rs.summaries.l3.tokens;
3353
+ this._calibrationArmed = true; // exactly one sample per compile
2591
3354
  return merged;
2592
3355
  }
2593
3356
 
3357
+ private _lastCompileEstimate = 0;
3358
+ private _storeView: MessageStoreView | null = null;
3359
+
3360
+ /**
3361
+ * Closed-loop estimator calibration (2026-07-12). Feed the REAL input total
3362
+ * for a request built from the latest compile (fresh + cache_read +
3363
+ * cache_creation, minus non-window overhead the caller knows about, e.g.
3364
+ * tool schemas). Maintains an EMA of real/estimated and applies it as the
3365
+ * store's global multiplier, persisted across restarts. The per-class rates
3366
+ * carry the shape; this carries the residual level.
3367
+ */
3368
+ reportRealInputTokens(realTotal: number): void {
3369
+ if (!Number.isFinite(realTotal) || realTotal <= 0) return;
3370
+ const est = this._lastCompileEstimate;
3371
+ if (!est || est <= 0) return;
3372
+
3373
+ // ARM-ONCE-PER-COMPILE (2026-07-12 regression fix). A turn makes MANY API
3374
+ // calls — tool-use rounds and max_tokens continuations each append to the
3375
+ // request — so only the FIRST completion after a compile was built from
3376
+ // the window this estimate describes. Feeding later calls compares a grown
3377
+ // request against the original estimate: ratios of 2.0-2.3 (est=224k
3378
+ // real=504k) drove the multiplier 1.0 -> 2.37 in minutes, inflating every
3379
+ // estimate (the fold floor went 62k -> 108k on unchanged content) and
3380
+ // exhausting the picker on every wake. One sample per compile, always.
3381
+ if (!this._calibrationArmed) return;
3382
+ this._calibrationArmed = false;
3383
+
3384
+ const ratio = realTotal / est;
3385
+ // SANITY BAND: a representative sample sits near 1. Anything wilder is a
3386
+ // structural mismatch (a request we didn't compile, a partial compile, a
3387
+ // provider quirk) — never evidence about chars-per-token. Log, don't learn.
3388
+ if (ratio < 0.6 || ratio > 1.8) {
3389
+ console.error(
3390
+ `[estimator-calibration] REJECTED out-of-band sample real/est=${ratio.toFixed(2)} ` +
3391
+ `(est=${Math.round(est / 1000)}k real=${Math.round(realTotal / 1000)}k) — ` +
3392
+ `not a window-shaped request; multiplier stays ${this._calibration.toFixed(2)}`,
3393
+ );
3394
+ return;
3395
+ }
3396
+
3397
+ const current = this._calibration;
3398
+ const observed = ratio * current; // back out the multiplier already applied
3399
+ const alpha = 0.2; // slow EMA: one wild request shouldn't yank the ruler
3400
+ const next = current + alpha * (observed - current);
3401
+ const clamped = Math.min(1.8, Math.max(0.6, next));
3402
+ if (Math.abs(clamped - current) / current > 0.02) {
3403
+ console.error(
3404
+ `[estimator-calibration] real/est=${ratio.toFixed(2)} ` +
3405
+ `multiplier ${current.toFixed(2)} -> ${clamped.toFixed(2)} (est=${Math.round(est / 1000)}k real=${Math.round(realTotal / 1000)}k)`,
3406
+ );
3407
+ }
3408
+ this._calibration = clamped;
3409
+ this.applyCalibration();
3410
+ try {
3411
+ this.store?.setStateJson(this.calibrationStateId, { multiplier: this._calibration, at: Date.now() });
3412
+ } catch { /* persistence is best-effort */ }
3413
+ }
3414
+
3415
+ private _calibrationArmed = false;
3416
+
3417
+ private _calibration = 1;
3418
+ private _calibrationLoaded = false;
3419
+
3420
+ protected applyCalibration(): void {
3421
+ this._storeView?.setTokenCalibration?.(this._calibration);
3422
+ }
3423
+
3424
+ /** Load the persisted multiplier once and push it into the store view. */
3425
+ protected loadCalibration(store: MessageStoreView): void {
3426
+ this._storeView = store;
3427
+ if (!this._calibrationLoaded) {
3428
+ this._calibrationLoaded = true;
3429
+ try {
3430
+ const saved = this.store?.getStateJson(this.calibrationStateId) as { multiplier?: number } | null;
3431
+ if (saved && Number.isFinite(saved.multiplier)) {
3432
+ this._calibration = Math.min(1.8, Math.max(0.6, saved.multiplier!));
3433
+ }
3434
+ } catch { /* absent slot is fine */ }
3435
+ }
3436
+ this.applyCalibration();
3437
+ }
3438
+
2594
3439
  /**
2595
3440
  * Place up to four `cache_control` breakpoints across the final ordered
2596
3441
  * entries: the head/system boundary, the end of the folded history (the
@@ -2644,12 +3489,23 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2644
3489
  ): ContextEntry[] {
2645
3490
  if (entries.length === 0) return entries;
2646
3491
 
2647
- // Look up bodyGroupId by sourceMessageId via the message store.
2648
- const groupOf = (sourceMessageId?: string): string | undefined => {
3492
+ // Look up bodyGroup metadata by sourceMessageId, memoized for the
3493
+ // duration of this pass. Entries reference the same messages repeatedly
3494
+ // (run-extension checks + the shardIndex sort comparator below), and
3495
+ // store.get() also resolves blobs — fetch each message at most once.
3496
+ const shardMeta = new Map<string, { groupId?: string; shardIndex?: number }>();
3497
+ const metaOf = (sourceMessageId?: string): { groupId?: string; shardIndex?: number } | undefined => {
2649
3498
  if (!sourceMessageId) return undefined;
2650
- const m = store.get(sourceMessageId);
2651
- return m?.bodyGroupId;
3499
+ let meta = shardMeta.get(sourceMessageId);
3500
+ if (!meta) {
3501
+ const m = store.get(sourceMessageId);
3502
+ meta = { groupId: m?.bodyGroupId, shardIndex: m?.shardIndex };
3503
+ shardMeta.set(sourceMessageId, meta);
3504
+ }
3505
+ return meta;
2652
3506
  };
3507
+ const groupOf = (sourceMessageId?: string): string | undefined =>
3508
+ metaOf(sourceMessageId)?.groupId;
2653
3509
 
2654
3510
  const out: ContextEntry[] = [];
2655
3511
  let i = 0;
@@ -2681,9 +3537,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2681
3537
  // ordering. (Head/tail emission keeps chronological order, but defending
2682
3538
  // against reorderings is cheap.)
2683
3539
  const sortedRun = [...run].sort((a, b) => {
2684
- const ma = a.sourceMessageId ? store.get(a.sourceMessageId) : null;
2685
- const mb = b.sourceMessageId ? store.get(b.sourceMessageId) : null;
2686
- return (ma?.shardIndex ?? 0) - (mb?.shardIndex ?? 0);
3540
+ const sa = metaOf(a.sourceMessageId)?.shardIndex ?? 0;
3541
+ const sb = metaOf(b.sourceMessageId)?.shardIndex ?? 0;
3542
+ return sa - sb;
2687
3543
  });
2688
3544
  // Build merged text content. Non-text blocks (rare in shards) are
2689
3545
  // preserved on the first shard's entry only.
@@ -2729,16 +3585,89 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2729
3585
  */
2730
3586
  protected buildPicker(inputs: PickerInputs): Picker {
2731
3587
  if (this.config.foldingStrategy === 'kv-stable') {
2732
- return new Picker(
2733
- new KvStableStrategy(inputs, {
2734
- reachTokens: this.config.kvStableReachTokens,
2735
- mergeThreshold: this.config.mergeThreshold,
2736
- }),
2737
- );
3588
+ const strategy = new KvStableStrategy(inputs, {
3589
+ reachTokens: this.config.kvStableReachTokens,
3590
+ qualityGapRatio: this.config.kvStableQualityGapRatio,
3591
+ mergeThreshold: this.config.mergeThreshold,
3592
+ });
3593
+ this._lastKvStable = strategy;
3594
+ return new Picker(strategy);
2738
3595
  }
3596
+ this._lastKvStable = null;
2739
3597
  return this.getAdaptivePicker();
2740
3598
  }
2741
3599
 
3600
+ /** The kv-stable strategy instance behind the most recent compile — kept for
3601
+ * `[kv-escalation]` observability (design §13.4: every override is loud). */
3602
+ private _lastKvStable: KvStableStrategy | null = null;
3603
+
3604
+ /**
3605
+ * Static salience prior (design §13.3) — "is the window the only copy?".
3606
+ * Content whose payload is externalized folds cheap: tool blocks
3607
+ * (re-derivable), fenced code (usually written to disk), images (the file/
3608
+ * CDN keeps them), bare link drops. Conversation exists nowhere but the
3609
+ * chronicle, so it stays at 1. Returns a value in [0.2, 1]; cheap,
3610
+ * deterministic, computed per message at picker-input construction.
3611
+ */
3612
+ protected static staticSalience(msg: StoredMessage): number {
3613
+ let totalChars = 0;
3614
+ let externalChars = 0;
3615
+ for (const block of msg.content) {
3616
+ const b = block as {
3617
+ type?: string;
3618
+ text?: string;
3619
+ input?: unknown;
3620
+ content?: unknown;
3621
+ };
3622
+ switch (b.type) {
3623
+ case 'text': {
3624
+ const t = b.text ?? '';
3625
+ totalChars += t.length;
3626
+ // Fenced code blocks.
3627
+ const fences = t.split('```');
3628
+ for (let i = 1; i < fences.length; i += 2) externalChars += fences[i].length;
3629
+ // Bare link-drop lines (the URL is the payload).
3630
+ for (const line of t.split('\n')) {
3631
+ const trimmed = line.trim();
3632
+ if (/^https?:\/\/\S+$/.test(trimmed)) externalChars += trimmed.length;
3633
+ }
3634
+ break;
3635
+ }
3636
+ case 'tool_use': {
3637
+ const n = JSON.stringify(b.input ?? {}).length;
3638
+ totalChars += n;
3639
+ externalChars += n;
3640
+ break;
3641
+ }
3642
+ case 'tool_result': {
3643
+ const n =
3644
+ typeof b.content === 'string'
3645
+ ? b.content.length
3646
+ : JSON.stringify(b.content ?? '').length;
3647
+ totalChars += n;
3648
+ externalChars += n;
3649
+ break;
3650
+ }
3651
+ case 'image': {
3652
+ // Estimate parity with the renderer's flat image cost; the payload
3653
+ // lives in the file/CDN, so it is fully externalized.
3654
+ totalChars += 6400; // ≈1600 tokens × 4 chars
3655
+ externalChars += 6400;
3656
+ break;
3657
+ }
3658
+ default: {
3659
+ const t = (b as { text?: string }).text ?? '';
3660
+ totalChars += t.length;
3661
+ }
3662
+ }
3663
+ }
3664
+ if (totalChars <= 0) return 1;
3665
+ const externalized = Math.min(1, externalChars / totalChars);
3666
+ // Fully-externalized content bottoms out at 0.2 — cheap, never free
3667
+ // (hard protections, not salience, are what make content unfoldable).
3668
+ return Math.max(0.2, 1 - 0.8 * externalized);
3669
+ }
3670
+
2742
3671
  /**
2743
3672
  * Walk the summary tree to find the L_k ancestor of a message.
2744
3673
  * Returns null if no ancestor exists at that level (e.g., L_k not yet produced).
@@ -2819,6 +3748,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2819
3748
  * Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
2820
3749
  */
2821
3750
  protected selectHierarchical(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
3751
+ phaseChannel.report('context-build'); // liveness-watchdog phase
2822
3752
  this.rsBegin();
2823
3753
  const entries: ContextEntry[] = [];
2824
3754
  const maxTokens = budget.maxTokens - budget.reserveForResponse;
@@ -2900,6 +3830,76 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2900
3830
  selectedSummaries.push(...l1Selected);
2901
3831
  totalSummaryTokens += l1Used;
2902
3832
 
3833
+ // Phase 3b: coverage repair (bug 6.9). getAntiRedundantSummaries excluded
3834
+ // an L2 (or L3) when ALL of its children were in the CANDIDATE shown-set —
3835
+ // computed before budget selection. If the budget then dropped some of
3836
+ // those children (e.g. KnowledgeStrategy's research L1 cap), the covered
3837
+ // history appears at NEITHER level: a silent memory hole. Re-include any
3838
+ // excluded L2/L3 whose children did not all make the final selection.
3839
+ // Some overlap with the children that DID survive is accepted — coverage
3840
+ // beats perfect dedup here.
3841
+ //
3842
+ // Repairs are additionally bounded by a per-level ALLOWANCE (a fraction of
3843
+ // that level's budget), not just the overall context budget. The
3844
+ // excluded-with-partially-dropped-children state only arises from a store
3845
+ // damaged mid-merge (the crash window at compressChunkHierarchical, or the
3846
+ // legacy setMergedInto index-desync). On such a store MANY L2s can be in
3847
+ // this state at once; without a cap, re-including all of them at full size
3848
+ // would starve the recent window via Phase 4's newest-first eviction. When
3849
+ // repairs exceed the allowance we stop re-including and warn — a corrupted
3850
+ // store announces itself instead of silently trading recent messages for
3851
+ // redundant summaries.
3852
+ {
3853
+ // Allowance = a fraction of the level budget, with a floor tied to the
3854
+ // overall budget so a strategy that zeroes a level budget (e.g.
3855
+ // KnowledgeStrategy prioritising L1) can still repair a handful of
3856
+ // covering summaries, while a corrupted store with dozens of them stays
3857
+ // bounded well short of the recent window.
3858
+ const REPAIR_ALLOWANCE_FRACTION = 0.25;
3859
+ const REPAIR_FLOOR_FRACTION = 0.05;
3860
+ const repairFloor = maxTokens * REPAIR_FLOOR_FRACTION;
3861
+ const l2RepairAllowance = Math.max(l2Budget * REPAIR_ALLOWANCE_FRACTION, repairFloor);
3862
+ const l3RepairAllowance = Math.max(l3Budget * REPAIR_ALLOWANCE_FRACTION, repairFloor);
3863
+ const selectedIds = new Set(selectedSummaries.map(s => s.id));
3864
+ const shownL2Ids = new Set(shownL2.map(s => s.id));
3865
+ const shownL3Ids = new Set(shownL3.map(s => s.id));
3866
+ let l2RepairTokens = 0;
3867
+ let l3RepairTokens = 0;
3868
+ let l2RepairsSkipped = 0;
3869
+ let l3RepairsSkipped = 0;
3870
+ // L2s excluded by anti-redundancy: unmerged, not in shownL2.
3871
+ for (const s of this.summaries) {
3872
+ if (s.level !== 2 || s.mergedInto || shownL2Ids.has(s.id)) continue;
3873
+ if (s.sourceIds.every(id => selectedIds.has(id))) continue; // truly redundant
3874
+ if (this.isOverBudget(totalTokens + totalSummaryTokens + s.tokens, maxTokens)) continue;
3875
+ if (l2RepairTokens + s.tokens > l2RepairAllowance) { l2RepairsSkipped++; continue; }
3876
+ selectedSummaries.push(s);
3877
+ totalSummaryTokens += s.tokens;
3878
+ l2RepairTokens += s.tokens;
3879
+ selectedIds.add(s.id);
3880
+ }
3881
+ // L3s excluded by anti-redundancy — after L2 repair, so a repaired L2
3882
+ // counts as selected coverage for its parent L3.
3883
+ for (const s of this.summaries) {
3884
+ if (s.level !== 3 || s.mergedInto || shownL3Ids.has(s.id)) continue;
3885
+ if (s.sourceIds.every(id => selectedIds.has(id))) continue;
3886
+ if (this.isOverBudget(totalTokens + totalSummaryTokens + s.tokens, maxTokens)) continue;
3887
+ if (l3RepairTokens + s.tokens > l3RepairAllowance) { l3RepairsSkipped++; continue; }
3888
+ selectedSummaries.push(s);
3889
+ totalSummaryTokens += s.tokens;
3890
+ l3RepairTokens += s.tokens;
3891
+ selectedIds.add(s.id);
3892
+ }
3893
+ if (l2RepairsSkipped > 0 || l3RepairsSkipped > 0) {
3894
+ console.warn(
3895
+ `[AutobiographicalStrategy] coverage-repair allowance exceeded — ` +
3896
+ `skipped ${l2RepairsSkipped} L2 and ${l3RepairsSkipped} L3 re-inclusions ` +
3897
+ `(store likely corrupted mid-merge). Some covered history may render at ` +
3898
+ `no summary level this pass.`,
3899
+ );
3900
+ }
3901
+ }
3902
+
2903
3903
  // Emit summaries + pinned messages between head and recent windows.
2904
3904
  //
2905
3905
  // Default (positionedRecallPairs=true): one Q/A pair per summary,
@@ -2983,6 +3983,10 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2983
3983
  for (const item of items) {
2984
3984
  if (item.kind === 'summary') {
2985
3985
  const summary = item.summary;
3986
+ // Defensive: never emit a recall pair for an empty/bugged summary — an
3987
+ // empty assistant text block triggers a 400. (Production guards too,
3988
+ // but a legacy empty summary may already exist in the store.)
3989
+ if (!summary.content || !summary.content.trim()) continue;
2986
3990
  const headerText = this.buildRecallHeader(summary);
2987
3991
  const questionEntry: ContextEntry = {
2988
3992
  index: entries.length,
@@ -3088,6 +4092,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3088
4092
  this.rsRaw('tail', tailStats.tokens, tailStats.messages);
3089
4093
 
3090
4094
  this.trimOrphanedToolUse(entries);
4095
+ // Full pairing invariant over the final rendered context — catches the
4096
+ // mid-list orphans the trailing/leading trims can't (bug 6.7).
4097
+ this.enforceToolPairing(entries);
3091
4098
  this.pruneToolEntries(entries);
3092
4099
  // Strip stale images before committing stats so RenderStats.total reflects
3093
4100
  // the post-strip context (this path places no cache markers).
@@ -3400,27 +4407,83 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3400
4407
  }
3401
4408
 
3402
4409
  /**
3403
- * Rebuild chunk boundaries based on current messages.
4410
+ * Rebuild the chunk list: persisted records own the past; the running-sum
4411
+ * chunker only extends at the frontier, and a chunk is only ever created
4412
+ * once it CLOSES (reaches targetChunkTokens). The trailing partial chunk
4413
+ * is never created and never compressed — eager partial-tail compression
4414
+ * minted a new near-duplicate L1 per rebuild while the tail grew (the
4415
+ * prefix-generation families found fleet-wide in the 2026-07 audit).
3404
4416
  */
3405
4417
  protected rebuildChunks(store: MessageStoreView): void {
3406
- const messagesToChunk = this.getCompressibleMessages(store);
4418
+ this.chunks = [];
4419
+ this.compressionQueue = [];
3407
4420
 
3408
- // Preserve existing compressed chunks (legacy) and summary linkage (hierarchical)
3409
- const existingCompressed = new Map<string, Chunk>();
4421
+ // ---- 1. Materialize persisted records (they OWN their messages). ----
4422
+ const byId = new Map<string, StoredMessage>();
4423
+ for (const m of store.getAll()) byId.set(m.id, m);
4424
+
4425
+ const consumed = new Set<string>();
4426
+ let orphaned = 0;
4427
+ for (const rec of this.chunkRecords) {
4428
+ const msgs: StoredMessage[] = [];
4429
+ for (const id of rec.sourceIds) {
4430
+ const m = byId.get(id);
4431
+ if (m) msgs.push(m);
4432
+ }
4433
+ if (msgs.length === 0) { orphaned++; continue; }
4434
+ for (const m of msgs) consumed.add(m.id);
4435
+ const chunk: Chunk = {
4436
+ index: this.chunks.length,
4437
+ startIndex: -1, // record-derived; filtered-array indices are not meaningful
4438
+ endIndex: -1,
4439
+ messages: msgs,
4440
+ tokens: msgs.reduce((sum, m) => sum + (this.config.attachmentsIgnoreSize
4441
+ ? this.estimateTextOnlyTokens(m)
4442
+ : store.estimateTokens(m)), 0),
4443
+ compressed: rec.compressed,
4444
+ summaryId: rec.summaryId,
4445
+ phaseType: rec.phaseType,
4446
+ recordId: rec.id,
4447
+ };
4448
+ this.chunks.push(chunk);
4449
+ }
4450
+
4451
+ // ---- 2. Fail closed on the chain-break signature. ----
4452
+ // Most records resolving to zero live messages means message identity
4453
+ // has been rebuilt/renumbered underneath us. Chunking "fresh" ground now
4454
+ // would re-compress already-lived history into duplicate memories.
4455
+ // Halt ALL compression until an operator reconciles the store.
4456
+ if (this.chunkPersistenceEnabled && this.chunkRecords.length >= 3 &&
4457
+ orphaned / this.chunkRecords.length > 0.5) {
4458
+ this.chunkRecordsOrphaned = true;
4459
+ this.compressionQueue = [];
4460
+ if (!this._orphanWarned) {
4461
+ this._orphanWarned = true;
4462
+ console.error(
4463
+ `[autobiographical] FAIL-CLOSED: ${orphaned}/${this.chunkRecords.length} chunk ` +
4464
+ `records resolve to zero live messages (messages chain break / store ` +
4465
+ `reconciliation signature). Compression halted to prevent duplicate ` +
4466
+ `memory formation — reconcile the store before resuming.`,
4467
+ );
4468
+ }
4469
+ return;
4470
+ }
4471
+ this.chunkRecordsOrphaned = false;
4472
+
4473
+ // Queue uncompressed record-backed chunks (crash-recovery: record was
4474
+ // appended but the process died before its L1 landed).
3410
4475
  for (const chunk of this.chunks) {
3411
- if (chunk.compressed) {
3412
- const key = this.chunkKey(chunk);
3413
- existingCompressed.set(key, chunk);
4476
+ if (!chunk.compressed && !(chunk.recordId && this._overlapBlocked.has(chunk.recordId))) {
4477
+ this.compressionQueue.push(chunk.index);
3414
4478
  }
3415
4479
  }
3416
4480
 
3417
- // Rebuild chunks
3418
- this.chunks = [];
3419
- this.compressionQueue = [];
4481
+ // ---- 3. Chunk the frontier: compressible messages not owned by any record. ----
4482
+ const messagesToChunk = this.getCompressibleMessages(store)
4483
+ .filter(m => !consumed.has(m.id));
3420
4484
 
3421
4485
  let currentChunk: StoredMessage[] = [];
3422
4486
  let currentTokens = 0;
3423
- // Track start position in the filtered array for chunk boundary metadata
3424
4487
  let chunkFilteredStart = 0;
3425
4488
 
3426
4489
  for (let i = 0; i < messagesToChunk.length; i++) {
@@ -3450,19 +4513,27 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3450
4513
  }
3451
4514
 
3452
4515
  if (shouldClose) {
3453
- const chunk = this.createChunk(
3454
- this.chunks.length,
3455
- chunkFilteredStart,
3456
- i + 1,
3457
- currentChunk,
3458
- currentTokens,
3459
- existingCompressed
3460
- );
3461
- this.chunks.push(chunk);
3462
-
3463
- if (!chunk.compressed) {
3464
- this.compressionQueue.push(chunk.index);
4516
+ const chunk: Chunk = {
4517
+ index: this.chunks.length,
4518
+ startIndex: chunkFilteredStart,
4519
+ endIndex: i + 1,
4520
+ messages: [...currentChunk],
4521
+ tokens: currentTokens,
4522
+ compressed: false,
4523
+ };
4524
+ // Persist the boundary the moment it closes — from here on this
4525
+ // span is owned and never re-keyed by config drift or restarts.
4526
+ if (this.chunkPersistenceEnabled) {
4527
+ const record: ChunkRecord = {
4528
+ id: `c-${this.chunkIdCounter++}`,
4529
+ sourceIds: chunk.messages.map(m => m.id),
4530
+ compressed: false,
4531
+ };
4532
+ this.appendChunkRecord(record);
4533
+ chunk.recordId = record.id;
3465
4534
  }
4535
+ this.chunks.push(chunk);
4536
+ this.compressionQueue.push(chunk.index);
3466
4537
 
3467
4538
  currentChunk = [];
3468
4539
  currentTokens = 0;
@@ -3470,20 +4541,27 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3470
4541
  }
3471
4542
  }
3472
4543
 
3473
- if (currentChunk.length >= 4) {
3474
- const chunk = this.createChunk(
3475
- this.chunks.length,
3476
- chunkFilteredStart,
3477
- messagesToChunk.length,
3478
- currentChunk,
3479
- currentTokens,
3480
- existingCompressed
3481
- );
3482
- this.chunks.push(chunk);
3483
-
3484
- if (!chunk.compressed) {
3485
- this.compressionQueue.push(chunk.index);
3486
- }
4544
+ // NOTE: no trailing-partial chunk. An unclosed chunk is not a chunk —
4545
+ // it compresses only after the running sum closes it.
4546
+
4547
+ // ---- 4. L1 holdback: keep the newest X closed chunks out of the
4548
+ // speculative queue (default 1). The chunk at the live edge is the one
4549
+ // most likely to still be in motion (edits, tool-result landings, the
4550
+ // episode it belongs to still resolving); summarize it once a newer chunk
4551
+ // has closed behind it. The queue is rebuilt on every message, so a
4552
+ // held-back chunk is released automatically the moment it ages out of the
4553
+ // window. Demand overrides: a picker `produce` op (enqueueL1ForRange)
4554
+ // marks the chunk demanded, and demanded chunks are never held back —
4555
+ // when folding actually NEEDS the L1, production must not be blocked.
4556
+ const holdback = this.config.l1HoldbackChunks ?? 1;
4557
+ if (holdback > 0 && this.chunks.length > 0) {
4558
+ const cutoff = this.chunks.length - holdback;
4559
+ this.compressionQueue = this.compressionQueue.filter((idx) => {
4560
+ if (idx < cutoff) return true;
4561
+ const ch = this.chunks[idx];
4562
+ const lastId = ch?.messages[ch.messages.length - 1]?.id;
4563
+ return lastId !== undefined && this._demandedL1Chunks.has(lastId);
4564
+ });
3487
4565
  }
3488
4566
  }
3489
4567
 
@@ -3581,7 +4659,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3581
4659
  protected applyImageStripping(entries: ContextEntry[], store: MessageStoreView): void {
3582
4660
  const maxLive = this.config.maxLiveImages ?? 0; // 0 = unlimited count
3583
4661
  const depthTokens = this.config.imageStripDepthTokens ?? 0; // 0 = no depth strip
3584
- if (maxLive === 0 && depthTokens === 0) return; // policy disabled
4662
+ const maxLiveBytes = this.config.maxLiveImageBytes ?? AutobiographicalStrategy.DEFAULT_MAX_LIVE_IMAGE_BYTES;
4663
+ if (maxLive === 0 && depthTokens === 0 && maxLiveBytes === 0) return; // policy disabled
3585
4664
 
3586
4665
  const messages = store.getAll();
3587
4666
  const posById = new Map<string, number>();
@@ -3612,34 +4691,96 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3612
4691
  .sort((a, b) => b.pos - a.pos);
3613
4692
 
3614
4693
  let keptImages = 0;
4694
+ let keptImageBytes = 0;
3615
4695
  for (const { idx, pos } of ordered) {
3616
4696
  const entry = entries[idx];
3617
4697
  const tooDeep = depthTokens > 0 && (pos < 0 || pos < stripStart);
3618
4698
  const bucket = bucketAt(pos);
3619
4699
  entry.content = entry.content.map((block) => {
3620
4700
  if (block.type !== 'image') return block;
4701
+ const blockBytes = AutobiographicalStrategy.imageBlockBytes(block);
3621
4702
  const overCount = maxLive > 0 && keptImages >= maxLive;
3622
- if (tooDeep || overCount) {
3623
- // The renderer estimates an image at `tokenEstimate ?? 1600` (see
3624
- // message-store/context-log). Hand that back to the bucket, less the
3625
- // placeholder text that replaces it, so the stats match the output.
3626
- const reclaimed =
3627
- ((block as { tokenEstimate?: number }).tokenEstimate ?? 1600) - placeholderTokens;
3628
- if (this._rs && reclaimed > 0) this._rs[bucket].tokens -= reclaimed;
4703
+ const overBytes = maxLiveBytes > 0 && keptImageBytes + blockBytes > maxLiveBytes;
4704
+ if (tooDeep || overCount || overBytes) {
4705
+ // Stats-neutral (2026-07-12): every budgeting site now tallies at
4706
+ // POST-STRIP prices (see postStripEstimates), so the bucket never
4707
+ // charged this image at full weight — reclaiming here would
4708
+ // double-decrement. The strip pass only swaps the block.
4709
+ void bucket;
4710
+ void placeholderTokens;
3629
4711
  return { type: 'text', text: AutobiographicalStrategy.IMAGE_PLACEHOLDER } as ContentBlock;
3630
4712
  }
3631
4713
  keptImages++;
4714
+ keptImageBytes += blockBytes;
3632
4715
  return block;
3633
4716
  });
3634
4717
  }
3635
4718
  }
3636
4719
 
4720
+ /**
4721
+ * Post-strip token estimate per message index (2026-07-12 tail-starvation
4722
+ * fix). Mirrors `applyImageStripping`: an image beyond the `maxLiveImages`
4723
+ * newest (counted newest-first) or deeper than `imageStripDepthTokens` of
4724
+ * raw estimate from the live end renders as a placeholder — so every place
4725
+ * that BUDGETS messages (recent-window walk-back, head/tail sums, middle
4726
+ * chunk sizes) must cost it as one. Pricing stripped images at their full
4727
+ * estimate collapsed an image-dense tail to a fraction of its configured
4728
+ * size (42k rendered of a 120k window), and pricing them post-strip in the
4729
+ * walk-back alone made the picker's raw-priced tail overflow the budget
4730
+ * (318k) — the estimate must be consistent EVERYWHERE.
4731
+ */
4732
+ protected postStripEstimates(store: MessageStoreView): number[] {
4733
+ const messages = store.getAll();
4734
+ const out = new Array<number>(messages.length);
4735
+ const stripDepth = this.config.imageStripDepthTokens ?? 0;
4736
+ const maxLive = this.config.maxLiveImages ?? 0;
4737
+ const maxLiveBytes = this.config.maxLiveImageBytes ?? AutobiographicalStrategy.DEFAULT_MAX_LIVE_IMAGE_BYTES;
4738
+ const stripActive = stripDepth > 0 || maxLive > 0 || maxLiveBytes > 0;
4739
+ const placeholderTokens = Math.ceil(AutobiographicalStrategy.IMAGE_PLACEHOLDER.length / 4);
4740
+ let liveImagesSeen = 0;
4741
+ let liveImageBytes = 0;
4742
+ let rawDepth = 0; // raw-estimate depth from the newest message (mirrors getImageStripStart)
4743
+ for (let i = messages.length - 1; i >= 0; i--) {
4744
+ const raw = store.estimateTokens(messages[i]);
4745
+ let est = raw;
4746
+ if (stripActive) {
4747
+ for (const b of messages[i].content) {
4748
+ if (b.type !== 'image') continue;
4749
+ const bytes = AutobiographicalStrategy.imageBlockBytes(b);
4750
+ const beyondDepth = stripDepth > 0 && rawDepth > stripDepth;
4751
+ const beyondCount = maxLive > 0 && liveImagesSeen >= maxLive;
4752
+ const beyondBytes = maxLiveBytes > 0 && liveImageBytes + bytes > maxLiveBytes;
4753
+ if (beyondDepth || beyondCount || beyondBytes) {
4754
+ const imgEst = (b as { tokenEstimate?: number }).tokenEstimate ?? 1600;
4755
+ est -= Math.max(0, imgEst - placeholderTokens);
4756
+ } else {
4757
+ liveImagesSeen++;
4758
+ liveImageBytes += bytes;
4759
+ }
4760
+ }
4761
+ }
4762
+ rawDepth += raw;
4763
+ out[i] = est;
4764
+ }
4765
+ return out;
4766
+ }
4767
+
4768
+ /** Byte wall default: 20MB of base64 (API total-request cap is 32MB). */
4769
+ protected static readonly DEFAULT_MAX_LIVE_IMAGE_BYTES = 20 * 1024 * 1024;
4770
+
4771
+ /** Base64 payload size of an image block (0 for non-base64 sources). */
4772
+ protected static imageBlockBytes(b: unknown): number {
4773
+ const src = (b as { source?: { data?: string } }).source;
4774
+ return typeof src?.data === 'string' ? src.data.length : 0;
4775
+ }
4776
+
3637
4777
  protected getRecentWindowStart(store: MessageStoreView): number {
3638
4778
  const messages = store.getAll();
4779
+ const pse = this.postStripEstimates(store);
3639
4780
  let tokens = 0;
3640
4781
 
3641
4782
  for (let i = messages.length - 1; i >= 0; i--) {
3642
- tokens += store.estimateTokens(messages[i]);
4783
+ tokens += pse[i];
3643
4784
  if (tokens > this.config.recentWindowTokens) {
3644
4785
  let boundary = i + 1;
3645
4786
  // Don't split a tool_use/tool_result pair: if the message at the boundary
@@ -3727,6 +4868,180 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3727
4868
  }
3728
4869
  }
3729
4870
 
4871
+ /** Placeholder body for a stub tool_result inserted by enforceToolPairing. */
4872
+ private static readonly STUB_TOOL_RESULT_TEXT =
4873
+ '[tool result unavailable — omitted during context compression]';
4874
+
4875
+ /**
4876
+ * Final post-selection tool-pairing validator (bug 6.7).
4877
+ *
4878
+ * The Anthropic API requires every `tool_use` block to be answered by a
4879
+ * matching `tool_result` in the immediately-following message, and every
4880
+ * `tool_result` to answer a `tool_use` in the immediately-preceding
4881
+ * message. Selection can violate this mid-list in ways the trailing
4882
+ * (`trimOrphanedToolUse`) and leading orphan trims don't catch:
4883
+ *
4884
+ * - a budget `break` cutting between a raw pin pair's two messages;
4885
+ * - the uncompressed-chunk fallback emitting a raw tool_result whose
4886
+ * tool_use chunk already compressed (or vice versa);
4887
+ * - a recall pair or pin interleaving between a tool_use and its result.
4888
+ *
4889
+ * Repair policy prefers preserving content over dropping:
4890
+ *
4891
+ * - a tool_use whose result is missing from the next entry first triggers
4892
+ * a short look-ahead: if the genuine (displaced) result is a few entries
4893
+ * down it is MOVED up into position (see
4894
+ * {@link relocateOrDropMissingResults}); only when no real result exists
4895
+ * is a STUB tool_result emitted. Either way the result block is merged
4896
+ * into the next entry when that entry already carries results for this
4897
+ * cycle, or inserted as a new user entry;
4898
+ * - a tool_result whose tool_use is not in the immediately-preceding
4899
+ * entry (and was not relocated) is dropped — there is no safe way to
4900
+ * stub a tool_use, so the result's information content survives only if
4901
+ * its use is adjacent; an entry left empty is replaced with a
4902
+ * placeholder text block.
4903
+ *
4904
+ * Runs as a structural pass over the rendered context in BOTH render
4905
+ * paths — `selectHierarchical` (downstream of `selectL1Summaries`, so
4906
+ * subclass overrides like KnowledgeStrategy are covered) and
4907
+ * `selectAdaptive` (the path FKM defaults onto). It's a no-op on
4908
+ * already-valid output.
4909
+ */
4910
+ protected enforceToolPairing(entries: ContextEntry[]): void {
4911
+ let prevUseIds = new Set<string>();
4912
+
4913
+ for (let i = 0; i < entries.length; i++) {
4914
+ const entry = entries[i];
4915
+
4916
+ // --- Rule A: every tool_result must answer a tool_use in the
4917
+ // immediately-preceding entry (and only once). Drop orphans/dupes. ---
4918
+ if (entry.content.some(b => b.type === 'tool_result')) {
4919
+ const seen = new Set<string>();
4920
+ const filtered = entry.content.filter(b => {
4921
+ if (b.type !== 'tool_result') return true;
4922
+ if (!prevUseIds.has(b.toolUseId) || seen.has(b.toolUseId)) return false;
4923
+ seen.add(b.toolUseId);
4924
+ return true;
4925
+ });
4926
+ if (filtered.length !== entry.content.length) {
4927
+ entry.content = filtered.length > 0
4928
+ ? filtered
4929
+ : [{ type: 'text', text: '[tool call omitted]' }];
4930
+ }
4931
+ }
4932
+
4933
+ // --- Rule B: every tool_use in the PREVIOUS entry must be answered
4934
+ // by this entry. Stub any that aren't. ---
4935
+ if (prevUseIds.size > 0) {
4936
+ const answered = new Set<string>();
4937
+ for (const b of entry.content) {
4938
+ if (b.type === 'tool_result') answered.add(b.toolUseId);
4939
+ }
4940
+ const missing = [...prevUseIds].filter(id => !answered.has(id));
4941
+ if (missing.length > 0) {
4942
+ // Look-ahead relocation: the genuine result for a "missing" id is
4943
+ // often sitting a few entries down, displaced by an interleaved
4944
+ // recall pair / pin (it will otherwise be dropped as an orphan by
4945
+ // Rule A when we reach it). Move the real block up into position and
4946
+ // only stub the ids for which no real result exists — so tool output
4947
+ // is preserved, not silently replaced by a placeholder.
4948
+ const results = this.relocateOrDropMissingResults(entries, i, missing);
4949
+ if (answered.size > 0) {
4950
+ // This entry already carries results for the cycle — prepend the
4951
+ // relocated/stub results so all results for the preceding tool_use
4952
+ // sit together (the API wants tool_result blocks at the head of
4953
+ // the message).
4954
+ entry.content = [...results, ...entry.content];
4955
+ } else {
4956
+ // Not a results entry at all — insert a synthetic user entry
4957
+ // between the tool_use entry and this one.
4958
+ entries.splice(i, 0, {
4959
+ index: i,
4960
+ participant: 'user',
4961
+ sourceRelation: 'derived',
4962
+ content: results,
4963
+ });
4964
+ // The stub entry (no tool_use blocks) is now at index i; the
4965
+ // current entry moved to i+1 and is re-processed next iteration
4966
+ // with an empty prevUseIds (its orphan results, if any, were
4967
+ // already filtered above and none survived — Rule A matched
4968
+ // against the same prevUseIds and answered.size === 0).
4969
+ prevUseIds = new Set();
4970
+ continue;
4971
+ }
4972
+ }
4973
+ }
4974
+
4975
+ prevUseIds = new Set();
4976
+ for (const b of entry.content) {
4977
+ if (b.type === 'tool_use') prevUseIds.add(b.id);
4978
+ }
4979
+ }
4980
+
4981
+ // Tail: an entry that mixes tool_use with other blocks can survive
4982
+ // trimOrphanedToolUse (which only pops pure use-without-result tails).
4983
+ if (prevUseIds.size > 0) {
4984
+ entries.push({
4985
+ index: entries.length,
4986
+ participant: 'user',
4987
+ sourceRelation: 'derived',
4988
+ content: [...prevUseIds].map(id => ({
4989
+ type: 'tool_result' as const,
4990
+ toolUseId: id,
4991
+ content: AutobiographicalStrategy.STUB_TOOL_RESULT_TEXT,
4992
+ })),
4993
+ });
4994
+ }
4995
+
4996
+ // Reindex after any splices/appends.
4997
+ for (let i = 0; i < entries.length; i++) entries[i].index = i;
4998
+ }
4999
+
5000
+ /**
5001
+ * For each `missing` tool_use id (a use in the preceding entry with no
5002
+ * adjacent result), return the result block to place next to it:
5003
+ *
5004
+ * - if the genuine result exists within a short look-ahead window after
5005
+ * `afterIndex`, MOVE it up into position (removing it from its source
5006
+ * entry — replacing a now-empty entry with a placeholder) so the real
5007
+ * tool output survives the repair;
5008
+ * - otherwise emit a stub.
5009
+ *
5010
+ * tool_use ids are unique, so the single result carrying a given id is
5011
+ * unambiguously the answer to that use — relocating it cannot break any
5012
+ * other pairing. Results for `missing` ids never sit at or before
5013
+ * `afterIndex` (that entry's results are already matched), so the search
5014
+ * starts at `afterIndex + 1`.
5015
+ */
5016
+ private relocateOrDropMissingResults(
5017
+ entries: ContextEntry[],
5018
+ afterIndex: number,
5019
+ missing: string[],
5020
+ ): ContentBlock[] {
5021
+ const LOOKAHEAD = 6;
5022
+ const end = Math.min(entries.length, afterIndex + 1 + LOOKAHEAD);
5023
+ return missing.map(id => {
5024
+ for (let j = afterIndex + 1; j < end; j++) {
5025
+ const src = entries[j];
5026
+ const bi = src.content.findIndex(
5027
+ b => b.type === 'tool_result' && b.toolUseId === id,
5028
+ );
5029
+ if (bi === -1) continue;
5030
+ const real = src.content[bi];
5031
+ const rest = src.content.filter((_, k) => k !== bi);
5032
+ src.content = rest.length > 0
5033
+ ? rest
5034
+ : [{ type: 'text', text: '[tool call omitted]' }];
5035
+ return real;
5036
+ }
5037
+ return {
5038
+ type: 'tool_result',
5039
+ toolUseId: id,
5040
+ content: AutobiographicalStrategy.STUB_TOOL_RESULT_TEXT,
5041
+ } as ContentBlock;
5042
+ });
5043
+ }
5044
+
3730
5045
  /**
3731
5046
  * Prune tool_use / tool_result blocks in-place:
3732
5047
  * 1. Truncate `tool_use.input` blocks whose serialized JSON exceeds