@animalabs/context-manager 0.5.2 → 0.5.4

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 (110) 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 +152 -0
  10. package/dist/src/adaptive/kv-control.d.ts.map +1 -0
  11. package/dist/src/adaptive/kv-control.js +368 -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/render-offsets.d.ts +61 -0
  18. package/dist/src/adaptive/render-offsets.d.ts.map +1 -0
  19. package/dist/src/adaptive/render-offsets.js +88 -0
  20. package/dist/src/adaptive/render-offsets.js.map +1 -0
  21. package/dist/src/adaptive/strategies/kv-stable.d.ts +48 -0
  22. package/dist/src/adaptive/strategies/kv-stable.d.ts.map +1 -0
  23. package/dist/src/adaptive/strategies/kv-stable.js +102 -0
  24. package/dist/src/adaptive/strategies/kv-stable.js.map +1 -0
  25. package/dist/src/adaptive/summary-tree.d.ts +98 -0
  26. package/dist/src/adaptive/summary-tree.d.ts.map +1 -0
  27. package/dist/src/adaptive/summary-tree.js +214 -0
  28. package/dist/src/adaptive/summary-tree.js.map +1 -0
  29. package/dist/src/context-log.d.ts.map +1 -1
  30. package/dist/src/context-log.js +7 -2
  31. package/dist/src/context-log.js.map +1 -1
  32. package/dist/src/index.d.ts +1 -1
  33. package/dist/src/index.d.ts.map +1 -1
  34. package/dist/src/index.js +1 -1
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/message-store.js +1 -1
  37. package/dist/src/message-store.js.map +1 -1
  38. package/dist/src/normalize-tool-messages.d.ts +33 -0
  39. package/dist/src/normalize-tool-messages.d.ts.map +1 -1
  40. package/dist/src/normalize-tool-messages.js +59 -0
  41. package/dist/src/normalize-tool-messages.js.map +1 -1
  42. package/dist/src/strategies/autobiographical.d.ts +101 -39
  43. package/dist/src/strategies/autobiographical.d.ts.map +1 -1
  44. package/dist/src/strategies/autobiographical.js +398 -222
  45. package/dist/src/strategies/autobiographical.js.map +1 -1
  46. package/dist/src/types/strategy.d.ts +40 -1
  47. package/dist/src/types/strategy.d.ts.map +1 -1
  48. package/dist/src/types/strategy.js +2 -0
  49. package/dist/src/types/strategy.js.map +1 -1
  50. package/dist/test/adaptive/kv-cache-sim.test.d.ts +16 -0
  51. package/dist/test/adaptive/kv-cache-sim.test.d.ts.map +1 -0
  52. package/dist/test/adaptive/kv-cache-sim.test.js +173 -0
  53. package/dist/test/adaptive/kv-cache-sim.test.js.map +1 -0
  54. package/dist/test/adaptive/kv-control.test.d.ts +14 -0
  55. package/dist/test/adaptive/kv-control.test.d.ts.map +1 -0
  56. package/dist/test/adaptive/kv-control.test.js +130 -0
  57. package/dist/test/adaptive/kv-control.test.js.map +1 -0
  58. package/dist/test/adaptive/kv-replay.test.d.ts +15 -0
  59. package/dist/test/adaptive/kv-replay.test.d.ts.map +1 -0
  60. package/dist/test/adaptive/kv-replay.test.js +112 -0
  61. package/dist/test/adaptive/kv-replay.test.js.map +1 -0
  62. package/dist/test/adaptive/kv-stable-strategy.test.d.ts +10 -0
  63. package/dist/test/adaptive/kv-stable-strategy.test.d.ts.map +1 -0
  64. package/dist/test/adaptive/kv-stable-strategy.test.js +89 -0
  65. package/dist/test/adaptive/kv-stable-strategy.test.js.map +1 -0
  66. package/dist/test/adaptive/render-offsets.test.d.ts +11 -0
  67. package/dist/test/adaptive/render-offsets.test.d.ts.map +1 -0
  68. package/dist/test/adaptive/render-offsets.test.js +85 -0
  69. package/dist/test/adaptive/render-offsets.test.js.map +1 -0
  70. package/dist/test/adaptive/summary-tree.test.d.ts +5 -0
  71. package/dist/test/adaptive/summary-tree.test.d.ts.map +1 -0
  72. package/dist/test/adaptive/summary-tree.test.js +72 -0
  73. package/dist/test/adaptive/summary-tree.test.js.map +1 -0
  74. package/dist/test/chunker-tool-boundary.test.d.ts +12 -0
  75. package/dist/test/chunker-tool-boundary.test.d.ts.map +1 -0
  76. package/dist/test/chunker-tool-boundary.test.js +89 -0
  77. package/dist/test/chunker-tool-boundary.test.js.map +1 -0
  78. package/dist/test/compression-shape-invariants.test.d.ts +22 -0
  79. package/dist/test/compression-shape-invariants.test.d.ts.map +1 -0
  80. package/dist/test/compression-shape-invariants.test.js +312 -0
  81. package/dist/test/compression-shape-invariants.test.js.map +1 -0
  82. package/dist/test/image-strip-render-stats.test.d.ts +2 -0
  83. package/dist/test/image-strip-render-stats.test.d.ts.map +1 -0
  84. package/dist/test/image-strip-render-stats.test.js +130 -0
  85. package/dist/test/image-strip-render-stats.test.js.map +1 -0
  86. package/dist/test/image-stripping.test.d.ts +13 -0
  87. package/dist/test/image-stripping.test.d.ts.map +1 -0
  88. package/dist/test/image-stripping.test.js +104 -0
  89. package/dist/test/image-stripping.test.js.map +1 -0
  90. package/dist/test/normalize-tool-messages.test.js +61 -1
  91. package/dist/test/normalize-tool-messages.test.js.map +1 -1
  92. package/dist/test/render-stats.test.d.ts +2 -0
  93. package/dist/test/render-stats.test.d.ts.map +1 -0
  94. package/dist/test/render-stats.test.js +91 -0
  95. package/dist/test/render-stats.test.js.map +1 -0
  96. package/dist/tsconfig.tsbuildinfo +1 -1
  97. package/package.json +2 -2
  98. package/src/adaptive/index.ts +48 -0
  99. package/src/adaptive/kv-cache-sim.ts +0 -0
  100. package/src/adaptive/kv-control.ts +497 -0
  101. package/src/adaptive/kv-replay.ts +238 -0
  102. package/src/adaptive/render-offsets.ts +116 -0
  103. package/src/adaptive/strategies/kv-stable.ts +123 -0
  104. package/src/adaptive/summary-tree.ts +252 -0
  105. package/src/context-log.ts +7 -2
  106. package/src/index.ts +1 -1
  107. package/src/message-store.ts +1 -1
  108. package/src/normalize-tool-messages.ts +61 -0
  109. package/src/strategies/autobiographical.ts +414 -271
  110. package/src/types/strategy.ts +40 -1
@@ -1,11 +1,12 @@
1
1
  import { NativeFormatter } from '@animalabs/membrane';
2
2
  import { DEFAULT_AUTOBIOGRAPHICAL_CONFIG } from '../types/index.js';
3
3
  import { getSummaryParentId } from '../types/strategy.js';
4
- import { splitMixedToolMessages } from '../normalize-tool-messages.js';
4
+ import { splitMixedToolMessages, stripUnpairedToolBlocks } from '../normalize-tool-messages.js';
5
5
  import { appendFileSync, mkdirSync } from 'node:fs';
6
6
  import { dirname } from 'node:path';
7
7
  import { Picker, OverBudgetError } from '../adaptive/picker.js';
8
8
  import { FlatProfileStrategy } from '../adaptive/strategies/flat-profile.js';
9
+ import { KvStableStrategy } from '../adaptive/strategies/kv-stable.js';
9
10
  import { OldestFirstStrategy } from '../adaptive/strategies/oldest-first.js';
10
11
  import { chunkMessage, DEFAULT_CHUNKER_OPTIONS } from '../adaptive/chunker.js';
11
12
  /**
@@ -923,9 +924,7 @@ export class AutobiographicalStrategy {
923
924
  const chunk = this.chunks[chunkIndex];
924
925
  if (!chunk || chunk.compressed)
925
926
  return;
926
- this.pendingCompression = this.config.hierarchical
927
- ? this.compressChunkHierarchical(chunk, ctx)
928
- : this.compressChunkLegacy(chunk, ctx);
927
+ this.pendingCompression = this.compressChunkHierarchical(chunk, ctx);
929
928
  try {
930
929
  await this.pendingCompression;
931
930
  }
@@ -992,12 +991,13 @@ export class AutobiographicalStrategy {
992
991
  }
993
992
  select(store, log, budget) {
994
993
  this.rebuildChunks(store);
994
+ // Image stripping runs inside each select path (before stats commit / cache
995
+ // markers), so the returned entries are already bounded — see
996
+ // applyImageStripping.
995
997
  if (this.config.adaptiveResolution) {
996
998
  return this.selectAdaptive(store, budget);
997
999
  }
998
- return this.config.hierarchical
999
- ? this.selectHierarchical(store, budget)
1000
- : this.selectLegacy(store, log, budget);
1000
+ return this.selectHierarchical(store, budget);
1001
1001
  }
1002
1002
  /**
1003
1003
  * Get summary statistics for observability.
@@ -1022,7 +1022,85 @@ export class AutobiographicalStrategy {
1022
1022
  * Useful for TUI / dashboards. The token sums use the strategy's own
1023
1023
  * token estimates (which match what `select()` uses for budget math).
1024
1024
  */
1025
+ // ===========================================================================
1026
+ // Render-stats instrumentation — inspect, don't reconstruct.
1027
+ //
1028
+ // selectAdaptive/selectHierarchical tally each entry into `_rs` AS THEY EMIT
1029
+ // it (raw head/tail, raw middle the picker kept verbatim, and recall pairs
1030
+ // bucketed by the ancestor summary's level), using the same token numbers the
1031
+ // renderer uses for budget math. `getRenderStats()` returns that committed
1032
+ // snapshot, so it reflects what the last compile actually rendered rather than
1033
+ // re-deriving the full pyramid (which, under adaptive resolution, bears little
1034
+ // resemblance to the folded output).
1035
+ // ===========================================================================
1036
+ _rs = null;
1037
+ _lastRenderStats = null;
1038
+ /** Begin a render-stats accumulation for one select() pass. */
1039
+ rsBegin() {
1040
+ this._rs = {
1041
+ head: { messages: 0, tokens: 0 },
1042
+ tail: { messages: 0, tokens: 0 },
1043
+ middleRaw: { messages: 0, tokens: 0 },
1044
+ summaries: {
1045
+ l1: { count: 0, tokens: 0 },
1046
+ l2: { count: 0, tokens: 0 },
1047
+ l3: { count: 0, tokens: 0 },
1048
+ },
1049
+ pending: { chunks: 0, merges: 0 },
1050
+ total: { messages: 0, tokens: 0 },
1051
+ };
1052
+ }
1053
+ /** Tally one (or `count`) raw message(s) into a raw bucket. */
1054
+ rsRaw(bucket, tokens, count = 1) {
1055
+ const r = this._rs;
1056
+ if (!r)
1057
+ return;
1058
+ r[bucket].messages += count;
1059
+ r[bucket].tokens += tokens;
1060
+ }
1061
+ /** Tally one emitted recall pair under its ancestor's level (>=3 folds into l3). */
1062
+ rsSummary(level, tokens) {
1063
+ const r = this._rs;
1064
+ if (!r)
1065
+ return;
1066
+ const k = level <= 1 ? 'l1' : level === 2 ? 'l2' : 'l3';
1067
+ r.summaries[k].count += 1;
1068
+ r.summaries[k].tokens += tokens;
1069
+ }
1070
+ /** Commit the accumulated stats as the last-render snapshot. */
1071
+ rsEnd() {
1072
+ const r = this._rs;
1073
+ if (!r)
1074
+ return;
1075
+ r.pending = {
1076
+ chunks: this.chunks.filter(c => !c.compressed).length,
1077
+ merges: this.mergeQueue.length,
1078
+ };
1079
+ const s = r.summaries;
1080
+ const summaryMsgs = (s.l1.count + s.l2.count + s.l3.count) * 2; // Q/A pair each
1081
+ r.total = {
1082
+ messages: r.head.messages + r.tail.messages + r.middleRaw.messages + summaryMsgs,
1083
+ tokens: r.head.tokens + r.tail.tokens + r.middleRaw.tokens +
1084
+ s.l1.tokens + s.l2.tokens + s.l3.tokens,
1085
+ };
1086
+ this._lastRenderStats = r;
1087
+ this._rs = null;
1088
+ }
1089
+ /**
1090
+ * Stats describing the LAST rendered context. Returns the inspected snapshot
1091
+ * captured during the most recent `select()`. Before any compile has run (no
1092
+ * snapshot yet), falls back to a reconstructed pyramid view so callers still
1093
+ * get a non-null shape.
1094
+ */
1025
1095
  getRenderStats(store) {
1096
+ return this._lastRenderStats ?? this.reconstructRenderStats(store);
1097
+ }
1098
+ /**
1099
+ * Pre-render fallback: re-derive head/tail windows + the full live pyramid.
1100
+ * NOTE: this is the old "reconstruct" behavior and does NOT reflect adaptive
1101
+ * folding — used only until the first compile populates the inspected stats.
1102
+ */
1103
+ reconstructRenderStats(store) {
1026
1104
  const messages = store.getAll();
1027
1105
  const headStart = this.getHeadWindowStartIndex(store);
1028
1106
  const headEnd = this.getHeadWindowEnd(store);
@@ -1032,131 +1110,30 @@ export class AutobiographicalStrategy {
1032
1110
  const tailMsgs = messages.slice(recentStart);
1033
1111
  const live = (level) => this.summaries.filter(s => s.level === level && !s.mergedInto);
1034
1112
  const sumLevelTokens = (level) => live(level).reduce((acc, s) => acc + s.tokens, 0);
1113
+ const head = { messages: headMsgs.length, tokens: sumTokens(headMsgs) };
1114
+ const tail = { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) };
1115
+ const summaries = {
1116
+ l1: { count: live(1).length, tokens: sumLevelTokens(1) },
1117
+ l2: { count: live(2).length, tokens: sumLevelTokens(2) },
1118
+ l3: { count: live(3).length, tokens: sumLevelTokens(3) },
1119
+ };
1035
1120
  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
- },
1121
+ head,
1122
+ tail,
1123
+ middleRaw: { messages: 0, tokens: 0 },
1124
+ summaries,
1043
1125
  pending: {
1044
1126
  chunks: this.chunks.filter(c => !c.compressed).length,
1045
1127
  merges: this.mergeQueue.length,
1046
1128
  },
1129
+ total: {
1130
+ messages: head.messages + tail.messages
1131
+ + (summaries.l1.count + summaries.l2.count + summaries.l3.count) * 2,
1132
+ tokens: head.tokens + tail.tokens
1133
+ + summaries.l1.tokens + summaries.l2.tokens + summaries.l3.tokens,
1134
+ },
1047
1135
  };
1048
1136
  }
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
1137
  /**
1161
1138
  * Emit recent-window messages, evicting OLDEST-first when the budget is tight.
1162
1139
  *
@@ -1170,7 +1147,7 @@ export class AutobiographicalStrategy {
1170
1147
  */
1171
1148
  emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokensBefore) {
1172
1149
  if (recentStart >= messages.length)
1173
- return;
1150
+ return { messages: 0, tokens: 0 };
1174
1151
  const accepted = [];
1175
1152
  let acceptedTokens = 0;
1176
1153
  for (let i = messages.length - 1; i >= recentStart; i--) {
@@ -1190,9 +1167,13 @@ export class AutobiographicalStrategy {
1190
1167
  !this.hasToolUse(messages[accepted[0]])) {
1191
1168
  accepted.shift();
1192
1169
  }
1170
+ let emittedTokens = 0;
1193
1171
  for (const i of accepted) {
1194
1172
  const msg = messages[i];
1195
1173
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1174
+ const tokens = msgCap > 0
1175
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
1176
+ : store.estimateTokens(msg);
1196
1177
  entries.push({
1197
1178
  index: entries.length,
1198
1179
  sourceMessageId: msg.id,
@@ -1200,86 +1181,9 @@ export class AutobiographicalStrategy {
1200
1181
  participant: msg.participant,
1201
1182
  content,
1202
1183
  });
1184
+ emittedTokens += tokens;
1203
1185
  }
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;
1186
+ return { messages: accepted.length, tokens: emittedTokens };
1283
1187
  }
1284
1188
  // ============================================================================
1285
1189
  // Hierarchical (L1/L2/L3) path
@@ -1410,10 +1314,20 @@ export class AutobiographicalStrategy {
1410
1314
  // raw messages reappear.
1411
1315
  const chunkFirstId = chunk.messages[0]?.id;
1412
1316
  if (chunkFirstId) {
1317
+ // Expand summary sourceIds down to leaf message IDs. An L2 in
1318
+ // `priorSummaries` has L1 IDs in its sourceIds, not message IDs;
1319
+ // a flat walk would miss every message it transitively covers.
1320
+ // (Bug 10 — same shape as executeMerge.)
1321
+ const summariesById = new Map();
1322
+ for (const s of this.summaries)
1323
+ summariesById.set(s.id, s);
1413
1324
  const priorSummaryMessageIds = new Set();
1325
+ for (const s of this.summaries) {
1326
+ if (s.level === 1)
1327
+ this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
1328
+ }
1414
1329
  for (const s of priorSummaries) {
1415
- for (const id of s.sourceIds)
1416
- priorSummaryMessageIds.add(id);
1330
+ this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
1417
1331
  }
1418
1332
  const chunkStartIdx = allMessages.findIndex((m) => m.id === chunkFirstId);
1419
1333
  for (let i = headEndIdx; i < chunkStartIdx && i < allMessages.length; i++) {
@@ -1457,6 +1371,11 @@ export class AutobiographicalStrategy {
1457
1371
  const split = splitMixedToolMessages(llmMessages);
1458
1372
  // Collapse consecutive same-participant messages for API compliance
1459
1373
  const collapsed = this.collapseConsecutiveMessages(split);
1374
+ // Defense in depth against chunk boundaries that cut a tool cycle
1375
+ // (rebuildChunks tries to avoid this, but covers only the most common
1376
+ // case). The API rejects any tool_use that isn't immediately followed
1377
+ // by its tool_result, and any tool_result that doesn't follow a use.
1378
+ const cleaned = stripUnpairedToolBlocks(collapsed);
1460
1379
  // NO system prompt. The agent's identity is established by the head
1461
1380
  // (the actual conversation opening — user message + agent reply that
1462
1381
  // grounded the original instance). A system prompt would (a) add a
@@ -1466,7 +1385,7 @@ export class AutobiographicalStrategy {
1466
1385
  // structural one carried by the conversation itself. Anchoring
1467
1386
  // identity by the chronicle's actual head is more honest.
1468
1387
  const request = {
1469
- messages: collapsed.map(m => ({ participant: m.participant, content: m.content })),
1388
+ messages: cleaned.map(m => ({ participant: m.participant, content: m.content })),
1470
1389
  config: {
1471
1390
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
1472
1391
  maxTokens: Math.round(targetTokens * 1.5),
@@ -1478,6 +1397,10 @@ export class AutobiographicalStrategy {
1478
1397
  let logSummaryId;
1479
1398
  try {
1480
1399
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
1400
+ // Text-only on purpose: this is the SUMMARIZER's one-shot response —
1401
+ // its thinking/redacted_thinking blocks are scratch work, not part of
1402
+ // the agent's history, and signed thinking is never valid inside a
1403
+ // rewritten summary anyway.
1481
1404
  const summaryText = response.content
1482
1405
  .filter((b) => b.type === 'text')
1483
1406
  .map(b => b.text)
@@ -1514,7 +1437,7 @@ export class AutobiographicalStrategy {
1514
1437
  logCompressionCall({
1515
1438
  operation: 'compress_l1',
1516
1439
  system: null,
1517
- messages: collapsed.map((m) => ({
1440
+ messages: cleaned.map((m) => ({
1518
1441
  participant: m.participant,
1519
1442
  // Flatten content for logging — store text only; binary content
1520
1443
  // would bloat the log and isn't typical in compression input.
@@ -1767,10 +1690,20 @@ export class AutobiographicalStrategy {
1767
1690
  // The full unmerged-frontier set covers what's "compressed somewhere"
1768
1691
  // for the raw-middle dedup below — a budget-dropped recall pair
1769
1692
  // doesn't make its underlying raw messages reappear.
1693
+ //
1694
+ // Critical: `sourceIds` on an L2+ summary points at L1 IDs, not raw
1695
+ // message IDs. The dedup happens against raw message IDs, so we must
1696
+ // recursively expand each summary down to its leaf message IDs.
1697
+ // Without this, every message under any L2 leaks back in as raw text
1698
+ // (Bug 10: 525-message merge requests on a 4234-msg conversation).
1699
+ // Also expand merged L1s as defense in depth.
1770
1700
  const priorSummaryMessageIds = new Set();
1701
+ for (const s of this.summaries) {
1702
+ if (s.level === 1)
1703
+ this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
1704
+ }
1771
1705
  for (const s of priorSummariesAll) {
1772
- for (const id of s.sourceIds)
1773
- priorSummaryMessageIds.add(id);
1706
+ this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
1774
1707
  }
1775
1708
  for (const s of keptPriorSummaries) {
1776
1709
  llmMessages.push({
@@ -1871,11 +1804,12 @@ export class AutobiographicalStrategy {
1871
1804
  // Same bundled-tool-cycle defense as compressChunkHierarchical.
1872
1805
  const split = splitMixedToolMessages(llmMessages);
1873
1806
  const collapsed = this.collapseConsecutiveMessages(split);
1807
+ const cleaned = stripUnpairedToolBlocks(collapsed);
1874
1808
  // NO system prompt — identity is established by the head window
1875
1809
  // (present at the start of llmMessages above) and by the prior
1876
1810
  // recall pairs. Same rationale as compressChunkHierarchical.
1877
1811
  const request = {
1878
- messages: collapsed.map(m => ({ participant: m.participant, content: m.content })),
1812
+ messages: cleaned.map(m => ({ participant: m.participant, content: m.content })),
1879
1813
  config: {
1880
1814
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
1881
1815
  maxTokens: Math.round(targetTokens * 1.5),
@@ -1887,6 +1821,7 @@ export class AutobiographicalStrategy {
1887
1821
  let logNewSummaryId;
1888
1822
  try {
1889
1823
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
1824
+ // Text-only on purpose: summarizer scratch thinking is not agent history
1890
1825
  const mergedText = response.content
1891
1826
  .filter((b) => b.type === 'text')
1892
1827
  .map(b => b.text)
@@ -1930,7 +1865,7 @@ export class AutobiographicalStrategy {
1930
1865
  logCompressionCall({
1931
1866
  operation: `merge_l${targetLevel}`,
1932
1867
  system: null,
1933
- messages: collapsed.map((m) => ({
1868
+ messages: cleaned.map((m) => ({
1934
1869
  participant: m.participant,
1935
1870
  text: m.content
1936
1871
  .filter((b) => b.type === 'text')
@@ -1966,6 +1901,7 @@ export class AutobiographicalStrategy {
1966
1901
  * See `docs/adaptive-resolution-design.md` §3, §5.
1967
1902
  */
1968
1903
  selectAdaptive(store, budget) {
1904
+ this.rsBegin();
1969
1905
  const entries = [];
1970
1906
  const maxTokens = budget.maxTokens - budget.reserveForResponse;
1971
1907
  const messages = store.getAll();
@@ -1996,10 +1932,11 @@ export class AutobiographicalStrategy {
1996
1932
  totalTokens += tokens;
1997
1933
  headMessageIds.add(msg.id);
1998
1934
  headTokens += tokens;
1935
+ this.rsRaw('head', tokens);
1999
1936
  }
2000
- if (entries.length > 0) {
2001
- entries[entries.length - 1].cacheMarker = true;
2002
- }
1937
+ // (Cache breakpoints are placed in one pass over the FINAL ordered entries
1938
+ // below see placeCacheMarkers — capturing the stable folded prefix, not
1939
+ // just the head boundary.)
2003
1940
  // Compute tail message IDs (will be emitted at end)
2004
1941
  const effectiveRecentStart = Math.max(recentStart, headEnd);
2005
1942
  for (let i = effectiveRecentStart; i < messages.length; i++) {
@@ -2094,8 +2031,7 @@ export class AutobiographicalStrategy {
2094
2031
  };
2095
2032
  const headSetForPicker = new Set(headMessageIds);
2096
2033
  const tailSetForPicker = new Set(tailMessageIds);
2097
- const picker = this.getAdaptivePicker();
2098
- const result = picker.run({
2034
+ const pickerInputs = {
2099
2035
  chunks: pickerChunks,
2100
2036
  summaries: summariesMap,
2101
2037
  recallPairTokens,
@@ -2103,7 +2039,9 @@ export class AutobiographicalStrategy {
2103
2039
  tailChunkIds: tailSetForPicker,
2104
2040
  headTokens,
2105
2041
  tailTokens,
2106
- }, foldingBudget);
2042
+ };
2043
+ const picker = this.buildPicker(pickerInputs);
2044
+ const result = picker.run(pickerInputs, foldingBudget);
2107
2045
  // Commit the new resolutions back to strategy state for next compile.
2108
2046
  // Persist to chronicle only if anything actually changed — avoids
2109
2047
  // unnecessary state-slot writes on no-op compiles (which is the common
@@ -2210,6 +2148,7 @@ export class AutobiographicalStrategy {
2210
2148
  content,
2211
2149
  });
2212
2150
  totalTokens += tokens;
2151
+ this.rsRaw('middleRaw', tokens);
2213
2152
  }
2214
2153
  else {
2215
2154
  // summary run — emit Q+A pair, dedup at the strategy level
@@ -2237,6 +2176,7 @@ export class AutobiographicalStrategy {
2237
2176
  entries.push(questionEntry);
2238
2177
  entries.push(answerEntry);
2239
2178
  totalTokens += pairTokens;
2179
+ this.rsSummary(ancestor.level, pairTokens);
2240
2180
  }
2241
2181
  }
2242
2182
  currentRun = null;
@@ -2310,6 +2250,7 @@ export class AutobiographicalStrategy {
2310
2250
  content,
2311
2251
  });
2312
2252
  totalTokens += tokens;
2253
+ this.rsRaw('middleRaw', tokens);
2313
2254
  i++;
2314
2255
  }
2315
2256
  else {
@@ -2327,6 +2268,7 @@ export class AutobiographicalStrategy {
2327
2268
  content,
2328
2269
  });
2329
2270
  totalTokens += tokens;
2271
+ this.rsRaw('middleRaw', tokens);
2330
2272
  i++;
2331
2273
  continue;
2332
2274
  }
@@ -2354,11 +2296,13 @@ export class AutobiographicalStrategy {
2354
2296
  entries.push(questionEntry);
2355
2297
  entries.push(answerEntry);
2356
2298
  totalTokens += pairTokens;
2299
+ this.rsSummary(ancestor.level, pairTokens);
2357
2300
  i++;
2358
2301
  }
2359
2302
  }
2360
2303
  // ----- 6. Emit tail entries newest-first eviction (matches existing behavior) -----
2361
- this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2304
+ const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2305
+ this.rsRaw('tail', tailStats.tokens, tailStats.messages);
2362
2306
  // ----- 7. Post-process: merge consecutive raw entries from the same bodyGroup -----
2363
2307
  // Both head and tail emission paths emit shards as separate ContextEntries.
2364
2308
  // The middle path already merges consecutive same-bodyGroup raw shards into
@@ -2366,9 +2310,62 @@ export class AutobiographicalStrategy {
2366
2310
  // a sharded message renders as ONE API message regardless of which region
2367
2311
  // it falls into (preserves KV cache through region transitions).
2368
2312
  const merged = this.mergeAdjacentBodyGroupRaw(entries, store);
2313
+ this.pruneToolEntries(merged);
2369
2314
  this.trimOrphanedToolUse(merged);
2315
+ // Strip stale images BEFORE placing markers and committing stats, so both
2316
+ // describe the post-strip context the agent actually receives.
2317
+ this.applyImageStripping(merged, store);
2318
+ // Place ≤4 cache breakpoints across the FINAL ordered entries so the
2319
+ // provider can reuse the stable folded prefix — not just the head. With a
2320
+ // single head marker the cache hit is ~2%; well-placed breakpoints take the
2321
+ // real strategy to ~50% (docs/kv-stable-context-control.md — marker
2322
+ // placement is the dominant KV lever).
2323
+ this.placeCacheMarkers(merged, headMessageIds, tailMessageIds);
2324
+ this.rsEnd();
2370
2325
  return merged;
2371
2326
  }
2327
+ /**
2328
+ * Place up to four `cache_control` breakpoints across the final ordered
2329
+ * entries: the head/system boundary, the end of the folded history (the
2330
+ * stable prefix that persists turn-to-turn — the most valuable), a mid-history
2331
+ * seam, and the very end (for pure-append reuse). Mirrors `placeMarkers` in
2332
+ * the adaptive layer but operates on emitted entries. Idempotent; clears any
2333
+ * pre-existing markers first.
2334
+ */
2335
+ placeCacheMarkers(entries, headMessageIds, tailMessageIds) {
2336
+ for (const e of entries)
2337
+ if (e.cacheMarker)
2338
+ e.cacheMarker = false;
2339
+ const n = entries.length;
2340
+ if (n === 0)
2341
+ return;
2342
+ let lastHead = -1;
2343
+ let firstTail = n;
2344
+ for (let i = 0; i < n; i++) {
2345
+ const sid = entries[i].sourceMessageId;
2346
+ if (sid && headMessageIds.has(sid))
2347
+ lastHead = i;
2348
+ }
2349
+ for (let i = 0; i < n; i++) {
2350
+ const sid = entries[i].sourceMessageId;
2351
+ if (sid && tailMessageIds.has(sid)) {
2352
+ firstTail = i;
2353
+ break;
2354
+ }
2355
+ }
2356
+ const historyEnd = firstTail - 1; // last middle (folded-history) entry
2357
+ const marks = new Set();
2358
+ if (lastHead >= 0)
2359
+ marks.add(lastHead); // system / head block
2360
+ if (historyEnd > lastHead)
2361
+ marks.add(historyEnd); // stable folded prefix (the big one)
2362
+ if (historyEnd - lastHead > 2)
2363
+ marks.add(lastHead + Math.floor((historyEnd - lastHead) / 2)); // mid-history
2364
+ marks.add(n - 1); // end → pure-append reuse
2365
+ for (const idx of marks)
2366
+ if (idx >= 0 && idx < n)
2367
+ entries[idx].cacheMarker = true;
2368
+ }
2372
2369
  /**
2373
2370
  * Walk an entries array; for every run of consecutive entries that
2374
2371
  * (a) have sourceRelation: 'copy' (raw, not a synthesized recall pair)
@@ -2457,6 +2454,20 @@ export class AutobiographicalStrategy {
2457
2454
  this._adaptivePicker = new Picker(strategy);
2458
2455
  return this._adaptivePicker;
2459
2456
  }
2457
+ /**
2458
+ * Build the picker for this compile. Instance folding strategies (kv-stable)
2459
+ * need the per-compile `PickerInputs` at construction, so they're built fresh
2460
+ * here; stateless ones (flat-profile / oldest-first) reuse the memoized picker.
2461
+ */
2462
+ buildPicker(inputs) {
2463
+ if (this.config.foldingStrategy === 'kv-stable') {
2464
+ return new Picker(new KvStableStrategy(inputs, {
2465
+ reachTokens: this.config.kvStableReachTokens,
2466
+ mergeThreshold: this.config.mergeThreshold,
2467
+ }));
2468
+ }
2469
+ return this.getAdaptivePicker();
2470
+ }
2460
2471
  /**
2461
2472
  * Walk the summary tree to find the L_k ancestor of a message.
2462
2473
  * Returns null if no ancestor exists at that level (e.g., L_k not yet produced).
@@ -2483,6 +2494,45 @@ export class AutobiographicalStrategy {
2483
2494
  return null;
2484
2495
  return current;
2485
2496
  }
2497
+ /**
2498
+ * Recursively expand a summary's `sourceIds` down to the leaf message IDs
2499
+ * it covers, adding each leaf into `out`.
2500
+ *
2501
+ * Required because `SummaryEntry.sourceIds` are level-relative: an L1's
2502
+ * sourceIds are raw message IDs (sourceLevel=0), but an L2's sourceIds
2503
+ * are L1 IDs (sourceLevel=1), and so on. Any dedup that walks `sourceIds`
2504
+ * directly only works for L1s — once L2+ summaries enter the picture
2505
+ * (which happens as soon as `mergeThreshold` L1s accumulate during
2506
+ * interleaved compression+merge ticks), the dedup silently fails and
2507
+ * already-summarized messages leak back into the request as raw text.
2508
+ * That's how Bug 10 produced 200k+ token merge prompts on a 4234-msg
2509
+ * import.
2510
+ *
2511
+ * Callers should also expand merged L1s (not just the unmerged frontier)
2512
+ * as defense in depth — a stale `mergedInto` pointer or a partially
2513
+ * applied merge shouldn't surface raw messages.
2514
+ *
2515
+ * `visited` guards against pathological cycles in the summary graph (a
2516
+ * corrupted store or a future merge regression that lets a summary
2517
+ * reference itself). The hierarchy is a DAG by construction, but a
2518
+ * stack overflow during compression — exactly when the safety net is
2519
+ * supposed to save the session — is too steep a price for trusting that.
2520
+ */
2521
+ expandSummaryToLeafMessageIds(summary, summariesById, out, visited = new Set()) {
2522
+ if (visited.has(summary.id))
2523
+ return;
2524
+ visited.add(summary.id);
2525
+ if (summary.sourceLevel === 0) {
2526
+ for (const id of summary.sourceIds)
2527
+ out.add(id);
2528
+ return;
2529
+ }
2530
+ for (const childId of summary.sourceIds) {
2531
+ const child = summariesById.get(childId);
2532
+ if (child)
2533
+ this.expandSummaryToLeafMessageIds(child, summariesById, out, visited);
2534
+ }
2535
+ }
2486
2536
  // ============================================================================
2487
2537
  // Hierarchical (threshold-driven) path
2488
2538
  // ============================================================================
@@ -2491,6 +2541,7 @@ export class AutobiographicalStrategy {
2491
2541
  * Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
2492
2542
  */
2493
2543
  selectHierarchical(store, budget) {
2544
+ this.rsBegin();
2494
2545
  const entries = [];
2495
2546
  const maxTokens = budget.maxTokens - budget.reserveForResponse;
2496
2547
  const messages = store.getAll();
@@ -2513,6 +2564,7 @@ export class AutobiographicalStrategy {
2513
2564
  content,
2514
2565
  });
2515
2566
  totalTokens += tokens;
2567
+ this.rsRaw('head', tokens);
2516
2568
  }
2517
2569
  // Mark the last head entry as a cache boundary (even if budget truncated the window)
2518
2570
  if (entries.length > 0) {
@@ -2662,6 +2714,7 @@ export class AutobiographicalStrategy {
2662
2714
  entries.push(questionEntry);
2663
2715
  entries.push(answerEntry);
2664
2716
  totalTokens += pairTokens;
2717
+ this.rsSummary(summary.level, pairTokens);
2665
2718
  }
2666
2719
  else {
2667
2720
  const msg = item.msg;
@@ -2679,6 +2732,7 @@ export class AutobiographicalStrategy {
2679
2732
  content,
2680
2733
  });
2681
2734
  totalTokens += tokens;
2735
+ this.rsRaw('middleRaw', tokens);
2682
2736
  }
2683
2737
  }
2684
2738
  }
@@ -2710,6 +2764,8 @@ export class AutobiographicalStrategy {
2710
2764
  entries.push(questionEntry);
2711
2765
  entries.push(answerEntry);
2712
2766
  totalTokens += pairTokens;
2767
+ for (const s of selectedSummaries)
2768
+ this.rsSummary(s.level, s.tokens);
2713
2769
  }
2714
2770
  // Sort by position so uncompressed-middle messages and pins both
2715
2771
  // appear in their chronological place after the combined recall pair.
@@ -2729,6 +2785,7 @@ export class AutobiographicalStrategy {
2729
2785
  content,
2730
2786
  });
2731
2787
  totalTokens += tokens;
2788
+ this.rsRaw('middleRaw', tokens);
2732
2789
  }
2733
2790
  }
2734
2791
  }
@@ -2737,9 +2794,14 @@ export class AutobiographicalStrategy {
2737
2794
  // budget, the latest messages (the ones the agent actually needs to act
2738
2795
  // on) are preserved and the oldest recent-window messages are dropped.
2739
2796
  const effectiveRecentStart = Math.max(recentStart, headEnd);
2740
- this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2797
+ const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2798
+ this.rsRaw('tail', tailStats.tokens, tailStats.messages);
2741
2799
  this.trimOrphanedToolUse(entries);
2742
2800
  this.pruneToolEntries(entries);
2801
+ // Strip stale images before committing stats so RenderStats.total reflects
2802
+ // the post-strip context (this path places no cache markers).
2803
+ this.applyImageStripping(entries, store);
2804
+ this.rsEnd();
2743
2805
  return entries;
2744
2806
  }
2745
2807
  // ============================================================================
@@ -2960,6 +3022,7 @@ export class AutobiographicalStrategy {
2960
3022
  },
2961
3023
  };
2962
3024
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
3025
+ // Text-only on purpose: summarizer scratch thinking is not agent history
2963
3026
  return response.content
2964
3027
  .filter((b) => b.type === 'text')
2965
3028
  .map(b => b.text)
@@ -3036,6 +3099,16 @@ export class AutobiographicalStrategy {
3036
3099
  currentTokens += msgTokens;
3037
3100
  const shouldClose = currentTokens >= this.config.targetChunkTokens &&
3038
3101
  currentChunk.length >= 4;
3102
+ // Don't close a chunk on a message containing a tool_use block —
3103
+ // the matching tool_result lives in the immediately-following user
3104
+ // message, and the Anthropic API rejects a request where a tool_use
3105
+ // isn't immediately followed by its tool_result. Defer the close
3106
+ // by one iteration so the result rides along in the same chunk.
3107
+ // The stripUnpairedToolBlocks runtime pass is a safety net for the
3108
+ // rare case where a tool_use is the very last message in the store.
3109
+ if (shouldClose && this.lastMessageContainsToolUse(currentChunk)) {
3110
+ continue;
3111
+ }
3039
3112
  if (shouldClose) {
3040
3113
  const chunk = this.createChunk(this.chunks.length, chunkFilteredStart, i + 1, currentChunk, currentTokens, existingCompressed);
3041
3114
  this.chunks.push(chunk);
@@ -3055,6 +3128,19 @@ export class AutobiographicalStrategy {
3055
3128
  }
3056
3129
  }
3057
3130
  }
3131
+ /**
3132
+ * Returns true if the last message in the chunk-in-progress contains a
3133
+ * `tool_use` block. Used by `rebuildChunks` to defer chunk closure until
3134
+ * the matching `tool_result` (in the immediately-following user message)
3135
+ * is pulled into the same chunk. See `stripUnpairedToolBlocks` for the
3136
+ * runtime safety net.
3137
+ */
3138
+ lastMessageContainsToolUse(chunk) {
3139
+ const last = chunk[chunk.length - 1];
3140
+ if (!last)
3141
+ return false;
3142
+ return last.content.some((b) => b.type === 'tool_use');
3143
+ }
3058
3144
  createChunk(index, startIndex, endIndex, messages, tokens, existingCompressed) {
3059
3145
  const chunk = {
3060
3146
  index,
@@ -3068,7 +3154,6 @@ export class AutobiographicalStrategy {
3068
3154
  const existing = existingCompressed.get(key);
3069
3155
  if (existing) {
3070
3156
  chunk.compressed = true;
3071
- chunk.diary = existing.diary;
3072
3157
  chunk.summaryId = existing.summaryId;
3073
3158
  }
3074
3159
  // In hierarchical mode, also check if a summary exists for this chunk
@@ -3084,6 +3169,97 @@ export class AutobiographicalStrategy {
3084
3169
  chunkKey(chunk) {
3085
3170
  return chunk.messages.map((m) => m.id).join(':');
3086
3171
  }
3172
+ /** True if any content block is a live image. */
3173
+ hasImageBlock(content) {
3174
+ return content.some((b) => b.type === 'image');
3175
+ }
3176
+ /** Message index marking the image-strip depth boundary: walks newest→oldest
3177
+ * summing the same per-message estimate as getRecentWindowStart, and returns
3178
+ * the index of the first message still within `depthTokens`. Messages before
3179
+ * this index have their images stripped to placeholders. */
3180
+ getImageStripStart(store, depthTokens) {
3181
+ const messages = store.getAll();
3182
+ let tokens = 0;
3183
+ for (let i = messages.length - 1; i >= 0; i--) {
3184
+ tokens += store.estimateTokens(messages[i]);
3185
+ if (tokens > depthTokens)
3186
+ return i + 1;
3187
+ }
3188
+ return 0;
3189
+ }
3190
+ /** Text substituted for an image block once it leaves the live-image window. */
3191
+ static IMAGE_PLACEHOLDER = '[image dropped from live context]';
3192
+ /** Post-pass over compiled entries: replace image blocks with a text
3193
+ * placeholder once they fall outside the live-image window — either deeper
3194
+ * than `imageStripDepthTokens` from the newest message, or beyond the
3195
+ * `maxLiveImages` most-recent images (counted newest-first). Summaries are
3196
+ * already text, so they're naturally unaffected. The adjacent
3197
+ * "[image attachment: <name>]" text added at ingest preserves the filename,
3198
+ * so the placeholder itself stays terse. Reduces tokens, so it never pushes
3199
+ * a compiled context back over budget.
3200
+ *
3201
+ * Runs INSIDE each select path, *before* `rsEnd()` and `placeCacheMarkers`,
3202
+ * so the committed render stats (and the cache breakpoints) describe the
3203
+ * post-strip context. As it strips, it decrements the matching raw bucket of
3204
+ * the in-progress render stats by the reclaimed tokens, keeping
3205
+ * `RenderStats.total` equal to the real rendered size. */
3206
+ applyImageStripping(entries, store) {
3207
+ const maxLive = this.config.maxLiveImages ?? 0; // 0 = unlimited count
3208
+ const depthTokens = this.config.imageStripDepthTokens ?? 0; // 0 = no depth strip
3209
+ if (maxLive === 0 && depthTokens === 0)
3210
+ return; // policy disabled
3211
+ const messages = store.getAll();
3212
+ const posById = new Map();
3213
+ for (let i = 0; i < messages.length; i++)
3214
+ posById.set(messages[i].id, i);
3215
+ const stripStart = depthTokens > 0 ? this.getImageStripStart(store, depthTokens) : 0;
3216
+ // Same region windows select() bucketed by, so a stripped image's reclaimed
3217
+ // tokens come back out of the bucket it was originally tallied into.
3218
+ const headStart = this.getHeadWindowStartIndex(store);
3219
+ const headEnd = this.getHeadWindowEnd(store);
3220
+ const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
3221
+ const bucketAt = (pos) => {
3222
+ if (pos < 0)
3223
+ return 'middleRaw'; // no resolvable region — keep total == Σbuckets
3224
+ if (pos >= headStart && pos < headEnd)
3225
+ return 'head';
3226
+ if (pos >= recentStart)
3227
+ return 'tail';
3228
+ return 'middleRaw';
3229
+ };
3230
+ const placeholderTokens = Math.ceil(AutobiographicalStrategy.IMAGE_PLACEHOLDER.length / 4);
3231
+ // Image-bearing entries, newest-first by source position. Entries with no
3232
+ // resolvable source position sort last (pos -1) and never count as "live".
3233
+ const ordered = entries
3234
+ .map((entry, idx) => ({
3235
+ idx,
3236
+ pos: entry.sourceMessageId !== undefined ? posById.get(entry.sourceMessageId) ?? -1 : -1,
3237
+ }))
3238
+ .filter(({ idx }) => this.hasImageBlock(entries[idx].content))
3239
+ .sort((a, b) => b.pos - a.pos);
3240
+ let keptImages = 0;
3241
+ for (const { idx, pos } of ordered) {
3242
+ const entry = entries[idx];
3243
+ const tooDeep = depthTokens > 0 && (pos < 0 || pos < stripStart);
3244
+ const bucket = bucketAt(pos);
3245
+ entry.content = entry.content.map((block) => {
3246
+ if (block.type !== 'image')
3247
+ return block;
3248
+ const overCount = maxLive > 0 && keptImages >= maxLive;
3249
+ if (tooDeep || overCount) {
3250
+ // The renderer estimates an image at `tokenEstimate ?? 1600` (see
3251
+ // message-store/context-log). Hand that back to the bucket, less the
3252
+ // placeholder text that replaces it, so the stats match the output.
3253
+ const reclaimed = (block.tokenEstimate ?? 1600) - placeholderTokens;
3254
+ if (this._rs && reclaimed > 0)
3255
+ this._rs[bucket].tokens -= reclaimed;
3256
+ return { type: 'text', text: AutobiographicalStrategy.IMAGE_PLACEHOLDER };
3257
+ }
3258
+ keptImages++;
3259
+ return block;
3260
+ });
3261
+ }
3262
+ }
3087
3263
  getRecentWindowStart(store) {
3088
3264
  const messages = store.getAll();
3089
3265
  let tokens = 0;