@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,4 +1,5 @@
1
1
  import { NativeFormatter } from '@animalabs/membrane';
2
+ import { phaseChannel } from '../phase-channel.js';
2
3
  import { DEFAULT_AUTOBIOGRAPHICAL_CONFIG } from '../types/index.js';
3
4
  import { getSummaryParentId } from '../types/strategy.js';
4
5
  import { splitMixedToolMessages, stripUnpairedToolBlocks } from '../normalize-tool-messages.js';
@@ -6,6 +7,7 @@ import { appendFileSync, mkdirSync } from 'node:fs';
6
7
  import { dirname } from 'node:path';
7
8
  import { Picker, OverBudgetError } from '../adaptive/picker.js';
8
9
  import { FlatProfileStrategy } from '../adaptive/strategies/flat-profile.js';
10
+ import { KvStableStrategy } from '../adaptive/strategies/kv-stable.js';
9
11
  import { OldestFirstStrategy } from '../adaptive/strategies/oldest-first.js';
10
12
  import { chunkMessage, DEFAULT_CHUNKER_OPTIONS } from '../adaptive/chunker.js';
11
13
  /**
@@ -149,6 +151,20 @@ function safeSlice(str, start, end) {
149
151
  }
150
152
  return str.slice(start, end);
151
153
  }
154
+ /**
155
+ * Validate + normalize the optional V2 pin fold-depth bounds. Returns only the
156
+ * fields that are present and valid (non-negative integers), so a classic pin
157
+ * with no bounds persists exactly as before. `level` takes precedence over
158
+ * `maxLevel` (pin-at-k is stronger than a cap), so they're never both emitted.
159
+ */
160
+ function normalizePinLevels(opts) {
161
+ const clean = (v) => typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : undefined;
162
+ const level = clean(opts?.level);
163
+ if (level !== undefined)
164
+ return { level };
165
+ const maxLevel = clean(opts?.maxLevel);
166
+ return maxLevel !== undefined ? { maxLevel } : {};
167
+ }
152
168
  /**
153
169
  * Autobiographical chunking strategy.
154
170
  * Compresses old conversation chunks into summaries in the model's own words.
@@ -166,6 +182,14 @@ export class AutobiographicalStrategy {
166
182
  pendingCompression = null;
167
183
  compressionQueue = [];
168
184
  _compressionCount = 0;
185
+ /**
186
+ * Monotonic counter of tick() operations that actually processed a queue item
187
+ * (compressed a chunk or executed a merge). `driveSpeculativeDrain` recurses
188
+ * while this advances — a length-delta check would falsely read "no progress"
189
+ * when a productive tick also enqueues a follow-on item (net queue length
190
+ * unchanged), halting the drain with work still queued.
191
+ */
192
+ _drainProgress = 0;
169
193
  // Hierarchical state
170
194
  summaries = [];
171
195
  summaryIdCounter = 0;
@@ -474,6 +498,7 @@ export class AutobiographicalStrategy {
474
498
  kind: 'pin',
475
499
  name: opts?.name,
476
500
  created: Date.now(),
501
+ ...normalizePinLevels(opts),
477
502
  });
478
503
  this.persistPins();
479
504
  return id;
@@ -492,10 +517,20 @@ export class AutobiographicalStrategy {
492
517
  kind: 'document',
493
518
  name: opts?.name,
494
519
  created: Date.now(),
520
+ ...normalizePinLevels(opts),
495
521
  });
496
522
  this.persistPins();
497
523
  return id;
498
524
  }
525
+ /**
526
+ * V2 dynamic pin-at-level-k convenience: fix a range to render at EXACTLY
527
+ * fold level `level` (0 = raw). Honored only by `foldingStrategy: 'kv-stable'`;
528
+ * other strategies fall back to treating the range as raw. Equivalent to
529
+ * `pinRange(first, last, { level })`.
530
+ */
531
+ pinAtLevel(firstMessageId, lastMessageId, level, opts) {
532
+ return this.pinRange(firstMessageId, lastMessageId, { name: opts?.name, level });
533
+ }
499
534
  /** Remove a pin or document mark by id. Returns true if removed. */
500
535
  unpin(pinId) {
501
536
  const before = this.pins.length;
@@ -611,6 +646,45 @@ export class AutobiographicalStrategy {
611
646
  }
612
647
  return out;
613
648
  }
649
+ /**
650
+ * Resolve the V2 dynamic-pin fold-depth bounds (`ProtectedRange.level` /
651
+ * `maxLevel`) to message positions. Only pins that carry a bound appear; a
652
+ * classic raw pin (no bound) is absent here and handled by `pinnedPositions`.
653
+ * When ranges overlap, the FINEST requirement wins (lowest effective level):
654
+ * a fixed `level` clamps both ends; a `maxLevel` only caps depth. Honored
655
+ * solely by the KV-stable controller — see `ProtectedRange`.
656
+ */
657
+ pinLevelBounds(messages) {
658
+ const out = new Map();
659
+ if (this.pins.length === 0)
660
+ return out;
661
+ const positionOf = new Map();
662
+ for (let i = 0; i < messages.length; i++)
663
+ positionOf.set(messages[i].id, i);
664
+ for (const pin of this.pins) {
665
+ if (pin.level === undefined && pin.maxLevel === undefined)
666
+ continue;
667
+ const first = positionOf.get(pin.firstMessageId);
668
+ const last = positionOf.get(pin.lastMessageId);
669
+ if (first === undefined || last === undefined)
670
+ continue;
671
+ const lo = Math.min(first, last);
672
+ const hi = Math.max(first, last);
673
+ for (let i = lo; i <= hi; i++) {
674
+ const prev = out.get(i) ?? {};
675
+ // A fixed level is the strongest constraint; when two pins fix the same
676
+ // position, the shallower (lower) level wins (finest requirement).
677
+ if (pin.level !== undefined) {
678
+ prev.level = prev.level === undefined ? pin.level : Math.min(prev.level, pin.level);
679
+ }
680
+ else if (pin.maxLevel !== undefined) {
681
+ prev.maxLevel = prev.maxLevel === undefined ? pin.maxLevel : Math.min(prev.maxLevel, pin.maxLevel);
682
+ }
683
+ out.set(i, prev);
684
+ }
685
+ }
686
+ return out;
687
+ }
614
688
  /**
615
689
  * Append a summary to the in-memory list and to the chronicle AppendLog.
616
690
  * Single point so subclasses inherit persistence.
@@ -865,21 +939,31 @@ export class AutobiographicalStrategy {
865
939
  driveSpeculativeDrain(ctx) {
866
940
  if (this.pendingCompression)
867
941
  return;
868
- if (this.compressionQueue.length === 0 && this.mergeQueue.length === 0)
942
+ // Merges consolidate existing L_k summaries into L_{k+1} and REDUCE the
943
+ // unmerged-L1 count; L1 compression PRODUCES new unmerged L1s. The
944
+ // speculation cap / preflight throttle *production* only — they must never
945
+ // gate merges, otherwise exceeding the cap (e.g. after a manual backfill)
946
+ // permanently deadlocks the drain: too many unmerged L1s trips the cap,
947
+ // which blocks the very merges that would bring the count back down.
948
+ const hasMerges = this.config.hierarchical === true && this.mergeQueue.length > 0;
949
+ const hasCompression = this.compressionQueue.length > 0;
950
+ if (!hasCompression && !hasMerges)
869
951
  return;
870
- if (this.isAtSpeculativeCap())
952
+ // Only bail when the *sole* available work is L1 compression that the cap
953
+ // or preflight currently forbids. Merge work always proceeds.
954
+ if (!hasMerges && (this.isAtSpeculativeCap() || !this.shouldCompressPreflight()))
871
955
  return;
872
- if (!this.shouldCompressPreflight())
873
- return;
874
- const beforeChunks = this.compressionQueue.length;
875
- const beforeMerges = this.mergeQueue.length;
956
+ const progressBefore = this._drainProgress;
876
957
  this.tick(ctx)
877
958
  .then(() => {
878
- const afterChunks = this.compressionQueue.length;
879
- const afterMerges = this.mergeQueue.length;
880
- // Made progress if either queue shrank.
881
- const progressed = afterChunks < beforeChunks || afterMerges < beforeMerges;
882
- if (!progressed)
959
+ // Progress = the tick actually processed a queue item (compress or
960
+ // merge), tracked by `_drainProgress`. A queue-length delta is the
961
+ // wrong signal: a productive merge tick can also enqueue a follow-on
962
+ // merge, leaving the length unchanged which the old check misread as
963
+ // "no progress" and halted the drain mid-backlog. A genuine no-op tick
964
+ // (empty queues, at-cap with no merges, no membrane) doesn't advance
965
+ // the counter, so this still stops cleanly (no runaway recursion).
966
+ if (this._drainProgress === progressBefore)
883
967
  return;
884
968
  // Recurse to drain more. queueMicrotask defers until the current
885
969
  // task is done, letting other code (the agent's stream consumer)
@@ -891,15 +975,24 @@ export class AutobiographicalStrategy {
891
975
  });
892
976
  }
893
977
  /**
894
- * Whether the strategy's pending+queued L1 budget has reached the cap
978
+ * Whether the count of *produced, unmerged* L1 summaries has reached the cap
895
979
  * configured by `maxSpeculativeL1s`. If no cap is set, always false.
980
+ *
981
+ * The cap bounds how many L1 summaries may sit un-consolidated before the
982
+ * strategy must merge them into L_{k+1} (bounding prefix churn / merge debt).
983
+ * It deliberately does NOT count the pending `compressionQueue`: that queue is
984
+ * the backlog of work to be *drained*, not produced summaries. Counting it
985
+ * here would let a large backlog permanently trip the cap and block the very
986
+ * compression that would clear it — a deadlock (merges relieve the cap, but
987
+ * compression of the backlog never resumes). The throttle is on produced L1s;
988
+ * the queue drains freely, with merges keeping the unmerged count under the cap.
896
989
  */
897
990
  isAtSpeculativeCap() {
898
991
  const cap = this.config.maxSpeculativeL1s;
899
992
  if (cap === undefined || cap < 0)
900
993
  return false;
901
994
  const unmergedL1s = this.summaries.filter(s => s.level === 1 && !s.mergedInto).length;
902
- return unmergedL1s + this.compressionQueue.length > cap;
995
+ return unmergedL1s > cap;
903
996
  }
904
997
  /**
905
998
  * Preflight hook for whether speculative compression should fire on
@@ -911,21 +1004,24 @@ export class AutobiographicalStrategy {
911
1004
  return true;
912
1005
  }
913
1006
  async tick(ctx) {
1007
+ phaseChannel.report('compress-tick'); // liveness-watchdog phase
914
1008
  if (this.pendingCompression)
915
1009
  return;
916
1010
  if (!ctx.membrane) {
917
1011
  console.warn('AutobiographicalStrategy: No membrane instance for compression');
918
1012
  return;
919
1013
  }
920
- // Priority 1: Compress raw chunks → L1
921
- if (this.compressionQueue.length > 0) {
1014
+ // Priority 1: Compress raw chunks → L1. Skipped while at the speculative
1015
+ // cap (maxSpeculativeL1s) so we don't pile up more unmerged L1s; the merge
1016
+ // priority below still runs to consolidate existing L1s and relieve the cap.
1017
+ // No cap configured → isAtSpeculativeCap() is always false → unchanged.
1018
+ if (this.compressionQueue.length > 0 && !this.isAtSpeculativeCap()) {
922
1019
  const chunkIndex = this.compressionQueue.shift();
1020
+ this._drainProgress++; // consumed a queue item (real work or stale-cleanup)
923
1021
  const chunk = this.chunks[chunkIndex];
924
1022
  if (!chunk || chunk.compressed)
925
1023
  return;
926
- this.pendingCompression = this.config.hierarchical
927
- ? this.compressChunkHierarchical(chunk, ctx)
928
- : this.compressChunkLegacy(chunk, ctx);
1024
+ this.pendingCompression = this.compressChunkHierarchical(chunk, ctx);
929
1025
  try {
930
1026
  await this.pendingCompression;
931
1027
  }
@@ -945,6 +1041,8 @@ export class AutobiographicalStrategy {
945
1041
  // and the next tick() retries it.
946
1042
  if (this.config.hierarchical && this.mergeQueue.length > 0) {
947
1043
  const merge = this.mergeQueue[0];
1044
+ this._drainProgress++; // executing a merge is real work, even if a
1045
+ // follow-on merge gets enqueued and the queue length nets out unchanged
948
1046
  this.pendingCompression = this.executeMerge(merge.level, merge.sourceIds, ctx);
949
1047
  try {
950
1048
  await this.pendingCompression;
@@ -992,12 +1090,13 @@ export class AutobiographicalStrategy {
992
1090
  }
993
1091
  select(store, log, budget) {
994
1092
  this.rebuildChunks(store);
1093
+ // Image stripping runs inside each select path (before stats commit / cache
1094
+ // markers), so the returned entries are already bounded — see
1095
+ // applyImageStripping.
995
1096
  if (this.config.adaptiveResolution) {
996
1097
  return this.selectAdaptive(store, budget);
997
1098
  }
998
- return this.config.hierarchical
999
- ? this.selectHierarchical(store, budget)
1000
- : this.selectLegacy(store, log, budget);
1099
+ return this.selectHierarchical(store, budget);
1001
1100
  }
1002
1101
  /**
1003
1102
  * Get summary statistics for observability.
@@ -1022,7 +1121,85 @@ export class AutobiographicalStrategy {
1022
1121
  * Useful for TUI / dashboards. The token sums use the strategy's own
1023
1122
  * token estimates (which match what `select()` uses for budget math).
1024
1123
  */
1124
+ // ===========================================================================
1125
+ // Render-stats instrumentation — inspect, don't reconstruct.
1126
+ //
1127
+ // selectAdaptive/selectHierarchical tally each entry into `_rs` AS THEY EMIT
1128
+ // it (raw head/tail, raw middle the picker kept verbatim, and recall pairs
1129
+ // bucketed by the ancestor summary's level), using the same token numbers the
1130
+ // renderer uses for budget math. `getRenderStats()` returns that committed
1131
+ // snapshot, so it reflects what the last compile actually rendered rather than
1132
+ // re-deriving the full pyramid (which, under adaptive resolution, bears little
1133
+ // resemblance to the folded output).
1134
+ // ===========================================================================
1135
+ _rs = null;
1136
+ _lastRenderStats = null;
1137
+ /** Begin a render-stats accumulation for one select() pass. */
1138
+ rsBegin() {
1139
+ this._rs = {
1140
+ head: { messages: 0, tokens: 0 },
1141
+ tail: { messages: 0, tokens: 0 },
1142
+ middleRaw: { messages: 0, tokens: 0 },
1143
+ summaries: {
1144
+ l1: { count: 0, tokens: 0 },
1145
+ l2: { count: 0, tokens: 0 },
1146
+ l3: { count: 0, tokens: 0 },
1147
+ },
1148
+ pending: { chunks: 0, merges: 0 },
1149
+ total: { messages: 0, tokens: 0 },
1150
+ };
1151
+ }
1152
+ /** Tally one (or `count`) raw message(s) into a raw bucket. */
1153
+ rsRaw(bucket, tokens, count = 1) {
1154
+ const r = this._rs;
1155
+ if (!r)
1156
+ return;
1157
+ r[bucket].messages += count;
1158
+ r[bucket].tokens += tokens;
1159
+ }
1160
+ /** Tally one emitted recall pair under its ancestor's level (>=3 folds into l3). */
1161
+ rsSummary(level, tokens) {
1162
+ const r = this._rs;
1163
+ if (!r)
1164
+ return;
1165
+ const k = level <= 1 ? 'l1' : level === 2 ? 'l2' : 'l3';
1166
+ r.summaries[k].count += 1;
1167
+ r.summaries[k].tokens += tokens;
1168
+ }
1169
+ /** Commit the accumulated stats as the last-render snapshot. */
1170
+ rsEnd() {
1171
+ const r = this._rs;
1172
+ if (!r)
1173
+ return;
1174
+ r.pending = {
1175
+ chunks: this.chunks.filter(c => !c.compressed).length,
1176
+ merges: this.mergeQueue.length,
1177
+ };
1178
+ const s = r.summaries;
1179
+ const summaryMsgs = (s.l1.count + s.l2.count + s.l3.count) * 2; // Q/A pair each
1180
+ r.total = {
1181
+ messages: r.head.messages + r.tail.messages + r.middleRaw.messages + summaryMsgs,
1182
+ tokens: r.head.tokens + r.tail.tokens + r.middleRaw.tokens +
1183
+ s.l1.tokens + s.l2.tokens + s.l3.tokens,
1184
+ };
1185
+ this._lastRenderStats = r;
1186
+ this._rs = null;
1187
+ }
1188
+ /**
1189
+ * Stats describing the LAST rendered context. Returns the inspected snapshot
1190
+ * captured during the most recent `select()`. Before any compile has run (no
1191
+ * snapshot yet), falls back to a reconstructed pyramid view so callers still
1192
+ * get a non-null shape.
1193
+ */
1025
1194
  getRenderStats(store) {
1195
+ return this._lastRenderStats ?? this.reconstructRenderStats(store);
1196
+ }
1197
+ /**
1198
+ * Pre-render fallback: re-derive head/tail windows + the full live pyramid.
1199
+ * NOTE: this is the old "reconstruct" behavior and does NOT reflect adaptive
1200
+ * folding — used only until the first compile populates the inspected stats.
1201
+ */
1202
+ reconstructRenderStats(store) {
1026
1203
  const messages = store.getAll();
1027
1204
  const headStart = this.getHeadWindowStartIndex(store);
1028
1205
  const headEnd = this.getHeadWindowEnd(store);
@@ -1032,131 +1209,30 @@ export class AutobiographicalStrategy {
1032
1209
  const tailMsgs = messages.slice(recentStart);
1033
1210
  const live = (level) => this.summaries.filter(s => s.level === level && !s.mergedInto);
1034
1211
  const sumLevelTokens = (level) => live(level).reduce((acc, s) => acc + s.tokens, 0);
1212
+ const head = { messages: headMsgs.length, tokens: sumTokens(headMsgs) };
1213
+ const tail = { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) };
1214
+ const summaries = {
1215
+ l1: { count: live(1).length, tokens: sumLevelTokens(1) },
1216
+ l2: { count: live(2).length, tokens: sumLevelTokens(2) },
1217
+ l3: { count: live(3).length, tokens: sumLevelTokens(3) },
1218
+ };
1035
1219
  return {
1036
- head: { messages: headMsgs.length, tokens: sumTokens(headMsgs) },
1037
- tail: { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) },
1038
- summaries: {
1039
- l1: { count: live(1).length, tokens: sumLevelTokens(1) },
1040
- l2: { count: live(2).length, tokens: sumLevelTokens(2) },
1041
- l3: { count: live(3).length, tokens: sumLevelTokens(3) },
1042
- },
1220
+ head,
1221
+ tail,
1222
+ middleRaw: { messages: 0, tokens: 0 },
1223
+ summaries,
1043
1224
  pending: {
1044
1225
  chunks: this.chunks.filter(c => !c.compressed).length,
1045
1226
  merges: this.mergeQueue.length,
1046
1227
  },
1228
+ total: {
1229
+ messages: head.messages + tail.messages
1230
+ + (summaries.l1.count + summaries.l2.count + summaries.l3.count) * 2,
1231
+ tokens: head.tokens + tail.tokens
1232
+ + summaries.l1.tokens + summaries.l2.tokens + summaries.l3.tokens,
1233
+ },
1047
1234
  };
1048
1235
  }
1049
- // ============================================================================
1050
- // Legacy (single-level) path
1051
- // ============================================================================
1052
- selectLegacy(store, _log, budget) {
1053
- const entries = [];
1054
- const maxTokens = budget.maxTokens - budget.reserveForResponse;
1055
- let totalTokens = 0;
1056
- const messages = store.getAll();
1057
- const msgCap = this.config.maxMessageTokens;
1058
- // 1. Head window: preserved verbatim as raw copies
1059
- const headStart = this.getHeadWindowStartIndex(store);
1060
- const headEnd = this.getHeadWindowEnd(store);
1061
- for (let i = headStart; i < headEnd && i < messages.length; i++) {
1062
- const msg = messages[i];
1063
- const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1064
- const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
1065
- if (this.isOverBudget(totalTokens + tokens, maxTokens))
1066
- break;
1067
- entries.push({
1068
- index: entries.length,
1069
- sourceMessageId: msg.id,
1070
- sourceRelation: 'copy',
1071
- participant: msg.participant,
1072
- content,
1073
- });
1074
- totalTokens += tokens;
1075
- }
1076
- // Mark the last head entry as a cache boundary (even if budget truncated the window)
1077
- if (entries.length > 0) {
1078
- entries[entries.length - 1].cacheMarker = true;
1079
- }
1080
- // 2. Middle zone: compressed chunks as diary pairs, uncompressed as raw messages.
1081
- const rawRecentStart = this.getRecentWindowStart(store);
1082
- // Track which message IDs are covered by chunks
1083
- const coveredByChunks = new Set();
1084
- for (const chunk of this.chunks) {
1085
- for (const m of chunk.messages)
1086
- coveredByChunks.add(m.id);
1087
- if (chunk.compressed && chunk.diary) {
1088
- const contextLabel = this.config.summaryContextLabel ?? 'Here is a summary of earlier conversation context:';
1089
- const summaryParticipant = this.config.summaryParticipant ?? 'Summary';
1090
- const questionEntry = {
1091
- index: entries.length,
1092
- participant: 'Context Manager',
1093
- content: [{ type: 'text', text: contextLabel }],
1094
- sourceRelation: 'derived',
1095
- };
1096
- // Synthesised summary turns must respect maxMessageTokens just like raw
1097
- // copies do — otherwise a runaway diary can starve recent messages.
1098
- const answerContent = [{ type: 'text', text: chunk.diary }];
1099
- const answerEntry = {
1100
- index: entries.length + 1,
1101
- participant: summaryParticipant,
1102
- content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
1103
- sourceRelation: 'derived',
1104
- };
1105
- const pairTokens = this.estimateTokens(questionEntry.content) +
1106
- this.estimateTokens(answerEntry.content);
1107
- if (this.isOverBudget(totalTokens + pairTokens, maxTokens))
1108
- break;
1109
- entries.push(questionEntry);
1110
- entries.push(answerEntry);
1111
- totalTokens += pairTokens;
1112
- }
1113
- else {
1114
- // Uncompressed: emit raw messages so they aren't lost
1115
- for (const msg of chunk.messages) {
1116
- const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1117
- const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
1118
- if (this.isOverBudget(totalTokens + tokens, maxTokens))
1119
- break;
1120
- entries.push({
1121
- index: entries.length,
1122
- sourceMessageId: msg.id,
1123
- sourceRelation: 'copy',
1124
- participant: msg.participant,
1125
- content,
1126
- });
1127
- totalTokens += tokens;
1128
- }
1129
- }
1130
- }
1131
- // Emit gap messages in the compressible zone not covered by any chunk.
1132
- // Compressible zone: [0, headStart) ∪ [headEnd, rawRecentStart)
1133
- for (let i = 0; i < rawRecentStart && i < messages.length; i++) {
1134
- // Skip head window messages (already emitted verbatim above)
1135
- if (i >= headStart && i < headEnd)
1136
- continue;
1137
- if (coveredByChunks.has(messages[i].id))
1138
- continue;
1139
- const msg = messages[i];
1140
- const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1141
- const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
1142
- if (this.isOverBudget(totalTokens + tokens, maxTokens))
1143
- break;
1144
- entries.push({
1145
- index: entries.length,
1146
- sourceMessageId: msg.id,
1147
- sourceRelation: 'copy',
1148
- participant: msg.participant,
1149
- content,
1150
- });
1151
- totalTokens += tokens;
1152
- }
1153
- // 3. Recent uncompressed messages (skip those already in head window)
1154
- const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
1155
- this.emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokens);
1156
- this.trimOrphanedToolUse(entries);
1157
- this.pruneToolEntries(entries);
1158
- return entries;
1159
- }
1160
1236
  /**
1161
1237
  * Emit recent-window messages, evicting OLDEST-first when the budget is tight.
1162
1238
  *
@@ -1170,7 +1246,7 @@ export class AutobiographicalStrategy {
1170
1246
  */
1171
1247
  emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokensBefore) {
1172
1248
  if (recentStart >= messages.length)
1173
- return;
1249
+ return { messages: 0, tokens: 0 };
1174
1250
  const accepted = [];
1175
1251
  let acceptedTokens = 0;
1176
1252
  for (let i = messages.length - 1; i >= recentStart; i--) {
@@ -1190,9 +1266,13 @@ export class AutobiographicalStrategy {
1190
1266
  !this.hasToolUse(messages[accepted[0]])) {
1191
1267
  accepted.shift();
1192
1268
  }
1269
+ let emittedTokens = 0;
1193
1270
  for (const i of accepted) {
1194
1271
  const msg = messages[i];
1195
1272
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1273
+ const tokens = msgCap > 0
1274
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
1275
+ : store.estimateTokens(msg);
1196
1276
  entries.push({
1197
1277
  index: entries.length,
1198
1278
  sourceMessageId: msg.id,
@@ -1200,86 +1280,9 @@ export class AutobiographicalStrategy {
1200
1280
  participant: msg.participant,
1201
1281
  content,
1202
1282
  });
1283
+ emittedTokens += tokens;
1203
1284
  }
1204
- }
1205
- async compressChunkLegacy(chunk, ctx) {
1206
- if (!ctx.membrane) {
1207
- throw new Error('No membrane instance for compression');
1208
- }
1209
- const priorContext = this.buildPriorContextLegacy(chunk, ctx);
1210
- const chunkContent = this.formatChunkForCompression(chunk);
1211
- const prompt = this.config.diaryUserPrompt ?? this.config.summaryUserPrompt;
1212
- const systemPrompt = this.config.diarySystemPrompt ?? this.config.summarySystemPrompt;
1213
- const messages = [
1214
- ...priorContext,
1215
- {
1216
- participant: 'Context Manager',
1217
- content: [{ type: 'text', text: prompt.replace('{content}', chunkContent) }],
1218
- },
1219
- ];
1220
- const request = {
1221
- messages: messages.map((m) => ({
1222
- participant: m.participant,
1223
- content: m.content,
1224
- })),
1225
- system: systemPrompt,
1226
- config: {
1227
- model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
1228
- maxTokens: 2000,
1229
- },
1230
- };
1231
- try {
1232
- const response = await ctx.membrane.complete(request);
1233
- const diaryText = response.content
1234
- .filter((b) => b.type === 'text')
1235
- .map((b) => b.text)
1236
- .join('\n');
1237
- chunk.compressed = true;
1238
- chunk.diary = diaryText;
1239
- this._compressionCount++;
1240
- }
1241
- catch (error) {
1242
- console.error('Failed to compress chunk:', error);
1243
- throw error;
1244
- }
1245
- }
1246
- buildPriorContextLegacy(chunk, ctx) {
1247
- const context = [];
1248
- for (const prevChunk of this.chunks) {
1249
- if (prevChunk.index >= chunk.index)
1250
- break;
1251
- if (!prevChunk.compressed || !prevChunk.diary)
1252
- continue;
1253
- context.push({
1254
- participant: 'Context Manager',
1255
- content: [{ type: 'text', text: this.config.summaryContextLabel ?? 'Summary of earlier context:' }],
1256
- });
1257
- context.push({
1258
- participant: this.config.summaryParticipant ?? 'Summary',
1259
- content: [{ type: 'text', text: prevChunk.diary }],
1260
- });
1261
- }
1262
- // Find the actual position of this chunk's first message in the full array
1263
- const messages = ctx.messageStore.getAll();
1264
- const firstMsgId = chunk.messages[0]?.id;
1265
- const chunkAbsStart = firstMsgId
1266
- ? messages.findIndex(m => m.id === firstMsgId)
1267
- : -1;
1268
- if (chunkAbsStart > 0) {
1269
- const precedingStart = Math.max(0, chunkAbsStart - 50);
1270
- let tokens = 0;
1271
- for (let i = chunkAbsStart - 1; i >= precedingStart && tokens < 15000; i--) {
1272
- const msg = messages[i];
1273
- if (!msg)
1274
- break;
1275
- tokens += ctx.messageStore.estimateTokens(msg);
1276
- context.unshift({
1277
- participant: msg.participant,
1278
- content: msg.content,
1279
- });
1280
- }
1281
- }
1282
- return context;
1285
+ return { messages: accepted.length, tokens: emittedTokens };
1283
1286
  }
1284
1287
  // ============================================================================
1285
1288
  // Hierarchical (L1/L2/L3) path
@@ -1312,6 +1315,7 @@ export class AutobiographicalStrategy {
1312
1315
  * No system prompt — framing via message structure only.
1313
1316
  */
1314
1317
  async compressChunkHierarchical(chunk, ctx) {
1318
+ phaseChannel.report('compress-chunk'); // liveness-watchdog phase
1315
1319
  if (!ctx.membrane) {
1316
1320
  throw new Error('No membrane instance for compression');
1317
1321
  }
@@ -1493,6 +1497,10 @@ export class AutobiographicalStrategy {
1493
1497
  let logSummaryId;
1494
1498
  try {
1495
1499
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
1500
+ // Text-only on purpose: this is the SUMMARIZER's one-shot response —
1501
+ // its thinking/redacted_thinking blocks are scratch work, not part of
1502
+ // the agent's history, and signed thinking is never valid inside a
1503
+ // rewritten summary anyway.
1496
1504
  const summaryText = response.content
1497
1505
  .filter((b) => b.type === 'text')
1498
1506
  .map(b => b.text)
@@ -1566,6 +1574,7 @@ export class AutobiographicalStrategy {
1566
1574
  * summaries when the queue eventually drains.
1567
1575
  */
1568
1576
  checkMergeThreshold() {
1577
+ phaseChannel.report('merge-threshold'); // liveness-watchdog phase
1569
1578
  if (this.config.speculativeProduction) {
1570
1579
  this.checkMergeThresholdRecursive();
1571
1580
  return;
@@ -1913,6 +1922,7 @@ export class AutobiographicalStrategy {
1913
1922
  let logNewSummaryId;
1914
1923
  try {
1915
1924
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
1925
+ // Text-only on purpose: summarizer scratch thinking is not agent history
1916
1926
  const mergedText = response.content
1917
1927
  .filter((b) => b.type === 'text')
1918
1928
  .map(b => b.text)
@@ -1992,6 +2002,8 @@ export class AutobiographicalStrategy {
1992
2002
  * See `docs/adaptive-resolution-design.md` §3, §5.
1993
2003
  */
1994
2004
  selectAdaptive(store, budget) {
2005
+ phaseChannel.report('context-build'); // liveness-watchdog phase
2006
+ this.rsBegin();
1995
2007
  const entries = [];
1996
2008
  const maxTokens = budget.maxTokens - budget.reserveForResponse;
1997
2009
  const messages = store.getAll();
@@ -2022,10 +2034,11 @@ export class AutobiographicalStrategy {
2022
2034
  totalTokens += tokens;
2023
2035
  headMessageIds.add(msg.id);
2024
2036
  headTokens += tokens;
2037
+ this.rsRaw('head', tokens);
2025
2038
  }
2026
- if (entries.length > 0) {
2027
- entries[entries.length - 1].cacheMarker = true;
2028
- }
2039
+ // (Cache breakpoints are placed in one pass over the FINAL ordered entries
2040
+ // below see placeCacheMarkers — capturing the stable folded prefix, not
2041
+ // just the head boundary.)
2029
2042
  // Compute tail message IDs (will be emitted at end)
2030
2043
  const effectiveRecentStart = Math.max(recentStart, headEnd);
2031
2044
  for (let i = effectiveRecentStart; i < messages.length; i++) {
@@ -2047,6 +2060,11 @@ export class AutobiographicalStrategy {
2047
2060
  // Pinned-position set so the picker doesn't fold messages the user
2048
2061
  // explicitly marked as keep-raw. Built once and reused.
2049
2062
  const pinnedSet = this.pinnedPositions(messages);
2063
+ // V2 dynamic-pin fold-depth bounds (level / maxLevel). A position with a
2064
+ // bound is NOT a classic force-raw pin — the KV-stable controller must be
2065
+ // able to move it to/within its bound — so it renders as `pinned: false`
2066
+ // carrying `pinLevel` / `pinMaxLevel` instead.
2067
+ const pinBounds = this.pinLevelBounds(messages);
2050
2068
  // O(1) summary lookup for findAncestorAt — avoids O(summaries) find()
2051
2069
  // calls during emission.
2052
2070
  const summariesById = new Map();
@@ -2059,13 +2077,18 @@ export class AutobiographicalStrategy {
2059
2077
  const tokens = msgCap > 0
2060
2078
  ? Math.min(store.estimateTokens(msg), msgCap + 50)
2061
2079
  : store.estimateTokens(msg);
2080
+ const bound = pinBounds.get(i);
2062
2081
  pickerChunks.push({
2063
2082
  id: msg.id,
2064
2083
  sequence: i,
2065
2084
  rawTokens: tokens,
2066
2085
  currentResolution: this.resolutions.get(msg.id) ?? 0,
2067
2086
  lockedByAgent: this.locked.has(msg.id),
2068
- pinned: pinnedSet.has(i),
2087
+ // A classic pin (in pinnedSet with no level bound) stays force-raw. A
2088
+ // leveled pin is not force-raw; it carries its bound instead.
2089
+ pinned: pinnedSet.has(i) && bound === undefined,
2090
+ pinLevel: bound?.level,
2091
+ pinMaxLevel: bound?.maxLevel,
2069
2092
  l1Id: ch?.summaryId,
2070
2093
  });
2071
2094
  }
@@ -2111,7 +2134,13 @@ export class AutobiographicalStrategy {
2111
2134
  recallPairTokens.set(s.id, s.tokens + 20);
2112
2135
  }
2113
2136
  // ----- 4. Run the picker -----
2114
- const totalBudget = maxTokens - totalTokens; // tokens left after head
2137
+ // The picker's token count ALREADY includes the pinned head+tail (it gets
2138
+ // headTokens/tailTokens in pickerInputs and result.finalTokens covers them).
2139
+ // So the budget it folds against is the full maxTokens — NOT maxTokens-head,
2140
+ // which double-counts the head (reserves it twice: once here, once because
2141
+ // finalTokens already includes it). The old form threw ~head-tokens early at
2142
+ // tight budgets and quietly under-used the budget by ~head everywhere.
2143
+ const totalBudget = maxTokens;
2115
2144
  const slack = this.config.compressionSlackRatio ?? 0.1;
2116
2145
  const foldingBudget = {
2117
2146
  totalBudget,
@@ -2120,8 +2149,7 @@ export class AutobiographicalStrategy {
2120
2149
  };
2121
2150
  const headSetForPicker = new Set(headMessageIds);
2122
2151
  const tailSetForPicker = new Set(tailMessageIds);
2123
- const picker = this.getAdaptivePicker();
2124
- const result = picker.run({
2152
+ const pickerInputs = {
2125
2153
  chunks: pickerChunks,
2126
2154
  summaries: summariesMap,
2127
2155
  recallPairTokens,
@@ -2129,7 +2157,9 @@ export class AutobiographicalStrategy {
2129
2157
  tailChunkIds: tailSetForPicker,
2130
2158
  headTokens,
2131
2159
  tailTokens,
2132
- }, foldingBudget);
2160
+ };
2161
+ const picker = this.buildPicker(pickerInputs);
2162
+ const result = picker.run(pickerInputs, foldingBudget);
2133
2163
  // Commit the new resolutions back to strategy state for next compile.
2134
2164
  // Persist to chronicle only if anything actually changed — avoids
2135
2165
  // unnecessary state-slot writes on no-op compiles (which is the common
@@ -2236,6 +2266,7 @@ export class AutobiographicalStrategy {
2236
2266
  content,
2237
2267
  });
2238
2268
  totalTokens += tokens;
2269
+ this.rsRaw('middleRaw', tokens);
2239
2270
  }
2240
2271
  else {
2241
2272
  // summary run — emit Q+A pair, dedup at the strategy level
@@ -2263,6 +2294,7 @@ export class AutobiographicalStrategy {
2263
2294
  entries.push(questionEntry);
2264
2295
  entries.push(answerEntry);
2265
2296
  totalTokens += pairTokens;
2297
+ this.rsSummary(ancestor.level, pairTokens);
2266
2298
  }
2267
2299
  }
2268
2300
  currentRun = null;
@@ -2336,6 +2368,7 @@ export class AutobiographicalStrategy {
2336
2368
  content,
2337
2369
  });
2338
2370
  totalTokens += tokens;
2371
+ this.rsRaw('middleRaw', tokens);
2339
2372
  i++;
2340
2373
  }
2341
2374
  else {
@@ -2353,6 +2386,7 @@ export class AutobiographicalStrategy {
2353
2386
  content,
2354
2387
  });
2355
2388
  totalTokens += tokens;
2389
+ this.rsRaw('middleRaw', tokens);
2356
2390
  i++;
2357
2391
  continue;
2358
2392
  }
@@ -2380,11 +2414,13 @@ export class AutobiographicalStrategy {
2380
2414
  entries.push(questionEntry);
2381
2415
  entries.push(answerEntry);
2382
2416
  totalTokens += pairTokens;
2417
+ this.rsSummary(ancestor.level, pairTokens);
2383
2418
  i++;
2384
2419
  }
2385
2420
  }
2386
2421
  // ----- 6. Emit tail entries newest-first eviction (matches existing behavior) -----
2387
- this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2422
+ const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2423
+ this.rsRaw('tail', tailStats.tokens, tailStats.messages);
2388
2424
  // ----- 7. Post-process: merge consecutive raw entries from the same bodyGroup -----
2389
2425
  // Both head and tail emission paths emit shards as separate ContextEntries.
2390
2426
  // The middle path already merges consecutive same-bodyGroup raw shards into
@@ -2392,9 +2428,62 @@ export class AutobiographicalStrategy {
2392
2428
  // a sharded message renders as ONE API message regardless of which region
2393
2429
  // it falls into (preserves KV cache through region transitions).
2394
2430
  const merged = this.mergeAdjacentBodyGroupRaw(entries, store);
2431
+ this.pruneToolEntries(merged);
2395
2432
  this.trimOrphanedToolUse(merged);
2433
+ // Strip stale images BEFORE placing markers and committing stats, so both
2434
+ // describe the post-strip context the agent actually receives.
2435
+ this.applyImageStripping(merged, store);
2436
+ // Place ≤4 cache breakpoints across the FINAL ordered entries so the
2437
+ // provider can reuse the stable folded prefix — not just the head. With a
2438
+ // single head marker the cache hit is ~2%; well-placed breakpoints take the
2439
+ // real strategy to ~50% (docs/kv-stable-context-control.md — marker
2440
+ // placement is the dominant KV lever).
2441
+ this.placeCacheMarkers(merged, headMessageIds, tailMessageIds);
2442
+ this.rsEnd();
2396
2443
  return merged;
2397
2444
  }
2445
+ /**
2446
+ * Place up to four `cache_control` breakpoints across the final ordered
2447
+ * entries: the head/system boundary, the end of the folded history (the
2448
+ * stable prefix that persists turn-to-turn — the most valuable), a mid-history
2449
+ * seam, and the very end (for pure-append reuse). Mirrors `placeMarkers` in
2450
+ * the adaptive layer but operates on emitted entries. Idempotent; clears any
2451
+ * pre-existing markers first.
2452
+ */
2453
+ placeCacheMarkers(entries, headMessageIds, tailMessageIds) {
2454
+ for (const e of entries)
2455
+ if (e.cacheMarker)
2456
+ e.cacheMarker = false;
2457
+ const n = entries.length;
2458
+ if (n === 0)
2459
+ return;
2460
+ let lastHead = -1;
2461
+ let firstTail = n;
2462
+ for (let i = 0; i < n; i++) {
2463
+ const sid = entries[i].sourceMessageId;
2464
+ if (sid && headMessageIds.has(sid))
2465
+ lastHead = i;
2466
+ }
2467
+ for (let i = 0; i < n; i++) {
2468
+ const sid = entries[i].sourceMessageId;
2469
+ if (sid && tailMessageIds.has(sid)) {
2470
+ firstTail = i;
2471
+ break;
2472
+ }
2473
+ }
2474
+ const historyEnd = firstTail - 1; // last middle (folded-history) entry
2475
+ const marks = new Set();
2476
+ if (lastHead >= 0)
2477
+ marks.add(lastHead); // system / head block
2478
+ if (historyEnd > lastHead)
2479
+ marks.add(historyEnd); // stable folded prefix (the big one)
2480
+ if (historyEnd - lastHead > 2)
2481
+ marks.add(lastHead + Math.floor((historyEnd - lastHead) / 2)); // mid-history
2482
+ marks.add(n - 1); // end → pure-append reuse
2483
+ for (const idx of marks)
2484
+ if (idx >= 0 && idx < n)
2485
+ entries[idx].cacheMarker = true;
2486
+ }
2398
2487
  /**
2399
2488
  * Walk an entries array; for every run of consecutive entries that
2400
2489
  * (a) have sourceRelation: 'copy' (raw, not a synthesized recall pair)
@@ -2483,6 +2572,20 @@ export class AutobiographicalStrategy {
2483
2572
  this._adaptivePicker = new Picker(strategy);
2484
2573
  return this._adaptivePicker;
2485
2574
  }
2575
+ /**
2576
+ * Build the picker for this compile. Instance folding strategies (kv-stable)
2577
+ * need the per-compile `PickerInputs` at construction, so they're built fresh
2578
+ * here; stateless ones (flat-profile / oldest-first) reuse the memoized picker.
2579
+ */
2580
+ buildPicker(inputs) {
2581
+ if (this.config.foldingStrategy === 'kv-stable') {
2582
+ return new Picker(new KvStableStrategy(inputs, {
2583
+ reachTokens: this.config.kvStableReachTokens,
2584
+ mergeThreshold: this.config.mergeThreshold,
2585
+ }));
2586
+ }
2587
+ return this.getAdaptivePicker();
2588
+ }
2486
2589
  /**
2487
2590
  * Walk the summary tree to find the L_k ancestor of a message.
2488
2591
  * Returns null if no ancestor exists at that level (e.g., L_k not yet produced).
@@ -2556,6 +2659,8 @@ export class AutobiographicalStrategy {
2556
2659
  * Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
2557
2660
  */
2558
2661
  selectHierarchical(store, budget) {
2662
+ phaseChannel.report('context-build'); // liveness-watchdog phase
2663
+ this.rsBegin();
2559
2664
  const entries = [];
2560
2665
  const maxTokens = budget.maxTokens - budget.reserveForResponse;
2561
2666
  const messages = store.getAll();
@@ -2578,6 +2683,7 @@ export class AutobiographicalStrategy {
2578
2683
  content,
2579
2684
  });
2580
2685
  totalTokens += tokens;
2686
+ this.rsRaw('head', tokens);
2581
2687
  }
2582
2688
  // Mark the last head entry as a cache boundary (even if budget truncated the window)
2583
2689
  if (entries.length > 0) {
@@ -2727,6 +2833,7 @@ export class AutobiographicalStrategy {
2727
2833
  entries.push(questionEntry);
2728
2834
  entries.push(answerEntry);
2729
2835
  totalTokens += pairTokens;
2836
+ this.rsSummary(summary.level, pairTokens);
2730
2837
  }
2731
2838
  else {
2732
2839
  const msg = item.msg;
@@ -2744,6 +2851,7 @@ export class AutobiographicalStrategy {
2744
2851
  content,
2745
2852
  });
2746
2853
  totalTokens += tokens;
2854
+ this.rsRaw('middleRaw', tokens);
2747
2855
  }
2748
2856
  }
2749
2857
  }
@@ -2775,6 +2883,8 @@ export class AutobiographicalStrategy {
2775
2883
  entries.push(questionEntry);
2776
2884
  entries.push(answerEntry);
2777
2885
  totalTokens += pairTokens;
2886
+ for (const s of selectedSummaries)
2887
+ this.rsSummary(s.level, s.tokens);
2778
2888
  }
2779
2889
  // Sort by position so uncompressed-middle messages and pins both
2780
2890
  // appear in their chronological place after the combined recall pair.
@@ -2794,6 +2904,7 @@ export class AutobiographicalStrategy {
2794
2904
  content,
2795
2905
  });
2796
2906
  totalTokens += tokens;
2907
+ this.rsRaw('middleRaw', tokens);
2797
2908
  }
2798
2909
  }
2799
2910
  }
@@ -2802,9 +2913,14 @@ export class AutobiographicalStrategy {
2802
2913
  // budget, the latest messages (the ones the agent actually needs to act
2803
2914
  // on) are preserved and the oldest recent-window messages are dropped.
2804
2915
  const effectiveRecentStart = Math.max(recentStart, headEnd);
2805
- this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2916
+ const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2917
+ this.rsRaw('tail', tailStats.tokens, tailStats.messages);
2806
2918
  this.trimOrphanedToolUse(entries);
2807
2919
  this.pruneToolEntries(entries);
2920
+ // Strip stale images before committing stats so RenderStats.total reflects
2921
+ // the post-strip context (this path places no cache markers).
2922
+ this.applyImageStripping(entries, store);
2923
+ this.rsEnd();
2808
2924
  return entries;
2809
2925
  }
2810
2926
  // ============================================================================
@@ -3025,6 +3141,7 @@ export class AutobiographicalStrategy {
3025
3141
  },
3026
3142
  };
3027
3143
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
3144
+ // Text-only on purpose: summarizer scratch thinking is not agent history
3028
3145
  return response.content
3029
3146
  .filter((b) => b.type === 'text')
3030
3147
  .map(b => b.text)
@@ -3156,7 +3273,6 @@ export class AutobiographicalStrategy {
3156
3273
  const existing = existingCompressed.get(key);
3157
3274
  if (existing) {
3158
3275
  chunk.compressed = true;
3159
- chunk.diary = existing.diary;
3160
3276
  chunk.summaryId = existing.summaryId;
3161
3277
  }
3162
3278
  // In hierarchical mode, also check if a summary exists for this chunk
@@ -3172,6 +3288,97 @@ export class AutobiographicalStrategy {
3172
3288
  chunkKey(chunk) {
3173
3289
  return chunk.messages.map((m) => m.id).join(':');
3174
3290
  }
3291
+ /** True if any content block is a live image. */
3292
+ hasImageBlock(content) {
3293
+ return content.some((b) => b.type === 'image');
3294
+ }
3295
+ /** Message index marking the image-strip depth boundary: walks newest→oldest
3296
+ * summing the same per-message estimate as getRecentWindowStart, and returns
3297
+ * the index of the first message still within `depthTokens`. Messages before
3298
+ * this index have their images stripped to placeholders. */
3299
+ getImageStripStart(store, depthTokens) {
3300
+ const messages = store.getAll();
3301
+ let tokens = 0;
3302
+ for (let i = messages.length - 1; i >= 0; i--) {
3303
+ tokens += store.estimateTokens(messages[i]);
3304
+ if (tokens > depthTokens)
3305
+ return i + 1;
3306
+ }
3307
+ return 0;
3308
+ }
3309
+ /** Text substituted for an image block once it leaves the live-image window. */
3310
+ static IMAGE_PLACEHOLDER = '[image dropped from live context]';
3311
+ /** Post-pass over compiled entries: replace image blocks with a text
3312
+ * placeholder once they fall outside the live-image window — either deeper
3313
+ * than `imageStripDepthTokens` from the newest message, or beyond the
3314
+ * `maxLiveImages` most-recent images (counted newest-first). Summaries are
3315
+ * already text, so they're naturally unaffected. The adjacent
3316
+ * "[image attachment: <name>]" text added at ingest preserves the filename,
3317
+ * so the placeholder itself stays terse. Reduces tokens, so it never pushes
3318
+ * a compiled context back over budget.
3319
+ *
3320
+ * Runs INSIDE each select path, *before* `rsEnd()` and `placeCacheMarkers`,
3321
+ * so the committed render stats (and the cache breakpoints) describe the
3322
+ * post-strip context. As it strips, it decrements the matching raw bucket of
3323
+ * the in-progress render stats by the reclaimed tokens, keeping
3324
+ * `RenderStats.total` equal to the real rendered size. */
3325
+ applyImageStripping(entries, store) {
3326
+ const maxLive = this.config.maxLiveImages ?? 0; // 0 = unlimited count
3327
+ const depthTokens = this.config.imageStripDepthTokens ?? 0; // 0 = no depth strip
3328
+ if (maxLive === 0 && depthTokens === 0)
3329
+ return; // policy disabled
3330
+ const messages = store.getAll();
3331
+ const posById = new Map();
3332
+ for (let i = 0; i < messages.length; i++)
3333
+ posById.set(messages[i].id, i);
3334
+ const stripStart = depthTokens > 0 ? this.getImageStripStart(store, depthTokens) : 0;
3335
+ // Same region windows select() bucketed by, so a stripped image's reclaimed
3336
+ // tokens come back out of the bucket it was originally tallied into.
3337
+ const headStart = this.getHeadWindowStartIndex(store);
3338
+ const headEnd = this.getHeadWindowEnd(store);
3339
+ const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
3340
+ const bucketAt = (pos) => {
3341
+ if (pos < 0)
3342
+ return 'middleRaw'; // no resolvable region — keep total == Σbuckets
3343
+ if (pos >= headStart && pos < headEnd)
3344
+ return 'head';
3345
+ if (pos >= recentStart)
3346
+ return 'tail';
3347
+ return 'middleRaw';
3348
+ };
3349
+ const placeholderTokens = Math.ceil(AutobiographicalStrategy.IMAGE_PLACEHOLDER.length / 4);
3350
+ // Image-bearing entries, newest-first by source position. Entries with no
3351
+ // resolvable source position sort last (pos -1) and never count as "live".
3352
+ const ordered = entries
3353
+ .map((entry, idx) => ({
3354
+ idx,
3355
+ pos: entry.sourceMessageId !== undefined ? posById.get(entry.sourceMessageId) ?? -1 : -1,
3356
+ }))
3357
+ .filter(({ idx }) => this.hasImageBlock(entries[idx].content))
3358
+ .sort((a, b) => b.pos - a.pos);
3359
+ let keptImages = 0;
3360
+ for (const { idx, pos } of ordered) {
3361
+ const entry = entries[idx];
3362
+ const tooDeep = depthTokens > 0 && (pos < 0 || pos < stripStart);
3363
+ const bucket = bucketAt(pos);
3364
+ entry.content = entry.content.map((block) => {
3365
+ if (block.type !== 'image')
3366
+ return block;
3367
+ const overCount = maxLive > 0 && keptImages >= maxLive;
3368
+ if (tooDeep || overCount) {
3369
+ // The renderer estimates an image at `tokenEstimate ?? 1600` (see
3370
+ // message-store/context-log). Hand that back to the bucket, less the
3371
+ // placeholder text that replaces it, so the stats match the output.
3372
+ const reclaimed = (block.tokenEstimate ?? 1600) - placeholderTokens;
3373
+ if (this._rs && reclaimed > 0)
3374
+ this._rs[bucket].tokens -= reclaimed;
3375
+ return { type: 'text', text: AutobiographicalStrategy.IMAGE_PLACEHOLDER };
3376
+ }
3377
+ keptImages++;
3378
+ return block;
3379
+ });
3380
+ }
3381
+ }
3175
3382
  getRecentWindowStart(store) {
3176
3383
  const messages = store.getAll();
3177
3384
  let tokens = 0;