@animalabs/context-manager 0.5.3 → 0.5.5

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 (123) hide show
  1. package/dist/src/adaptive/index.d.ts +6 -0
  2. package/dist/src/adaptive/index.d.ts.map +1 -1
  3. package/dist/src/adaptive/index.js +8 -0
  4. package/dist/src/adaptive/index.js.map +1 -1
  5. package/dist/src/adaptive/kv-cache-sim.d.ts +128 -0
  6. package/dist/src/adaptive/kv-cache-sim.d.ts.map +1 -0
  7. package/dist/src/adaptive/kv-cache-sim.js +0 -0
  8. package/dist/src/adaptive/kv-cache-sim.js.map +1 -0
  9. package/dist/src/adaptive/kv-control.d.ts +166 -0
  10. package/dist/src/adaptive/kv-control.d.ts.map +1 -0
  11. package/dist/src/adaptive/kv-control.js +434 -0
  12. package/dist/src/adaptive/kv-control.js.map +1 -0
  13. package/dist/src/adaptive/kv-replay.d.ts +76 -0
  14. package/dist/src/adaptive/kv-replay.d.ts.map +1 -0
  15. package/dist/src/adaptive/kv-replay.js +175 -0
  16. package/dist/src/adaptive/kv-replay.js.map +1 -0
  17. package/dist/src/adaptive/picker.d.ts +13 -0
  18. package/dist/src/adaptive/picker.d.ts.map +1 -1
  19. package/dist/src/adaptive/picker.js.map +1 -1
  20. package/dist/src/adaptive/render-offsets.d.ts +61 -0
  21. package/dist/src/adaptive/render-offsets.d.ts.map +1 -0
  22. package/dist/src/adaptive/render-offsets.js +88 -0
  23. package/dist/src/adaptive/render-offsets.js.map +1 -0
  24. package/dist/src/adaptive/strategies/kv-stable.d.ts +48 -0
  25. package/dist/src/adaptive/strategies/kv-stable.d.ts.map +1 -0
  26. package/dist/src/adaptive/strategies/kv-stable.js +153 -0
  27. package/dist/src/adaptive/strategies/kv-stable.js.map +1 -0
  28. package/dist/src/adaptive/summary-tree.d.ts +98 -0
  29. package/dist/src/adaptive/summary-tree.d.ts.map +1 -0
  30. package/dist/src/adaptive/summary-tree.js +214 -0
  31. package/dist/src/adaptive/summary-tree.js.map +1 -0
  32. package/dist/src/context-log.d.ts.map +1 -1
  33. package/dist/src/context-log.js +7 -2
  34. package/dist/src/context-log.js.map +1 -1
  35. package/dist/src/context-manager.d.ts +10 -5
  36. package/dist/src/context-manager.d.ts.map +1 -1
  37. package/dist/src/context-manager.js +9 -0
  38. package/dist/src/context-manager.js.map +1 -1
  39. package/dist/src/index.d.ts +1 -0
  40. package/dist/src/index.d.ts.map +1 -1
  41. package/dist/src/index.js +2 -0
  42. package/dist/src/index.js.map +1 -1
  43. package/dist/src/message-store.js +1 -1
  44. package/dist/src/message-store.js.map +1 -1
  45. package/dist/src/phase-channel.d.ts +13 -0
  46. package/dist/src/phase-channel.d.ts.map +1 -0
  47. package/dist/src/phase-channel.js +55 -0
  48. package/dist/src/phase-channel.js.map +1 -0
  49. package/dist/src/strategies/autobiographical.d.ts +107 -44
  50. package/dist/src/strategies/autobiographical.d.ts.map +1 -1
  51. package/dist/src/strategies/autobiographical.js +437 -230
  52. package/dist/src/strategies/autobiographical.js.map +1 -1
  53. package/dist/src/types/index.d.ts +1 -1
  54. package/dist/src/types/index.d.ts.map +1 -1
  55. package/dist/src/types/index.js.map +1 -1
  56. package/dist/src/types/strategy.d.ts +70 -7
  57. package/dist/src/types/strategy.d.ts.map +1 -1
  58. package/dist/src/types/strategy.js +2 -0
  59. package/dist/src/types/strategy.js.map +1 -1
  60. package/dist/test/adaptive/kv-cache-sim.test.d.ts +16 -0
  61. package/dist/test/adaptive/kv-cache-sim.test.d.ts.map +1 -0
  62. package/dist/test/adaptive/kv-cache-sim.test.js +173 -0
  63. package/dist/test/adaptive/kv-cache-sim.test.js.map +1 -0
  64. package/dist/test/adaptive/kv-control.test.d.ts +14 -0
  65. package/dist/test/adaptive/kv-control.test.d.ts.map +1 -0
  66. package/dist/test/adaptive/kv-control.test.js +130 -0
  67. package/dist/test/adaptive/kv-control.test.js.map +1 -0
  68. package/dist/test/adaptive/kv-replay.test.d.ts +15 -0
  69. package/dist/test/adaptive/kv-replay.test.d.ts.map +1 -0
  70. package/dist/test/adaptive/kv-replay.test.js +112 -0
  71. package/dist/test/adaptive/kv-replay.test.js.map +1 -0
  72. package/dist/test/adaptive/kv-stable-strategy.test.d.ts +10 -0
  73. package/dist/test/adaptive/kv-stable-strategy.test.d.ts.map +1 -0
  74. package/dist/test/adaptive/kv-stable-strategy.test.js +89 -0
  75. package/dist/test/adaptive/kv-stable-strategy.test.js.map +1 -0
  76. package/dist/test/adaptive/pin-at-level.test.d.ts +16 -0
  77. package/dist/test/adaptive/pin-at-level.test.d.ts.map +1 -0
  78. package/dist/test/adaptive/pin-at-level.test.js +133 -0
  79. package/dist/test/adaptive/pin-at-level.test.js.map +1 -0
  80. package/dist/test/adaptive/render-offsets.test.d.ts +11 -0
  81. package/dist/test/adaptive/render-offsets.test.d.ts.map +1 -0
  82. package/dist/test/adaptive/render-offsets.test.js +85 -0
  83. package/dist/test/adaptive/render-offsets.test.js.map +1 -0
  84. package/dist/test/adaptive/summary-tree.test.d.ts +5 -0
  85. package/dist/test/adaptive/summary-tree.test.d.ts.map +1 -0
  86. package/dist/test/adaptive/summary-tree.test.js +72 -0
  87. package/dist/test/adaptive/summary-tree.test.js.map +1 -0
  88. package/dist/test/image-strip-render-stats.test.d.ts +2 -0
  89. package/dist/test/image-strip-render-stats.test.d.ts.map +1 -0
  90. package/dist/test/image-strip-render-stats.test.js +130 -0
  91. package/dist/test/image-strip-render-stats.test.js.map +1 -0
  92. package/dist/test/image-stripping.test.d.ts +13 -0
  93. package/dist/test/image-stripping.test.d.ts.map +1 -0
  94. package/dist/test/image-stripping.test.js +104 -0
  95. package/dist/test/image-stripping.test.js.map +1 -0
  96. package/dist/test/pins.test.js +40 -0
  97. package/dist/test/pins.test.js.map +1 -1
  98. package/dist/test/render-stats.test.d.ts +2 -0
  99. package/dist/test/render-stats.test.d.ts.map +1 -0
  100. package/dist/test/render-stats.test.js +91 -0
  101. package/dist/test/render-stats.test.js.map +1 -0
  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 +175 -3
  105. package/dist/test/speculation-cap.test.js.map +1 -1
  106. package/dist/tsconfig.tsbuildinfo +1 -1
  107. package/package.json +2 -2
  108. package/src/adaptive/index.ts +48 -0
  109. package/src/adaptive/kv-cache-sim.ts +0 -0
  110. package/src/adaptive/kv-control.ts +574 -0
  111. package/src/adaptive/kv-replay.ts +238 -0
  112. package/src/adaptive/picker.ts +13 -0
  113. package/src/adaptive/render-offsets.ts +116 -0
  114. package/src/adaptive/strategies/kv-stable.ts +161 -0
  115. package/src/adaptive/summary-tree.ts +252 -0
  116. package/src/context-log.ts +7 -2
  117. package/src/context-manager.ts +13 -2
  118. package/src/index.ts +3 -0
  119. package/src/message-store.ts +1 -1
  120. package/src/phase-channel.ts +57 -0
  121. package/src/strategies/autobiographical.ts +453 -282
  122. package/src/types/index.ts +1 -0
  123. package/src/types/strategy.ts +71 -3
@@ -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,16 +16,19 @@ import type {
15
16
  SummaryLevel,
16
17
  SummaryEntry,
17
18
  ProtectedRange,
19
+ PinLevelOptions,
18
20
  SearchQuery,
19
21
  SearchResult,
22
+ RenderStats,
20
23
  } from '../types/index.js';
21
24
  import { DEFAULT_AUTOBIOGRAPHICAL_CONFIG } from '../types/index.js';
22
25
  import { getSummaryParentId } from '../types/strategy.js';
23
26
  import { splitMixedToolMessages, stripUnpairedToolBlocks } from '../normalize-tool-messages.js';
24
27
  import { appendFileSync, mkdirSync } from 'node:fs';
25
28
  import { dirname } from 'node:path';
26
- import { Picker, OverBudgetError, type PickerChunk } from '../adaptive/picker.js';
29
+ import { Picker, OverBudgetError, type PickerChunk, type PickerInputs } from '../adaptive/picker.js';
27
30
  import { FlatProfileStrategy } from '../adaptive/strategies/flat-profile.js';
31
+ import { KvStableStrategy } from '../adaptive/strategies/kv-stable.js';
28
32
  import { OldestFirstStrategy } from '../adaptive/strategies/oldest-first.js';
29
33
  import type {
30
34
  FoldingStrategy,
@@ -223,8 +227,6 @@ export interface Chunk {
223
227
  tokens: number;
224
228
  /** Whether this chunk has been compressed */
225
229
  compressed: boolean;
226
- /** The diary entry if compressed (legacy mode) */
227
- diary?: string;
228
230
  /** ID of the L1 SummaryEntry (hierarchical mode) */
229
231
  summaryId?: string;
230
232
  /** Phase type tag (set by KnowledgeStrategy for semantic chunking) */
@@ -252,6 +254,21 @@ export interface AutobiographicalProgressSnapshot {
252
254
  pending: boolean;
253
255
  }
254
256
 
257
+ /**
258
+ * Validate + normalize the optional V2 pin fold-depth bounds. Returns only the
259
+ * fields that are present and valid (non-negative integers), so a classic pin
260
+ * with no bounds persists exactly as before. `level` takes precedence over
261
+ * `maxLevel` (pin-at-k is stronger than a cap), so they're never both emitted.
262
+ */
263
+ function normalizePinLevels(opts?: PinLevelOptions): { level?: number; maxLevel?: number } {
264
+ const clean = (v: number | undefined): number | undefined =>
265
+ typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : undefined;
266
+ const level = clean(opts?.level);
267
+ if (level !== undefined) return { level };
268
+ const maxLevel = clean(opts?.maxLevel);
269
+ return maxLevel !== undefined ? { maxLevel } : {};
270
+ }
271
+
255
272
  /**
256
273
  * Autobiographical chunking strategy.
257
274
  * Compresses old conversation chunks into summaries in the model's own words.
@@ -271,6 +288,14 @@ export class AutobiographicalStrategy implements ResettableStrategy {
271
288
  protected pendingCompression: Promise<void> | null = null;
272
289
  protected compressionQueue: number[] = [];
273
290
  protected _compressionCount = 0;
291
+ /**
292
+ * Monotonic counter of tick() operations that actually processed a queue item
293
+ * (compressed a chunk or executed a merge). `driveSpeculativeDrain` recurses
294
+ * while this advances — a length-delta check would falsely read "no progress"
295
+ * when a productive tick also enqueues a follow-on item (net queue length
296
+ * unchanged), halting the drain with work still queued.
297
+ */
298
+ protected _drainProgress = 0;
274
299
 
275
300
  // Hierarchical state
276
301
  protected summaries: SummaryEntry[] = [];
@@ -583,7 +608,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
583
608
  * Pin a range of messages so they aren't compressed and render raw at
584
609
  * their original position. Returns the pin id.
585
610
  */
586
- pinRange(firstMessageId: string, lastMessageId: string, opts?: { name?: string }): string {
611
+ pinRange(firstMessageId: string, lastMessageId: string, opts?: PinLevelOptions): string {
587
612
  const id = `pin-${this.pinIdCounter++}`;
588
613
  this.pins.push({
589
614
  id,
@@ -592,6 +617,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
592
617
  kind: 'pin',
593
618
  name: opts?.name,
594
619
  created: Date.now(),
620
+ ...normalizePinLevels(opts),
595
621
  });
596
622
  this.persistPins();
597
623
  return id;
@@ -602,7 +628,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
602
628
  * information the agent wants to retain in full. Functionally a
603
629
  * single-message pin with `kind: 'document'`.
604
630
  */
605
- markDocument(messageId: string, opts?: { name?: string }): string {
631
+ markDocument(messageId: string, opts?: PinLevelOptions): string {
606
632
  const id = `pin-${this.pinIdCounter++}`;
607
633
  this.pins.push({
608
634
  id,
@@ -611,11 +637,22 @@ export class AutobiographicalStrategy implements ResettableStrategy {
611
637
  kind: 'document',
612
638
  name: opts?.name,
613
639
  created: Date.now(),
640
+ ...normalizePinLevels(opts),
614
641
  });
615
642
  this.persistPins();
616
643
  return id;
617
644
  }
618
645
 
646
+ /**
647
+ * V2 dynamic pin-at-level-k convenience: fix a range to render at EXACTLY
648
+ * fold level `level` (0 = raw). Honored only by `foldingStrategy: 'kv-stable'`;
649
+ * other strategies fall back to treating the range as raw. Equivalent to
650
+ * `pinRange(first, last, { level })`.
651
+ */
652
+ pinAtLevel(firstMessageId: string, lastMessageId: string, level: number, opts?: { name?: string }): string {
653
+ return this.pinRange(firstMessageId, lastMessageId, { name: opts?.name, level });
654
+ }
655
+
619
656
  /** Remove a pin or document mark by id. Returns true if removed. */
620
657
  unpin(pinId: string): boolean {
621
658
  const before = this.pins.length;
@@ -735,6 +772,42 @@ export class AutobiographicalStrategy implements ResettableStrategy {
735
772
  return out;
736
773
  }
737
774
 
775
+ /**
776
+ * Resolve the V2 dynamic-pin fold-depth bounds (`ProtectedRange.level` /
777
+ * `maxLevel`) to message positions. Only pins that carry a bound appear; a
778
+ * classic raw pin (no bound) is absent here and handled by `pinnedPositions`.
779
+ * When ranges overlap, the FINEST requirement wins (lowest effective level):
780
+ * a fixed `level` clamps both ends; a `maxLevel` only caps depth. Honored
781
+ * solely by the KV-stable controller — see `ProtectedRange`.
782
+ */
783
+ protected pinLevelBounds(messages: StoredMessage[]): Map<number, { level?: number; maxLevel?: number }> {
784
+ const out = new Map<number, { level?: number; maxLevel?: number }>();
785
+ if (this.pins.length === 0) return out;
786
+ const positionOf = new Map<string, number>();
787
+ for (let i = 0; i < messages.length; i++) positionOf.set(messages[i].id, i);
788
+
789
+ for (const pin of this.pins) {
790
+ if (pin.level === undefined && pin.maxLevel === undefined) continue;
791
+ const first = positionOf.get(pin.firstMessageId);
792
+ const last = positionOf.get(pin.lastMessageId);
793
+ if (first === undefined || last === undefined) continue;
794
+ const lo = Math.min(first, last);
795
+ const hi = Math.max(first, last);
796
+ for (let i = lo; i <= hi; i++) {
797
+ const prev = out.get(i) ?? {};
798
+ // A fixed level is the strongest constraint; when two pins fix the same
799
+ // position, the shallower (lower) level wins (finest requirement).
800
+ if (pin.level !== undefined) {
801
+ prev.level = prev.level === undefined ? pin.level : Math.min(prev.level, pin.level);
802
+ } else if (pin.maxLevel !== undefined) {
803
+ prev.maxLevel = prev.maxLevel === undefined ? pin.maxLevel : Math.min(prev.maxLevel, pin.maxLevel);
804
+ }
805
+ out.set(i, prev);
806
+ }
807
+ }
808
+ return out;
809
+ }
810
+
738
811
  /**
739
812
  * Append a summary to the in-memory list and to the chronicle AppendLog.
740
813
  * Single point so subclasses inherit persistence.
@@ -1004,20 +1077,31 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1004
1077
  */
1005
1078
  protected driveSpeculativeDrain(ctx: StrategyContext): void {
1006
1079
  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;
1080
+ // Merges consolidate existing L_k summaries into L_{k+1} and REDUCE the
1081
+ // unmerged-L1 count; L1 compression PRODUCES new unmerged L1s. The
1082
+ // speculation cap / preflight throttle *production* only — they must never
1083
+ // gate merges, otherwise exceeding the cap (e.g. after a manual backfill)
1084
+ // permanently deadlocks the drain: too many unmerged L1s trips the cap,
1085
+ // which blocks the very merges that would bring the count back down.
1086
+ const hasMerges = this.config.hierarchical === true && this.mergeQueue.length > 0;
1087
+ const hasCompression = this.compressionQueue.length > 0;
1088
+ if (!hasCompression && !hasMerges) return;
1089
+ // Only bail when the *sole* available work is L1 compression that the cap
1090
+ // or preflight currently forbids. Merge work always proceeds.
1091
+ if (!hasMerges && (this.isAtSpeculativeCap() || !this.shouldCompressPreflight())) return;
1092
+
1093
+ const progressBefore = this._drainProgress;
1013
1094
 
1014
1095
  this.tick(ctx)
1015
1096
  .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;
1097
+ // Progress = the tick actually processed a queue item (compress or
1098
+ // merge), tracked by `_drainProgress`. A queue-length delta is the
1099
+ // wrong signal: a productive merge tick can also enqueue a follow-on
1100
+ // merge, leaving the length unchanged which the old check misread as
1101
+ // "no progress" and halted the drain mid-backlog. A genuine no-op tick
1102
+ // (empty queues, at-cap with no merges, no membrane) doesn't advance
1103
+ // the counter, so this still stops cleanly (no runaway recursion).
1104
+ if (this._drainProgress === progressBefore) return;
1021
1105
  // Recurse to drain more. queueMicrotask defers until the current
1022
1106
  // task is done, letting other code (the agent's stream consumer)
1023
1107
  // interleave.
@@ -1029,14 +1113,23 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1029
1113
  }
1030
1114
 
1031
1115
  /**
1032
- * Whether the strategy's pending+queued L1 budget has reached the cap
1116
+ * Whether the count of *produced, unmerged* L1 summaries has reached the cap
1033
1117
  * configured by `maxSpeculativeL1s`. If no cap is set, always false.
1118
+ *
1119
+ * The cap bounds how many L1 summaries may sit un-consolidated before the
1120
+ * strategy must merge them into L_{k+1} (bounding prefix churn / merge debt).
1121
+ * It deliberately does NOT count the pending `compressionQueue`: that queue is
1122
+ * the backlog of work to be *drained*, not produced summaries. Counting it
1123
+ * here would let a large backlog permanently trip the cap and block the very
1124
+ * compression that would clear it — a deadlock (merges relieve the cap, but
1125
+ * compression of the backlog never resumes). The throttle is on produced L1s;
1126
+ * the queue drains freely, with merges keeping the unmerged count under the cap.
1034
1127
  */
1035
1128
  protected isAtSpeculativeCap(): boolean {
1036
1129
  const cap = this.config.maxSpeculativeL1s;
1037
1130
  if (cap === undefined || cap < 0) return false;
1038
1131
  const unmergedL1s = this.summaries.filter(s => s.level === 1 && !s.mergedInto).length;
1039
- return unmergedL1s + this.compressionQueue.length > cap;
1132
+ return unmergedL1s > cap;
1040
1133
  }
1041
1134
 
1042
1135
  /**
@@ -1050,6 +1143,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1050
1143
  }
1051
1144
 
1052
1145
  async tick(ctx: StrategyContext): Promise<void> {
1146
+ phaseChannel.report('compress-tick'); // liveness-watchdog phase
1053
1147
  if (this.pendingCompression) return;
1054
1148
 
1055
1149
  if (!ctx.membrane) {
@@ -1057,16 +1151,18 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1057
1151
  return;
1058
1152
  }
1059
1153
 
1060
- // Priority 1: Compress raw chunks → L1
1061
- if (this.compressionQueue.length > 0) {
1154
+ // Priority 1: Compress raw chunks → L1. Skipped while at the speculative
1155
+ // cap (maxSpeculativeL1s) so we don't pile up more unmerged L1s; the merge
1156
+ // priority below still runs to consolidate existing L1s and relieve the cap.
1157
+ // No cap configured → isAtSpeculativeCap() is always false → unchanged.
1158
+ if (this.compressionQueue.length > 0 && !this.isAtSpeculativeCap()) {
1062
1159
  const chunkIndex = this.compressionQueue.shift()!;
1160
+ this._drainProgress++; // consumed a queue item (real work or stale-cleanup)
1063
1161
  const chunk = this.chunks[chunkIndex];
1064
1162
 
1065
1163
  if (!chunk || chunk.compressed) return;
1066
1164
 
1067
- this.pendingCompression = this.config.hierarchical
1068
- ? this.compressChunkHierarchical(chunk, ctx)
1069
- : this.compressChunkLegacy(chunk, ctx);
1165
+ this.pendingCompression = this.compressChunkHierarchical(chunk, ctx);
1070
1166
 
1071
1167
  try {
1072
1168
  await this.pendingCompression;
@@ -1087,6 +1183,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1087
1183
  // and the next tick() retries it.
1088
1184
  if (this.config.hierarchical && this.mergeQueue.length > 0) {
1089
1185
  const merge = this.mergeQueue[0]!;
1186
+ this._drainProgress++; // executing a merge is real work, even if a
1187
+ // follow-on merge gets enqueued and the queue length nets out unchanged
1090
1188
  this.pendingCompression = this.executeMerge(merge.level, merge.sourceIds, ctx);
1091
1189
 
1092
1190
  try {
@@ -1136,12 +1234,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1136
1234
  ): ContextEntry[] {
1137
1235
  this.rebuildChunks(store);
1138
1236
 
1237
+ // Image stripping runs inside each select path (before stats commit / cache
1238
+ // markers), so the returned entries are already bounded — see
1239
+ // applyImageStripping.
1139
1240
  if (this.config.adaptiveResolution) {
1140
1241
  return this.selectAdaptive(store, budget);
1141
1242
  }
1142
- return this.config.hierarchical
1143
- ? this.selectHierarchical(store, budget)
1144
- : this.selectLegacy(store, log, budget);
1243
+ return this.selectHierarchical(store, budget);
1145
1244
  }
1146
1245
 
1147
1246
  /**
@@ -1171,16 +1270,89 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1171
1270
  * Useful for TUI / dashboards. The token sums use the strategy's own
1172
1271
  * token estimates (which match what `select()` uses for budget math).
1173
1272
  */
1174
- getRenderStats(store: MessageStoreView): {
1175
- head: { messages: number; tokens: number };
1176
- tail: { messages: number; tokens: number };
1177
- summaries: {
1178
- l1: { count: number; tokens: number };
1179
- l2: { count: number; tokens: number };
1180
- l3: { count: number; tokens: number };
1273
+ // ===========================================================================
1274
+ // Render-stats instrumentation inspect, don't reconstruct.
1275
+ //
1276
+ // selectAdaptive/selectHierarchical tally each entry into `_rs` AS THEY EMIT
1277
+ // it (raw head/tail, raw middle the picker kept verbatim, and recall pairs
1278
+ // bucketed by the ancestor summary's level), using the same token numbers the
1279
+ // renderer uses for budget math. `getRenderStats()` returns that committed
1280
+ // snapshot, so it reflects what the last compile actually rendered rather than
1281
+ // re-deriving the full pyramid (which, under adaptive resolution, bears little
1282
+ // resemblance to the folded output).
1283
+ // ===========================================================================
1284
+ private _rs: RenderStats | null = null;
1285
+ private _lastRenderStats: RenderStats | null = null;
1286
+
1287
+ /** Begin a render-stats accumulation for one select() pass. */
1288
+ protected rsBegin(): void {
1289
+ this._rs = {
1290
+ head: { messages: 0, tokens: 0 },
1291
+ tail: { messages: 0, tokens: 0 },
1292
+ middleRaw: { messages: 0, tokens: 0 },
1293
+ summaries: {
1294
+ l1: { count: 0, tokens: 0 },
1295
+ l2: { count: 0, tokens: 0 },
1296
+ l3: { count: 0, tokens: 0 },
1297
+ },
1298
+ pending: { chunks: 0, merges: 0 },
1299
+ total: { messages: 0, tokens: 0 },
1181
1300
  };
1182
- pending: { chunks: number; merges: number };
1183
- } {
1301
+ }
1302
+
1303
+ /** Tally one (or `count`) raw message(s) into a raw bucket. */
1304
+ protected rsRaw(bucket: 'head' | 'tail' | 'middleRaw', tokens: number, count = 1): void {
1305
+ const r = this._rs;
1306
+ if (!r) return;
1307
+ r[bucket].messages += count;
1308
+ r[bucket].tokens += tokens;
1309
+ }
1310
+
1311
+ /** Tally one emitted recall pair under its ancestor's level (>=3 folds into l3). */
1312
+ protected rsSummary(level: number, tokens: number): void {
1313
+ const r = this._rs;
1314
+ if (!r) return;
1315
+ const k: 'l1' | 'l2' | 'l3' = level <= 1 ? 'l1' : level === 2 ? 'l2' : 'l3';
1316
+ r.summaries[k].count += 1;
1317
+ r.summaries[k].tokens += tokens;
1318
+ }
1319
+
1320
+ /** Commit the accumulated stats as the last-render snapshot. */
1321
+ protected rsEnd(): void {
1322
+ const r = this._rs;
1323
+ if (!r) return;
1324
+ r.pending = {
1325
+ chunks: this.chunks.filter(c => !c.compressed).length,
1326
+ merges: this.mergeQueue.length,
1327
+ };
1328
+ const s = r.summaries;
1329
+ const summaryMsgs = (s.l1.count + s.l2.count + s.l3.count) * 2; // Q/A pair each
1330
+ r.total = {
1331
+ messages: r.head.messages + r.tail.messages + r.middleRaw.messages + summaryMsgs,
1332
+ tokens:
1333
+ r.head.tokens + r.tail.tokens + r.middleRaw.tokens +
1334
+ s.l1.tokens + s.l2.tokens + s.l3.tokens,
1335
+ };
1336
+ this._lastRenderStats = r;
1337
+ this._rs = null;
1338
+ }
1339
+
1340
+ /**
1341
+ * Stats describing the LAST rendered context. Returns the inspected snapshot
1342
+ * captured during the most recent `select()`. Before any compile has run (no
1343
+ * snapshot yet), falls back to a reconstructed pyramid view so callers still
1344
+ * get a non-null shape.
1345
+ */
1346
+ getRenderStats(store: MessageStoreView): RenderStats {
1347
+ return this._lastRenderStats ?? this.reconstructRenderStats(store);
1348
+ }
1349
+
1350
+ /**
1351
+ * Pre-render fallback: re-derive head/tail windows + the full live pyramid.
1352
+ * NOTE: this is the old "reconstruct" behavior and does NOT reflect adaptive
1353
+ * folding — used only until the first compile populates the inspected stats.
1354
+ */
1355
+ protected reconstructRenderStats(store: MessageStoreView): RenderStats {
1184
1356
  const messages = store.getAll();
1185
1357
  const headStart = this.getHeadWindowStartIndex(store);
1186
1358
  const headEnd = this.getHeadWindowEnd(store);
@@ -1197,145 +1369,31 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1197
1369
  const sumLevelTokens = (level: SummaryLevel): number =>
1198
1370
  live(level).reduce((acc, s) => acc + s.tokens, 0);
1199
1371
 
1372
+ const head = { messages: headMsgs.length, tokens: sumTokens(headMsgs) };
1373
+ const tail = { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) };
1374
+ const summaries = {
1375
+ l1: { count: live(1).length, tokens: sumLevelTokens(1) },
1376
+ l2: { count: live(2).length, tokens: sumLevelTokens(2) },
1377
+ l3: { count: live(3).length, tokens: sumLevelTokens(3) },
1378
+ };
1200
1379
  return {
1201
- head: { messages: headMsgs.length, tokens: sumTokens(headMsgs) },
1202
- tail: { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) },
1203
- summaries: {
1204
- l1: { count: live(1).length, tokens: sumLevelTokens(1) },
1205
- l2: { count: live(2).length, tokens: sumLevelTokens(2) },
1206
- l3: { count: live(3).length, tokens: sumLevelTokens(3) },
1207
- },
1380
+ head,
1381
+ tail,
1382
+ middleRaw: { messages: 0, tokens: 0 },
1383
+ summaries,
1208
1384
  pending: {
1209
1385
  chunks: this.chunks.filter(c => !c.compressed).length,
1210
1386
  merges: this.mergeQueue.length,
1211
1387
  },
1388
+ total: {
1389
+ messages: head.messages + tail.messages
1390
+ + (summaries.l1.count + summaries.l2.count + summaries.l3.count) * 2,
1391
+ tokens: head.tokens + tail.tokens
1392
+ + summaries.l1.tokens + summaries.l2.tokens + summaries.l3.tokens,
1393
+ },
1212
1394
  };
1213
1395
  }
1214
1396
 
1215
- // ============================================================================
1216
- // Legacy (single-level) path
1217
- // ============================================================================
1218
-
1219
- protected selectLegacy(
1220
- store: MessageStoreView,
1221
- _log: ContextLogView,
1222
- budget: TokenBudget
1223
- ): ContextEntry[] {
1224
- const entries: ContextEntry[] = [];
1225
- const maxTokens = budget.maxTokens - budget.reserveForResponse;
1226
- let totalTokens = 0;
1227
- const messages = store.getAll();
1228
- const msgCap = this.config.maxMessageTokens;
1229
-
1230
- // 1. Head window: preserved verbatim as raw copies
1231
- const headStart = this.getHeadWindowStartIndex(store);
1232
- const headEnd = this.getHeadWindowEnd(store);
1233
- for (let i = headStart; i < headEnd && i < messages.length; i++) {
1234
- const msg = messages[i];
1235
- const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1236
- const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
1237
- if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
1238
-
1239
- entries.push({
1240
- index: entries.length,
1241
- sourceMessageId: msg.id,
1242
- sourceRelation: 'copy',
1243
- participant: msg.participant,
1244
- content,
1245
- });
1246
- totalTokens += tokens;
1247
- }
1248
- // Mark the last head entry as a cache boundary (even if budget truncated the window)
1249
- if (entries.length > 0) {
1250
- entries[entries.length - 1].cacheMarker = true;
1251
- }
1252
-
1253
- // 2. Middle zone: compressed chunks as diary pairs, uncompressed as raw messages.
1254
- const rawRecentStart = this.getRecentWindowStart(store);
1255
- // Track which message IDs are covered by chunks
1256
- const coveredByChunks = new Set<string>();
1257
-
1258
- for (const chunk of this.chunks) {
1259
- for (const m of chunk.messages) coveredByChunks.add(m.id);
1260
-
1261
- if (chunk.compressed && chunk.diary) {
1262
- const contextLabel = this.config.summaryContextLabel ?? 'Here is a summary of earlier conversation context:';
1263
- const summaryParticipant = this.config.summaryParticipant ?? 'Summary';
1264
-
1265
- const questionEntry: ContextEntry = {
1266
- index: entries.length,
1267
- participant: 'Context Manager',
1268
- content: [{ type: 'text', text: contextLabel }],
1269
- sourceRelation: 'derived',
1270
- };
1271
-
1272
- // Synthesised summary turns must respect maxMessageTokens just like raw
1273
- // copies do — otherwise a runaway diary can starve recent messages.
1274
- const answerContent: ContentBlock[] = [{ type: 'text', text: chunk.diary }];
1275
- const answerEntry: ContextEntry = {
1276
- index: entries.length + 1,
1277
- participant: summaryParticipant,
1278
- content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
1279
- sourceRelation: 'derived',
1280
- };
1281
-
1282
- const pairTokens = this.estimateTokens(questionEntry.content) +
1283
- this.estimateTokens(answerEntry.content);
1284
-
1285
- if (this.isOverBudget(totalTokens + pairTokens, maxTokens)) break;
1286
-
1287
- entries.push(questionEntry);
1288
- entries.push(answerEntry);
1289
- totalTokens += pairTokens;
1290
- } else {
1291
- // Uncompressed: emit raw messages so they aren't lost
1292
- for (const msg of chunk.messages) {
1293
- const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1294
- const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
1295
- if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
1296
-
1297
- entries.push({
1298
- index: entries.length,
1299
- sourceMessageId: msg.id,
1300
- sourceRelation: 'copy',
1301
- participant: msg.participant,
1302
- content,
1303
- });
1304
- totalTokens += tokens;
1305
- }
1306
- }
1307
- }
1308
-
1309
- // Emit gap messages in the compressible zone not covered by any chunk.
1310
- // Compressible zone: [0, headStart) ∪ [headEnd, rawRecentStart)
1311
- for (let i = 0; i < rawRecentStart && i < messages.length; i++) {
1312
- // Skip head window messages (already emitted verbatim above)
1313
- if (i >= headStart && i < headEnd) continue;
1314
- if (coveredByChunks.has(messages[i].id)) continue;
1315
- const msg = messages[i];
1316
- const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1317
- const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
1318
- if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
1319
-
1320
- entries.push({
1321
- index: entries.length,
1322
- sourceMessageId: msg.id,
1323
- sourceRelation: 'copy',
1324
- participant: msg.participant,
1325
- content,
1326
- });
1327
- totalTokens += tokens;
1328
- }
1329
-
1330
- // 3. Recent uncompressed messages (skip those already in head window)
1331
- const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
1332
- this.emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokens);
1333
-
1334
- this.trimOrphanedToolUse(entries);
1335
- this.pruneToolEntries(entries);
1336
- return entries;
1337
- }
1338
-
1339
1397
  /**
1340
1398
  * Emit recent-window messages, evicting OLDEST-first when the budget is tight.
1341
1399
  *
@@ -1355,8 +1413,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1355
1413
  msgCap: number,
1356
1414
  maxTokens: number,
1357
1415
  totalTokensBefore: number,
1358
- ): void {
1359
- if (recentStart >= messages.length) return;
1416
+ ): { messages: number; tokens: number } {
1417
+ if (recentStart >= messages.length) return { messages: 0, tokens: 0 };
1360
1418
 
1361
1419
  const accepted: number[] = [];
1362
1420
  let acceptedTokens = 0;
@@ -1380,9 +1438,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1380
1438
  accepted.shift();
1381
1439
  }
1382
1440
 
1441
+ let emittedTokens = 0;
1383
1442
  for (const i of accepted) {
1384
1443
  const msg = messages[i];
1385
1444
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1445
+ const tokens = msgCap > 0
1446
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
1447
+ : store.estimateTokens(msg);
1386
1448
  entries.push({
1387
1449
  index: entries.length,
1388
1450
  sourceMessageId: msg.id,
@@ -1390,100 +1452,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1390
1452
  participant: msg.participant,
1391
1453
  content,
1392
1454
  });
1455
+ emittedTokens += tokens;
1393
1456
  }
1394
- }
1395
-
1396
- protected async compressChunkLegacy(chunk: Chunk, ctx: StrategyContext): Promise<void> {
1397
- if (!ctx.membrane) {
1398
- throw new Error('No membrane instance for compression');
1399
- }
1400
-
1401
- const priorContext = this.buildPriorContextLegacy(chunk, ctx);
1402
- const chunkContent = this.formatChunkForCompression(chunk);
1403
-
1404
- const prompt = this.config.diaryUserPrompt ?? this.config.summaryUserPrompt!;
1405
- const systemPrompt = this.config.diarySystemPrompt ?? this.config.summarySystemPrompt!;
1406
-
1407
- const messages = [
1408
- ...priorContext,
1409
- {
1410
- participant: 'Context Manager',
1411
- content: [{ type: 'text' as const, text: prompt.replace('{content}', chunkContent) }],
1412
- },
1413
- ];
1414
-
1415
- const request: NormalizedRequest = {
1416
- messages: messages.map((m) => ({
1417
- participant: m.participant,
1418
- content: m.content,
1419
- })),
1420
- system: systemPrompt,
1421
- config: {
1422
- model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
1423
- maxTokens: 2000,
1424
- },
1425
- };
1426
-
1427
- try {
1428
- const response = await ctx.membrane.complete(request);
1429
- const diaryText = response.content
1430
- .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
1431
- .map((b) => b.text)
1432
- .join('\n');
1433
-
1434
- chunk.compressed = true;
1435
- chunk.diary = diaryText;
1436
- this._compressionCount++;
1437
- } catch (error) {
1438
- console.error('Failed to compress chunk:', error);
1439
- throw error;
1440
- }
1441
- }
1442
-
1443
- protected buildPriorContextLegacy(chunk: Chunk, ctx: StrategyContext): Array<{
1444
- participant: string;
1445
- content: ContentBlock[];
1446
- }> {
1447
- const context: Array<{ participant: string; content: ContentBlock[] }> = [];
1448
-
1449
- for (const prevChunk of this.chunks) {
1450
- if (prevChunk.index >= chunk.index) break;
1451
- if (!prevChunk.compressed || !prevChunk.diary) continue;
1452
-
1453
- context.push({
1454
- participant: 'Context Manager',
1455
- content: [{ type: 'text', text: this.config.summaryContextLabel ?? 'Summary of earlier context:' }],
1456
- });
1457
- context.push({
1458
- participant: this.config.summaryParticipant ?? 'Summary',
1459
- content: [{ type: 'text', text: prevChunk.diary }],
1460
- });
1461
- }
1462
-
1463
- // Find the actual position of this chunk's first message in the full array
1464
- const messages = ctx.messageStore.getAll();
1465
- const firstMsgId = chunk.messages[0]?.id;
1466
- const chunkAbsStart = firstMsgId
1467
- ? messages.findIndex(m => m.id === firstMsgId)
1468
- : -1;
1469
-
1470
- if (chunkAbsStart > 0) {
1471
- const precedingStart = Math.max(0, chunkAbsStart - 50);
1472
- let tokens = 0;
1473
-
1474
- for (let i = chunkAbsStart - 1; i >= precedingStart && tokens < 15000; i--) {
1475
- const msg = messages[i];
1476
- if (!msg) break;
1477
-
1478
- tokens += ctx.messageStore.estimateTokens(msg);
1479
- context.unshift({
1480
- participant: msg.participant,
1481
- content: msg.content,
1482
- });
1483
- }
1484
- }
1485
-
1486
- return context;
1457
+ return { messages: accepted.length, tokens: emittedTokens };
1487
1458
  }
1488
1459
 
1489
1460
  // ============================================================================
@@ -1532,6 +1503,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1532
1503
  * No system prompt — framing via message structure only.
1533
1504
  */
1534
1505
  protected async compressChunkHierarchical(chunk: Chunk, ctx: StrategyContext): Promise<void> {
1506
+ phaseChannel.report('compress-chunk'); // liveness-watchdog phase
1535
1507
  if (!ctx.membrane) {
1536
1508
  throw new Error('No membrane instance for compression');
1537
1509
  }
@@ -1731,6 +1703,10 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1731
1703
 
1732
1704
  try {
1733
1705
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
1706
+ // Text-only on purpose: this is the SUMMARIZER's one-shot response —
1707
+ // its thinking/redacted_thinking blocks are scratch work, not part of
1708
+ // the agent's history, and signed thinking is never valid inside a
1709
+ // rewritten summary anyway.
1734
1710
  const summaryText = response.content
1735
1711
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
1736
1712
  .map(b => b.text)
@@ -1806,6 +1782,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1806
1782
  * summaries when the queue eventually drains.
1807
1783
  */
1808
1784
  protected checkMergeThreshold(): void {
1785
+ phaseChannel.report('merge-threshold'); // liveness-watchdog phase
1809
1786
  if (this.config.speculativeProduction) {
1810
1787
  this.checkMergeThresholdRecursive();
1811
1788
  return;
@@ -2188,6 +2165,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2188
2165
 
2189
2166
  try {
2190
2167
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
2168
+ // Text-only on purpose: summarizer scratch thinking is not agent history
2191
2169
  const mergedText = response.content
2192
2170
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
2193
2171
  .map(b => b.text)
@@ -2272,6 +2250,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2272
2250
  * See `docs/adaptive-resolution-design.md` §3, §5.
2273
2251
  */
2274
2252
  protected selectAdaptive(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
2253
+ phaseChannel.report('context-build'); // liveness-watchdog phase
2254
+ this.rsBegin();
2275
2255
  const entries: ContextEntry[] = [];
2276
2256
  const maxTokens = budget.maxTokens - budget.reserveForResponse;
2277
2257
  const messages = store.getAll();
@@ -2305,10 +2285,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2305
2285
  totalTokens += tokens;
2306
2286
  headMessageIds.add(msg.id);
2307
2287
  headTokens += tokens;
2288
+ this.rsRaw('head', tokens);
2308
2289
  }
2309
- if (entries.length > 0) {
2310
- entries[entries.length - 1].cacheMarker = true;
2311
- }
2290
+ // (Cache breakpoints are placed in one pass over the FINAL ordered entries
2291
+ // below see placeCacheMarkers — capturing the stable folded prefix, not
2292
+ // just the head boundary.)
2312
2293
 
2313
2294
  // Compute tail message IDs (will be emitted at end)
2314
2295
  const effectiveRecentStart = Math.max(recentStart, headEnd);
@@ -2333,6 +2314,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2333
2314
  // Pinned-position set so the picker doesn't fold messages the user
2334
2315
  // explicitly marked as keep-raw. Built once and reused.
2335
2316
  const pinnedSet = this.pinnedPositions(messages);
2317
+ // V2 dynamic-pin fold-depth bounds (level / maxLevel). A position with a
2318
+ // bound is NOT a classic force-raw pin — the KV-stable controller must be
2319
+ // able to move it to/within its bound — so it renders as `pinned: false`
2320
+ // carrying `pinLevel` / `pinMaxLevel` instead.
2321
+ const pinBounds = this.pinLevelBounds(messages);
2336
2322
 
2337
2323
  // O(1) summary lookup for findAncestorAt — avoids O(summaries) find()
2338
2324
  // calls during emission.
@@ -2346,13 +2332,18 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2346
2332
  const tokens = msgCap > 0
2347
2333
  ? Math.min(store.estimateTokens(msg), msgCap + 50)
2348
2334
  : store.estimateTokens(msg);
2335
+ const bound = pinBounds.get(i);
2349
2336
  pickerChunks.push({
2350
2337
  id: msg.id,
2351
2338
  sequence: i,
2352
2339
  rawTokens: tokens,
2353
2340
  currentResolution: this.resolutions.get(msg.id) ?? 0,
2354
2341
  lockedByAgent: this.locked.has(msg.id),
2355
- pinned: pinnedSet.has(i),
2342
+ // A classic pin (in pinnedSet with no level bound) stays force-raw. A
2343
+ // leveled pin is not force-raw; it carries its bound instead.
2344
+ pinned: pinnedSet.has(i) && bound === undefined,
2345
+ pinLevel: bound?.level,
2346
+ pinMaxLevel: bound?.maxLevel,
2356
2347
  l1Id: ch?.summaryId,
2357
2348
  });
2358
2349
  }
@@ -2401,7 +2392,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2401
2392
  }
2402
2393
 
2403
2394
  // ----- 4. Run the picker -----
2404
- const totalBudget = maxTokens - totalTokens; // tokens left after head
2395
+ // The picker's token count ALREADY includes the pinned head+tail (it gets
2396
+ // headTokens/tailTokens in pickerInputs and result.finalTokens covers them).
2397
+ // So the budget it folds against is the full maxTokens — NOT maxTokens-head,
2398
+ // which double-counts the head (reserves it twice: once here, once because
2399
+ // finalTokens already includes it). The old form threw ~head-tokens early at
2400
+ // tight budgets and quietly under-used the budget by ~head everywhere.
2401
+ const totalBudget = maxTokens;
2405
2402
  const slack = this.config.compressionSlackRatio ?? 0.1;
2406
2403
  const foldingBudget: FoldingBudget = {
2407
2404
  totalBudget,
@@ -2412,19 +2409,17 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2412
2409
  const headSetForPicker = new Set<ChunkId>(headMessageIds);
2413
2410
  const tailSetForPicker = new Set<ChunkId>(tailMessageIds);
2414
2411
 
2415
- const picker = this.getAdaptivePicker();
2416
- const result = picker.run(
2417
- {
2418
- chunks: pickerChunks,
2419
- summaries: summariesMap,
2420
- recallPairTokens,
2421
- headChunkIds: headSetForPicker,
2422
- tailChunkIds: tailSetForPicker,
2423
- headTokens,
2424
- tailTokens,
2425
- },
2426
- foldingBudget
2427
- );
2412
+ const pickerInputs: PickerInputs = {
2413
+ chunks: pickerChunks,
2414
+ summaries: summariesMap,
2415
+ recallPairTokens,
2416
+ headChunkIds: headSetForPicker,
2417
+ tailChunkIds: tailSetForPicker,
2418
+ headTokens,
2419
+ tailTokens,
2420
+ };
2421
+ const picker = this.buildPicker(pickerInputs);
2422
+ const result = picker.run(pickerInputs, foldingBudget);
2428
2423
 
2429
2424
  // Commit the new resolutions back to strategy state for next compile.
2430
2425
  // Persist to chronicle only if anything actually changed — avoids
@@ -2552,6 +2547,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2552
2547
  content,
2553
2548
  });
2554
2549
  totalTokens += tokens;
2550
+ this.rsRaw('middleRaw', tokens);
2555
2551
  } else {
2556
2552
  // summary run — emit Q+A pair, dedup at the strategy level
2557
2553
  const ancestor = currentRun.ancestor;
@@ -2578,6 +2574,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2578
2574
  entries.push(questionEntry);
2579
2575
  entries.push(answerEntry);
2580
2576
  totalTokens += pairTokens;
2577
+ this.rsSummary(ancestor.level, pairTokens);
2581
2578
  }
2582
2579
  }
2583
2580
  currentRun = null;
@@ -2638,6 +2635,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2638
2635
  content,
2639
2636
  });
2640
2637
  totalTokens += tokens;
2638
+ this.rsRaw('middleRaw', tokens);
2641
2639
  i++;
2642
2640
  } else {
2643
2641
  const ancestor = this.findAncestorAt(msg.id, resolution, chunksByMessageId, summariesById);
@@ -2653,6 +2651,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2653
2651
  content,
2654
2652
  });
2655
2653
  totalTokens += tokens;
2654
+ this.rsRaw('middleRaw', tokens);
2656
2655
  i++;
2657
2656
  continue;
2658
2657
  }
@@ -2679,12 +2678,14 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2679
2678
  entries.push(questionEntry);
2680
2679
  entries.push(answerEntry);
2681
2680
  totalTokens += pairTokens;
2681
+ this.rsSummary(ancestor.level, pairTokens);
2682
2682
  i++;
2683
2683
  }
2684
2684
  }
2685
2685
 
2686
2686
  // ----- 6. Emit tail entries newest-first eviction (matches existing behavior) -----
2687
- this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2687
+ const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2688
+ this.rsRaw('tail', tailStats.tokens, tailStats.messages);
2688
2689
 
2689
2690
  // ----- 7. Post-process: merge consecutive raw entries from the same bodyGroup -----
2690
2691
  // Both head and tail emission paths emit shards as separate ContextEntries.
@@ -2694,10 +2695,59 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2694
2695
  // it falls into (preserves KV cache through region transitions).
2695
2696
  const merged = this.mergeAdjacentBodyGroupRaw(entries, store);
2696
2697
 
2698
+ this.pruneToolEntries(merged);
2697
2699
  this.trimOrphanedToolUse(merged);
2700
+ // Strip stale images BEFORE placing markers and committing stats, so both
2701
+ // describe the post-strip context the agent actually receives.
2702
+ this.applyImageStripping(merged, store);
2703
+ // Place ≤4 cache breakpoints across the FINAL ordered entries so the
2704
+ // provider can reuse the stable folded prefix — not just the head. With a
2705
+ // single head marker the cache hit is ~2%; well-placed breakpoints take the
2706
+ // real strategy to ~50% (docs/kv-stable-context-control.md — marker
2707
+ // placement is the dominant KV lever).
2708
+ this.placeCacheMarkers(merged, headMessageIds, tailMessageIds);
2709
+ this.rsEnd();
2698
2710
  return merged;
2699
2711
  }
2700
2712
 
2713
+ /**
2714
+ * Place up to four `cache_control` breakpoints across the final ordered
2715
+ * entries: the head/system boundary, the end of the folded history (the
2716
+ * stable prefix that persists turn-to-turn — the most valuable), a mid-history
2717
+ * seam, and the very end (for pure-append reuse). Mirrors `placeMarkers` in
2718
+ * the adaptive layer but operates on emitted entries. Idempotent; clears any
2719
+ * pre-existing markers first.
2720
+ */
2721
+ protected placeCacheMarkers(
2722
+ entries: ContextEntry[],
2723
+ headMessageIds: ReadonlySet<MessageId>,
2724
+ tailMessageIds: ReadonlySet<MessageId>,
2725
+ ): void {
2726
+ for (const e of entries) if (e.cacheMarker) e.cacheMarker = false;
2727
+ const n = entries.length;
2728
+ if (n === 0) return;
2729
+
2730
+ let lastHead = -1;
2731
+ let firstTail = n;
2732
+ for (let i = 0; i < n; i++) {
2733
+ const sid = entries[i].sourceMessageId;
2734
+ if (sid && headMessageIds.has(sid)) lastHead = i;
2735
+ }
2736
+ for (let i = 0; i < n; i++) {
2737
+ const sid = entries[i].sourceMessageId;
2738
+ if (sid && tailMessageIds.has(sid)) { firstTail = i; break; }
2739
+ }
2740
+ const historyEnd = firstTail - 1; // last middle (folded-history) entry
2741
+
2742
+ const marks = new Set<number>();
2743
+ if (lastHead >= 0) marks.add(lastHead); // system / head block
2744
+ if (historyEnd > lastHead) marks.add(historyEnd); // stable folded prefix (the big one)
2745
+ if (historyEnd - lastHead > 2) marks.add(lastHead + Math.floor((historyEnd - lastHead) / 2)); // mid-history
2746
+ marks.add(n - 1); // end → pure-append reuse
2747
+
2748
+ for (const idx of marks) if (idx >= 0 && idx < n) entries[idx].cacheMarker = true;
2749
+ }
2750
+
2701
2751
  /**
2702
2752
  * Walk an entries array; for every run of consecutive entries that
2703
2753
  * (a) have sourceRelation: 'copy' (raw, not a synthesized recall pair)
@@ -2791,6 +2841,23 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2791
2841
  return this._adaptivePicker;
2792
2842
  }
2793
2843
 
2844
+ /**
2845
+ * Build the picker for this compile. Instance folding strategies (kv-stable)
2846
+ * need the per-compile `PickerInputs` at construction, so they're built fresh
2847
+ * here; stateless ones (flat-profile / oldest-first) reuse the memoized picker.
2848
+ */
2849
+ protected buildPicker(inputs: PickerInputs): Picker {
2850
+ if (this.config.foldingStrategy === 'kv-stable') {
2851
+ return new Picker(
2852
+ new KvStableStrategy(inputs, {
2853
+ reachTokens: this.config.kvStableReachTokens,
2854
+ mergeThreshold: this.config.mergeThreshold,
2855
+ }),
2856
+ );
2857
+ }
2858
+ return this.getAdaptivePicker();
2859
+ }
2860
+
2794
2861
  /**
2795
2862
  * Walk the summary tree to find the L_k ancestor of a message.
2796
2863
  * Returns null if no ancestor exists at that level (e.g., L_k not yet produced).
@@ -2871,6 +2938,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2871
2938
  * Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
2872
2939
  */
2873
2940
  protected selectHierarchical(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
2941
+ phaseChannel.report('context-build'); // liveness-watchdog phase
2942
+ this.rsBegin();
2874
2943
  const entries: ContextEntry[] = [];
2875
2944
  const maxTokens = budget.maxTokens - budget.reserveForResponse;
2876
2945
  const messages = store.getAll();
@@ -2895,6 +2964,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2895
2964
  content,
2896
2965
  });
2897
2966
  totalTokens += tokens;
2967
+ this.rsRaw('head', tokens);
2898
2968
  }
2899
2969
  // Mark the last head entry as a cache boundary (even if budget truncated the window)
2900
2970
  if (entries.length > 0) {
@@ -3054,6 +3124,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3054
3124
  entries.push(questionEntry);
3055
3125
  entries.push(answerEntry);
3056
3126
  totalTokens += pairTokens;
3127
+ this.rsSummary(summary.level, pairTokens);
3057
3128
  } else {
3058
3129
  const msg = item.msg;
3059
3130
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
@@ -3069,6 +3140,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3069
3140
  content,
3070
3141
  });
3071
3142
  totalTokens += tokens;
3143
+ this.rsRaw('middleRaw', tokens);
3072
3144
  }
3073
3145
  }
3074
3146
  } else {
@@ -3102,6 +3174,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3102
3174
  entries.push(questionEntry);
3103
3175
  entries.push(answerEntry);
3104
3176
  totalTokens += pairTokens;
3177
+ for (const s of selectedSummaries) this.rsSummary(s.level, s.tokens);
3105
3178
  }
3106
3179
 
3107
3180
  // Sort by position so uncompressed-middle messages and pins both
@@ -3121,6 +3194,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3121
3194
  content,
3122
3195
  });
3123
3196
  totalTokens += tokens;
3197
+ this.rsRaw('middleRaw', tokens);
3124
3198
  }
3125
3199
  }
3126
3200
  }
@@ -3130,10 +3204,15 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3130
3204
  // budget, the latest messages (the ones the agent actually needs to act
3131
3205
  // on) are preserved and the oldest recent-window messages are dropped.
3132
3206
  const effectiveRecentStart = Math.max(recentStart, headEnd);
3133
- this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
3207
+ const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
3208
+ this.rsRaw('tail', tailStats.tokens, tailStats.messages);
3134
3209
 
3135
3210
  this.trimOrphanedToolUse(entries);
3136
3211
  this.pruneToolEntries(entries);
3212
+ // Strip stale images before committing stats so RenderStats.total reflects
3213
+ // the post-strip context (this path places no cache markers).
3214
+ this.applyImageStripping(entries, store);
3215
+ this.rsEnd();
3137
3216
  return entries;
3138
3217
  }
3139
3218
 
@@ -3388,6 +3467,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3388
3467
  };
3389
3468
 
3390
3469
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
3470
+ // Text-only on purpose: summarizer scratch thinking is not agent history
3391
3471
  return response.content
3392
3472
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
3393
3473
  .map(b => b.text)
@@ -3561,7 +3641,6 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3561
3641
  const existing = existingCompressed.get(key);
3562
3642
  if (existing) {
3563
3643
  chunk.compressed = true;
3564
- chunk.diary = existing.diary;
3565
3644
  chunk.summaryId = existing.summaryId;
3566
3645
  }
3567
3646
 
@@ -3583,6 +3662,98 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3583
3662
  return chunk.messages.map((m) => m.id).join(':');
3584
3663
  }
3585
3664
 
3665
+ /** True if any content block is a live image. */
3666
+ protected hasImageBlock(content: ContentBlock[]): boolean {
3667
+ return content.some((b) => b.type === 'image');
3668
+ }
3669
+
3670
+ /** Message index marking the image-strip depth boundary: walks newest→oldest
3671
+ * summing the same per-message estimate as getRecentWindowStart, and returns
3672
+ * the index of the first message still within `depthTokens`. Messages before
3673
+ * this index have their images stripped to placeholders. */
3674
+ protected getImageStripStart(store: MessageStoreView, depthTokens: number): number {
3675
+ const messages = store.getAll();
3676
+ let tokens = 0;
3677
+ for (let i = messages.length - 1; i >= 0; i--) {
3678
+ tokens += store.estimateTokens(messages[i]);
3679
+ if (tokens > depthTokens) return i + 1;
3680
+ }
3681
+ return 0;
3682
+ }
3683
+
3684
+ /** Text substituted for an image block once it leaves the live-image window. */
3685
+ private static readonly IMAGE_PLACEHOLDER = '[image dropped from live context]';
3686
+
3687
+ /** Post-pass over compiled entries: replace image blocks with a text
3688
+ * placeholder once they fall outside the live-image window — either deeper
3689
+ * than `imageStripDepthTokens` from the newest message, or beyond the
3690
+ * `maxLiveImages` most-recent images (counted newest-first). Summaries are
3691
+ * already text, so they're naturally unaffected. The adjacent
3692
+ * "[image attachment: <name>]" text added at ingest preserves the filename,
3693
+ * so the placeholder itself stays terse. Reduces tokens, so it never pushes
3694
+ * a compiled context back over budget.
3695
+ *
3696
+ * Runs INSIDE each select path, *before* `rsEnd()` and `placeCacheMarkers`,
3697
+ * so the committed render stats (and the cache breakpoints) describe the
3698
+ * post-strip context. As it strips, it decrements the matching raw bucket of
3699
+ * the in-progress render stats by the reclaimed tokens, keeping
3700
+ * `RenderStats.total` equal to the real rendered size. */
3701
+ protected applyImageStripping(entries: ContextEntry[], store: MessageStoreView): void {
3702
+ const maxLive = this.config.maxLiveImages ?? 0; // 0 = unlimited count
3703
+ const depthTokens = this.config.imageStripDepthTokens ?? 0; // 0 = no depth strip
3704
+ if (maxLive === 0 && depthTokens === 0) return; // policy disabled
3705
+
3706
+ const messages = store.getAll();
3707
+ const posById = new Map<string, number>();
3708
+ for (let i = 0; i < messages.length; i++) posById.set(messages[i].id, i);
3709
+ const stripStart = depthTokens > 0 ? this.getImageStripStart(store, depthTokens) : 0;
3710
+
3711
+ // Same region windows select() bucketed by, so a stripped image's reclaimed
3712
+ // tokens come back out of the bucket it was originally tallied into.
3713
+ const headStart = this.getHeadWindowStartIndex(store);
3714
+ const headEnd = this.getHeadWindowEnd(store);
3715
+ const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
3716
+ const bucketAt = (pos: number): 'head' | 'tail' | 'middleRaw' => {
3717
+ if (pos < 0) return 'middleRaw'; // no resolvable region — keep total == Σbuckets
3718
+ if (pos >= headStart && pos < headEnd) return 'head';
3719
+ if (pos >= recentStart) return 'tail';
3720
+ return 'middleRaw';
3721
+ };
3722
+ const placeholderTokens = Math.ceil(AutobiographicalStrategy.IMAGE_PLACEHOLDER.length / 4);
3723
+
3724
+ // Image-bearing entries, newest-first by source position. Entries with no
3725
+ // resolvable source position sort last (pos -1) and never count as "live".
3726
+ const ordered = entries
3727
+ .map((entry, idx) => ({
3728
+ idx,
3729
+ pos: entry.sourceMessageId !== undefined ? posById.get(entry.sourceMessageId) ?? -1 : -1,
3730
+ }))
3731
+ .filter(({ idx }) => this.hasImageBlock(entries[idx].content))
3732
+ .sort((a, b) => b.pos - a.pos);
3733
+
3734
+ let keptImages = 0;
3735
+ for (const { idx, pos } of ordered) {
3736
+ const entry = entries[idx];
3737
+ const tooDeep = depthTokens > 0 && (pos < 0 || pos < stripStart);
3738
+ const bucket = bucketAt(pos);
3739
+ entry.content = entry.content.map((block) => {
3740
+ if (block.type !== 'image') return block;
3741
+ const overCount = maxLive > 0 && keptImages >= maxLive;
3742
+ if (tooDeep || overCount) {
3743
+ // The renderer estimates an image at `tokenEstimate ?? 1600` (see
3744
+ // message-store/context-log). Hand that back to the bucket, less the
3745
+ // placeholder text that replaces it, so the stats match the output.
3746
+ const reclaimed =
3747
+ ((block as { tokenEstimate?: number }).tokenEstimate ?? 1600) - placeholderTokens;
3748
+ if (this._rs && reclaimed > 0) this._rs[bucket].tokens -= reclaimed;
3749
+ return { type: 'text', text: AutobiographicalStrategy.IMAGE_PLACEHOLDER } as ContentBlock;
3750
+ }
3751
+ keptImages++;
3752
+ return block;
3753
+ });
3754
+ }
3755
+ }
3756
+
3586
3757
  protected getRecentWindowStart(store: MessageStoreView): number {
3587
3758
  const messages = store.getAll();
3588
3759
  let tokens = 0;