@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
@@ -17,14 +17,16 @@ import type {
17
17
  ProtectedRange,
18
18
  SearchQuery,
19
19
  SearchResult,
20
+ RenderStats,
20
21
  } from '../types/index.js';
21
22
  import { DEFAULT_AUTOBIOGRAPHICAL_CONFIG } from '../types/index.js';
22
23
  import { getSummaryParentId } from '../types/strategy.js';
23
- import { splitMixedToolMessages } from '../normalize-tool-messages.js';
24
+ import { splitMixedToolMessages, stripUnpairedToolBlocks } from '../normalize-tool-messages.js';
24
25
  import { appendFileSync, mkdirSync } from 'node:fs';
25
26
  import { dirname } from 'node:path';
26
- import { Picker, OverBudgetError, type PickerChunk } from '../adaptive/picker.js';
27
+ import { Picker, OverBudgetError, type PickerChunk, type PickerInputs } from '../adaptive/picker.js';
27
28
  import { FlatProfileStrategy } from '../adaptive/strategies/flat-profile.js';
29
+ import { KvStableStrategy } from '../adaptive/strategies/kv-stable.js';
28
30
  import { OldestFirstStrategy } from '../adaptive/strategies/oldest-first.js';
29
31
  import type {
30
32
  FoldingStrategy,
@@ -223,8 +225,6 @@ export interface Chunk {
223
225
  tokens: number;
224
226
  /** Whether this chunk has been compressed */
225
227
  compressed: boolean;
226
- /** The diary entry if compressed (legacy mode) */
227
- diary?: string;
228
228
  /** ID of the L1 SummaryEntry (hierarchical mode) */
229
229
  summaryId?: string;
230
230
  /** Phase type tag (set by KnowledgeStrategy for semantic chunking) */
@@ -1064,9 +1064,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1064
1064
 
1065
1065
  if (!chunk || chunk.compressed) return;
1066
1066
 
1067
- this.pendingCompression = this.config.hierarchical
1068
- ? this.compressChunkHierarchical(chunk, ctx)
1069
- : this.compressChunkLegacy(chunk, ctx);
1067
+ this.pendingCompression = this.compressChunkHierarchical(chunk, ctx);
1070
1068
 
1071
1069
  try {
1072
1070
  await this.pendingCompression;
@@ -1136,12 +1134,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1136
1134
  ): ContextEntry[] {
1137
1135
  this.rebuildChunks(store);
1138
1136
 
1137
+ // Image stripping runs inside each select path (before stats commit / cache
1138
+ // markers), so the returned entries are already bounded — see
1139
+ // applyImageStripping.
1139
1140
  if (this.config.adaptiveResolution) {
1140
1141
  return this.selectAdaptive(store, budget);
1141
1142
  }
1142
- return this.config.hierarchical
1143
- ? this.selectHierarchical(store, budget)
1144
- : this.selectLegacy(store, log, budget);
1143
+ return this.selectHierarchical(store, budget);
1145
1144
  }
1146
1145
 
1147
1146
  /**
@@ -1171,16 +1170,89 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1171
1170
  * Useful for TUI / dashboards. The token sums use the strategy's own
1172
1171
  * token estimates (which match what `select()` uses for budget math).
1173
1172
  */
1174
- getRenderStats(store: MessageStoreView): {
1175
- head: { messages: number; tokens: number };
1176
- tail: { messages: number; tokens: number };
1177
- summaries: {
1178
- l1: { count: number; tokens: number };
1179
- l2: { count: number; tokens: number };
1180
- l3: { count: number; tokens: number };
1173
+ // ===========================================================================
1174
+ // Render-stats instrumentation inspect, don't reconstruct.
1175
+ //
1176
+ // selectAdaptive/selectHierarchical tally each entry into `_rs` AS THEY EMIT
1177
+ // it (raw head/tail, raw middle the picker kept verbatim, and recall pairs
1178
+ // bucketed by the ancestor summary's level), using the same token numbers the
1179
+ // renderer uses for budget math. `getRenderStats()` returns that committed
1180
+ // snapshot, so it reflects what the last compile actually rendered rather than
1181
+ // re-deriving the full pyramid (which, under adaptive resolution, bears little
1182
+ // resemblance to the folded output).
1183
+ // ===========================================================================
1184
+ private _rs: RenderStats | null = null;
1185
+ private _lastRenderStats: RenderStats | null = null;
1186
+
1187
+ /** Begin a render-stats accumulation for one select() pass. */
1188
+ protected rsBegin(): void {
1189
+ this._rs = {
1190
+ head: { messages: 0, tokens: 0 },
1191
+ tail: { messages: 0, tokens: 0 },
1192
+ middleRaw: { messages: 0, tokens: 0 },
1193
+ summaries: {
1194
+ l1: { count: 0, tokens: 0 },
1195
+ l2: { count: 0, tokens: 0 },
1196
+ l3: { count: 0, tokens: 0 },
1197
+ },
1198
+ pending: { chunks: 0, merges: 0 },
1199
+ total: { messages: 0, tokens: 0 },
1181
1200
  };
1182
- pending: { chunks: number; merges: number };
1183
- } {
1201
+ }
1202
+
1203
+ /** Tally one (or `count`) raw message(s) into a raw bucket. */
1204
+ protected rsRaw(bucket: 'head' | 'tail' | 'middleRaw', tokens: number, count = 1): void {
1205
+ const r = this._rs;
1206
+ if (!r) return;
1207
+ r[bucket].messages += count;
1208
+ r[bucket].tokens += tokens;
1209
+ }
1210
+
1211
+ /** Tally one emitted recall pair under its ancestor's level (>=3 folds into l3). */
1212
+ protected rsSummary(level: number, tokens: number): void {
1213
+ const r = this._rs;
1214
+ if (!r) return;
1215
+ const k: 'l1' | 'l2' | 'l3' = level <= 1 ? 'l1' : level === 2 ? 'l2' : 'l3';
1216
+ r.summaries[k].count += 1;
1217
+ r.summaries[k].tokens += tokens;
1218
+ }
1219
+
1220
+ /** Commit the accumulated stats as the last-render snapshot. */
1221
+ protected rsEnd(): void {
1222
+ const r = this._rs;
1223
+ if (!r) return;
1224
+ r.pending = {
1225
+ chunks: this.chunks.filter(c => !c.compressed).length,
1226
+ merges: this.mergeQueue.length,
1227
+ };
1228
+ const s = r.summaries;
1229
+ const summaryMsgs = (s.l1.count + s.l2.count + s.l3.count) * 2; // Q/A pair each
1230
+ r.total = {
1231
+ messages: r.head.messages + r.tail.messages + r.middleRaw.messages + summaryMsgs,
1232
+ tokens:
1233
+ r.head.tokens + r.tail.tokens + r.middleRaw.tokens +
1234
+ s.l1.tokens + s.l2.tokens + s.l3.tokens,
1235
+ };
1236
+ this._lastRenderStats = r;
1237
+ this._rs = null;
1238
+ }
1239
+
1240
+ /**
1241
+ * Stats describing the LAST rendered context. Returns the inspected snapshot
1242
+ * captured during the most recent `select()`. Before any compile has run (no
1243
+ * snapshot yet), falls back to a reconstructed pyramid view so callers still
1244
+ * get a non-null shape.
1245
+ */
1246
+ getRenderStats(store: MessageStoreView): RenderStats {
1247
+ return this._lastRenderStats ?? this.reconstructRenderStats(store);
1248
+ }
1249
+
1250
+ /**
1251
+ * Pre-render fallback: re-derive head/tail windows + the full live pyramid.
1252
+ * NOTE: this is the old "reconstruct" behavior and does NOT reflect adaptive
1253
+ * folding — used only until the first compile populates the inspected stats.
1254
+ */
1255
+ protected reconstructRenderStats(store: MessageStoreView): RenderStats {
1184
1256
  const messages = store.getAll();
1185
1257
  const headStart = this.getHeadWindowStartIndex(store);
1186
1258
  const headEnd = this.getHeadWindowEnd(store);
@@ -1197,145 +1269,31 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1197
1269
  const sumLevelTokens = (level: SummaryLevel): number =>
1198
1270
  live(level).reduce((acc, s) => acc + s.tokens, 0);
1199
1271
 
1272
+ const head = { messages: headMsgs.length, tokens: sumTokens(headMsgs) };
1273
+ const tail = { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) };
1274
+ const summaries = {
1275
+ l1: { count: live(1).length, tokens: sumLevelTokens(1) },
1276
+ l2: { count: live(2).length, tokens: sumLevelTokens(2) },
1277
+ l3: { count: live(3).length, tokens: sumLevelTokens(3) },
1278
+ };
1200
1279
  return {
1201
- head: { messages: headMsgs.length, tokens: sumTokens(headMsgs) },
1202
- tail: { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) },
1203
- summaries: {
1204
- l1: { count: live(1).length, tokens: sumLevelTokens(1) },
1205
- l2: { count: live(2).length, tokens: sumLevelTokens(2) },
1206
- l3: { count: live(3).length, tokens: sumLevelTokens(3) },
1207
- },
1280
+ head,
1281
+ tail,
1282
+ middleRaw: { messages: 0, tokens: 0 },
1283
+ summaries,
1208
1284
  pending: {
1209
1285
  chunks: this.chunks.filter(c => !c.compressed).length,
1210
1286
  merges: this.mergeQueue.length,
1211
1287
  },
1288
+ total: {
1289
+ messages: head.messages + tail.messages
1290
+ + (summaries.l1.count + summaries.l2.count + summaries.l3.count) * 2,
1291
+ tokens: head.tokens + tail.tokens
1292
+ + summaries.l1.tokens + summaries.l2.tokens + summaries.l3.tokens,
1293
+ },
1212
1294
  };
1213
1295
  }
1214
1296
 
1215
- // ============================================================================
1216
- // Legacy (single-level) path
1217
- // ============================================================================
1218
-
1219
- protected selectLegacy(
1220
- store: MessageStoreView,
1221
- _log: ContextLogView,
1222
- budget: TokenBudget
1223
- ): ContextEntry[] {
1224
- const entries: ContextEntry[] = [];
1225
- const maxTokens = budget.maxTokens - budget.reserveForResponse;
1226
- let totalTokens = 0;
1227
- const messages = store.getAll();
1228
- const msgCap = this.config.maxMessageTokens;
1229
-
1230
- // 1. Head window: preserved verbatim as raw copies
1231
- const headStart = this.getHeadWindowStartIndex(store);
1232
- const headEnd = this.getHeadWindowEnd(store);
1233
- for (let i = headStart; i < headEnd && i < messages.length; i++) {
1234
- const msg = messages[i];
1235
- const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1236
- const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
1237
- if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
1238
-
1239
- entries.push({
1240
- index: entries.length,
1241
- sourceMessageId: msg.id,
1242
- sourceRelation: 'copy',
1243
- participant: msg.participant,
1244
- content,
1245
- });
1246
- totalTokens += tokens;
1247
- }
1248
- // Mark the last head entry as a cache boundary (even if budget truncated the window)
1249
- if (entries.length > 0) {
1250
- entries[entries.length - 1].cacheMarker = true;
1251
- }
1252
-
1253
- // 2. Middle zone: compressed chunks as diary pairs, uncompressed as raw messages.
1254
- const rawRecentStart = this.getRecentWindowStart(store);
1255
- // Track which message IDs are covered by chunks
1256
- const coveredByChunks = new Set<string>();
1257
-
1258
- for (const chunk of this.chunks) {
1259
- for (const m of chunk.messages) coveredByChunks.add(m.id);
1260
-
1261
- if (chunk.compressed && chunk.diary) {
1262
- const contextLabel = this.config.summaryContextLabel ?? 'Here is a summary of earlier conversation context:';
1263
- const summaryParticipant = this.config.summaryParticipant ?? 'Summary';
1264
-
1265
- const questionEntry: ContextEntry = {
1266
- index: entries.length,
1267
- participant: 'Context Manager',
1268
- content: [{ type: 'text', text: contextLabel }],
1269
- sourceRelation: 'derived',
1270
- };
1271
-
1272
- // Synthesised summary turns must respect maxMessageTokens just like raw
1273
- // copies do — otherwise a runaway diary can starve recent messages.
1274
- const answerContent: ContentBlock[] = [{ type: 'text', text: chunk.diary }];
1275
- const answerEntry: ContextEntry = {
1276
- index: entries.length + 1,
1277
- participant: summaryParticipant,
1278
- content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
1279
- sourceRelation: 'derived',
1280
- };
1281
-
1282
- const pairTokens = this.estimateTokens(questionEntry.content) +
1283
- this.estimateTokens(answerEntry.content);
1284
-
1285
- if (this.isOverBudget(totalTokens + pairTokens, maxTokens)) break;
1286
-
1287
- entries.push(questionEntry);
1288
- entries.push(answerEntry);
1289
- totalTokens += pairTokens;
1290
- } else {
1291
- // Uncompressed: emit raw messages so they aren't lost
1292
- for (const msg of chunk.messages) {
1293
- const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1294
- const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
1295
- if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
1296
-
1297
- entries.push({
1298
- index: entries.length,
1299
- sourceMessageId: msg.id,
1300
- sourceRelation: 'copy',
1301
- participant: msg.participant,
1302
- content,
1303
- });
1304
- totalTokens += tokens;
1305
- }
1306
- }
1307
- }
1308
-
1309
- // Emit gap messages in the compressible zone not covered by any chunk.
1310
- // Compressible zone: [0, headStart) ∪ [headEnd, rawRecentStart)
1311
- for (let i = 0; i < rawRecentStart && i < messages.length; i++) {
1312
- // Skip head window messages (already emitted verbatim above)
1313
- if (i >= headStart && i < headEnd) continue;
1314
- if (coveredByChunks.has(messages[i].id)) continue;
1315
- const msg = messages[i];
1316
- const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1317
- const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
1318
- if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
1319
-
1320
- entries.push({
1321
- index: entries.length,
1322
- sourceMessageId: msg.id,
1323
- sourceRelation: 'copy',
1324
- participant: msg.participant,
1325
- content,
1326
- });
1327
- totalTokens += tokens;
1328
- }
1329
-
1330
- // 3. Recent uncompressed messages (skip those already in head window)
1331
- const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
1332
- this.emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokens);
1333
-
1334
- this.trimOrphanedToolUse(entries);
1335
- this.pruneToolEntries(entries);
1336
- return entries;
1337
- }
1338
-
1339
1297
  /**
1340
1298
  * Emit recent-window messages, evicting OLDEST-first when the budget is tight.
1341
1299
  *
@@ -1355,8 +1313,8 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1355
1313
  msgCap: number,
1356
1314
  maxTokens: number,
1357
1315
  totalTokensBefore: number,
1358
- ): void {
1359
- if (recentStart >= messages.length) return;
1316
+ ): { messages: number; tokens: number } {
1317
+ if (recentStart >= messages.length) return { messages: 0, tokens: 0 };
1360
1318
 
1361
1319
  const accepted: number[] = [];
1362
1320
  let acceptedTokens = 0;
@@ -1380,9 +1338,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1380
1338
  accepted.shift();
1381
1339
  }
1382
1340
 
1341
+ let emittedTokens = 0;
1383
1342
  for (const i of accepted) {
1384
1343
  const msg = messages[i];
1385
1344
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1345
+ const tokens = msgCap > 0
1346
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
1347
+ : store.estimateTokens(msg);
1386
1348
  entries.push({
1387
1349
  index: entries.length,
1388
1350
  sourceMessageId: msg.id,
@@ -1390,100 +1352,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1390
1352
  participant: msg.participant,
1391
1353
  content,
1392
1354
  });
1355
+ emittedTokens += tokens;
1393
1356
  }
1394
- }
1395
-
1396
- protected async compressChunkLegacy(chunk: Chunk, ctx: StrategyContext): Promise<void> {
1397
- if (!ctx.membrane) {
1398
- throw new Error('No membrane instance for compression');
1399
- }
1400
-
1401
- const priorContext = this.buildPriorContextLegacy(chunk, ctx);
1402
- const chunkContent = this.formatChunkForCompression(chunk);
1403
-
1404
- const prompt = this.config.diaryUserPrompt ?? this.config.summaryUserPrompt!;
1405
- const systemPrompt = this.config.diarySystemPrompt ?? this.config.summarySystemPrompt!;
1406
-
1407
- const messages = [
1408
- ...priorContext,
1409
- {
1410
- participant: 'Context Manager',
1411
- content: [{ type: 'text' as const, text: prompt.replace('{content}', chunkContent) }],
1412
- },
1413
- ];
1414
-
1415
- const request: NormalizedRequest = {
1416
- messages: messages.map((m) => ({
1417
- participant: m.participant,
1418
- content: m.content,
1419
- })),
1420
- system: systemPrompt,
1421
- config: {
1422
- model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
1423
- maxTokens: 2000,
1424
- },
1425
- };
1426
-
1427
- try {
1428
- const response = await ctx.membrane.complete(request);
1429
- const diaryText = response.content
1430
- .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
1431
- .map((b) => b.text)
1432
- .join('\n');
1433
-
1434
- chunk.compressed = true;
1435
- chunk.diary = diaryText;
1436
- this._compressionCount++;
1437
- } catch (error) {
1438
- console.error('Failed to compress chunk:', error);
1439
- throw error;
1440
- }
1441
- }
1442
-
1443
- protected buildPriorContextLegacy(chunk: Chunk, ctx: StrategyContext): Array<{
1444
- participant: string;
1445
- content: ContentBlock[];
1446
- }> {
1447
- const context: Array<{ participant: string; content: ContentBlock[] }> = [];
1448
-
1449
- for (const prevChunk of this.chunks) {
1450
- if (prevChunk.index >= chunk.index) break;
1451
- if (!prevChunk.compressed || !prevChunk.diary) continue;
1452
-
1453
- context.push({
1454
- participant: 'Context Manager',
1455
- content: [{ type: 'text', text: this.config.summaryContextLabel ?? 'Summary of earlier context:' }],
1456
- });
1457
- context.push({
1458
- participant: this.config.summaryParticipant ?? 'Summary',
1459
- content: [{ type: 'text', text: prevChunk.diary }],
1460
- });
1461
- }
1462
-
1463
- // Find the actual position of this chunk's first message in the full array
1464
- const messages = ctx.messageStore.getAll();
1465
- const firstMsgId = chunk.messages[0]?.id;
1466
- const chunkAbsStart = firstMsgId
1467
- ? messages.findIndex(m => m.id === firstMsgId)
1468
- : -1;
1469
-
1470
- if (chunkAbsStart > 0) {
1471
- const precedingStart = Math.max(0, chunkAbsStart - 50);
1472
- let tokens = 0;
1473
-
1474
- for (let i = chunkAbsStart - 1; i >= precedingStart && tokens < 15000; i--) {
1475
- const msg = messages[i];
1476
- if (!msg) break;
1477
-
1478
- tokens += ctx.messageStore.estimateTokens(msg);
1479
- context.unshift({
1480
- participant: msg.participant,
1481
- content: msg.content,
1482
- });
1483
- }
1484
- }
1485
-
1486
- return context;
1357
+ return { messages: accepted.length, tokens: emittedTokens };
1487
1358
  }
1488
1359
 
1489
1360
  // ============================================================================
@@ -1642,9 +1513,18 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1642
1513
  // raw messages reappear.
1643
1514
  const chunkFirstId = chunk.messages[0]?.id;
1644
1515
  if (chunkFirstId) {
1645
- const priorSummaryMessageIds = new Set<string>();
1516
+ // Expand summary sourceIds down to leaf message IDs. An L2 in
1517
+ // `priorSummaries` has L1 IDs in its sourceIds, not message IDs;
1518
+ // a flat walk would miss every message it transitively covers.
1519
+ // (Bug 10 — same shape as executeMerge.)
1520
+ const summariesById = new Map<string, SummaryEntry>();
1521
+ for (const s of this.summaries) summariesById.set(s.id, s);
1522
+ const priorSummaryMessageIds = new Set<MessageId>();
1523
+ for (const s of this.summaries) {
1524
+ if (s.level === 1) this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
1525
+ }
1646
1526
  for (const s of priorSummaries) {
1647
- for (const id of s.sourceIds) priorSummaryMessageIds.add(id);
1527
+ this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
1648
1528
  }
1649
1529
  const chunkStartIdx = allMessages.findIndex((m) => m.id === chunkFirstId);
1650
1530
  for (let i = headEndIdx; i < chunkStartIdx && i < allMessages.length; i++) {
@@ -1693,6 +1573,12 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1693
1573
  // Collapse consecutive same-participant messages for API compliance
1694
1574
  const collapsed = this.collapseConsecutiveMessages(split);
1695
1575
 
1576
+ // Defense in depth against chunk boundaries that cut a tool cycle
1577
+ // (rebuildChunks tries to avoid this, but covers only the most common
1578
+ // case). The API rejects any tool_use that isn't immediately followed
1579
+ // by its tool_result, and any tool_result that doesn't follow a use.
1580
+ const cleaned = stripUnpairedToolBlocks(collapsed);
1581
+
1696
1582
  // NO system prompt. The agent's identity is established by the head
1697
1583
  // (the actual conversation opening — user message + agent reply that
1698
1584
  // grounded the original instance). A system prompt would (a) add a
@@ -1702,7 +1588,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1702
1588
  // structural one carried by the conversation itself. Anchoring
1703
1589
  // identity by the chronicle's actual head is more honest.
1704
1590
  const request: NormalizedRequest = {
1705
- messages: collapsed.map(m => ({ participant: m.participant, content: m.content })),
1591
+ messages: cleaned.map(m => ({ participant: m.participant, content: m.content })),
1706
1592
  config: {
1707
1593
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
1708
1594
  maxTokens: Math.round(targetTokens * 1.5),
@@ -1716,6 +1602,10 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1716
1602
 
1717
1603
  try {
1718
1604
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
1605
+ // Text-only on purpose: this is the SUMMARIZER's one-shot response —
1606
+ // its thinking/redacted_thinking blocks are scratch work, not part of
1607
+ // the agent's history, and signed thinking is never valid inside a
1608
+ // rewritten summary anyway.
1719
1609
  const summaryText = response.content
1720
1610
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
1721
1611
  .map(b => b.text)
@@ -1753,7 +1643,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1753
1643
  logCompressionCall({
1754
1644
  operation: 'compress_l1',
1755
1645
  system: null,
1756
- messages: collapsed.map((m) => ({
1646
+ messages: cleaned.map((m) => ({
1757
1647
  participant: m.participant,
1758
1648
  // Flatten content for logging — store text only; binary content
1759
1649
  // would bloat the log and isn't typical in compression input.
@@ -2032,9 +1922,19 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2032
1922
  // The full unmerged-frontier set covers what's "compressed somewhere"
2033
1923
  // for the raw-middle dedup below — a budget-dropped recall pair
2034
1924
  // doesn't make its underlying raw messages reappear.
1925
+ //
1926
+ // Critical: `sourceIds` on an L2+ summary points at L1 IDs, not raw
1927
+ // message IDs. The dedup happens against raw message IDs, so we must
1928
+ // recursively expand each summary down to its leaf message IDs.
1929
+ // Without this, every message under any L2 leaks back in as raw text
1930
+ // (Bug 10: 525-message merge requests on a 4234-msg conversation).
1931
+ // Also expand merged L1s as defense in depth.
2035
1932
  const priorSummaryMessageIds = new Set<MessageId>();
1933
+ for (const s of this.summaries) {
1934
+ if (s.level === 1) this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
1935
+ }
2036
1936
  for (const s of priorSummariesAll) {
2037
- for (const id of s.sourceIds) priorSummaryMessageIds.add(id);
1937
+ this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
2038
1938
  }
2039
1939
 
2040
1940
  for (const s of keptPriorSummaries) {
@@ -2143,12 +2043,13 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2143
2043
  // Same bundled-tool-cycle defense as compressChunkHierarchical.
2144
2044
  const split = splitMixedToolMessages(llmMessages);
2145
2045
  const collapsed = this.collapseConsecutiveMessages(split);
2046
+ const cleaned = stripUnpairedToolBlocks(collapsed);
2146
2047
 
2147
2048
  // NO system prompt — identity is established by the head window
2148
2049
  // (present at the start of llmMessages above) and by the prior
2149
2050
  // recall pairs. Same rationale as compressChunkHierarchical.
2150
2051
  const request: NormalizedRequest = {
2151
- messages: collapsed.map(m => ({ participant: m.participant, content: m.content })),
2052
+ messages: cleaned.map(m => ({ participant: m.participant, content: m.content })),
2152
2053
  config: {
2153
2054
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
2154
2055
  maxTokens: Math.round(targetTokens * 1.5),
@@ -2162,6 +2063,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2162
2063
 
2163
2064
  try {
2164
2065
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
2066
+ // Text-only on purpose: summarizer scratch thinking is not agent history
2165
2067
  const mergedText = response.content
2166
2068
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
2167
2069
  .map(b => b.text)
@@ -2208,7 +2110,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2208
2110
  logCompressionCall({
2209
2111
  operation: `merge_l${targetLevel}`,
2210
2112
  system: null,
2211
- messages: collapsed.map((m) => ({
2113
+ messages: cleaned.map((m) => ({
2212
2114
  participant: m.participant,
2213
2115
  text: m.content
2214
2116
  .filter((b: ContentBlock) => b.type === 'text')
@@ -2246,6 +2148,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2246
2148
  * See `docs/adaptive-resolution-design.md` §3, §5.
2247
2149
  */
2248
2150
  protected selectAdaptive(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
2151
+ this.rsBegin();
2249
2152
  const entries: ContextEntry[] = [];
2250
2153
  const maxTokens = budget.maxTokens - budget.reserveForResponse;
2251
2154
  const messages = store.getAll();
@@ -2279,10 +2182,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2279
2182
  totalTokens += tokens;
2280
2183
  headMessageIds.add(msg.id);
2281
2184
  headTokens += tokens;
2185
+ this.rsRaw('head', tokens);
2282
2186
  }
2283
- if (entries.length > 0) {
2284
- entries[entries.length - 1].cacheMarker = true;
2285
- }
2187
+ // (Cache breakpoints are placed in one pass over the FINAL ordered entries
2188
+ // below see placeCacheMarkers — capturing the stable folded prefix, not
2189
+ // just the head boundary.)
2286
2190
 
2287
2191
  // Compute tail message IDs (will be emitted at end)
2288
2192
  const effectiveRecentStart = Math.max(recentStart, headEnd);
@@ -2386,19 +2290,17 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2386
2290
  const headSetForPicker = new Set<ChunkId>(headMessageIds);
2387
2291
  const tailSetForPicker = new Set<ChunkId>(tailMessageIds);
2388
2292
 
2389
- const picker = this.getAdaptivePicker();
2390
- const result = picker.run(
2391
- {
2392
- chunks: pickerChunks,
2393
- summaries: summariesMap,
2394
- recallPairTokens,
2395
- headChunkIds: headSetForPicker,
2396
- tailChunkIds: tailSetForPicker,
2397
- headTokens,
2398
- tailTokens,
2399
- },
2400
- foldingBudget
2401
- );
2293
+ const pickerInputs: PickerInputs = {
2294
+ chunks: pickerChunks,
2295
+ summaries: summariesMap,
2296
+ recallPairTokens,
2297
+ headChunkIds: headSetForPicker,
2298
+ tailChunkIds: tailSetForPicker,
2299
+ headTokens,
2300
+ tailTokens,
2301
+ };
2302
+ const picker = this.buildPicker(pickerInputs);
2303
+ const result = picker.run(pickerInputs, foldingBudget);
2402
2304
 
2403
2305
  // Commit the new resolutions back to strategy state for next compile.
2404
2306
  // Persist to chronicle only if anything actually changed — avoids
@@ -2526,6 +2428,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2526
2428
  content,
2527
2429
  });
2528
2430
  totalTokens += tokens;
2431
+ this.rsRaw('middleRaw', tokens);
2529
2432
  } else {
2530
2433
  // summary run — emit Q+A pair, dedup at the strategy level
2531
2434
  const ancestor = currentRun.ancestor;
@@ -2552,6 +2455,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2552
2455
  entries.push(questionEntry);
2553
2456
  entries.push(answerEntry);
2554
2457
  totalTokens += pairTokens;
2458
+ this.rsSummary(ancestor.level, pairTokens);
2555
2459
  }
2556
2460
  }
2557
2461
  currentRun = null;
@@ -2612,6 +2516,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2612
2516
  content,
2613
2517
  });
2614
2518
  totalTokens += tokens;
2519
+ this.rsRaw('middleRaw', tokens);
2615
2520
  i++;
2616
2521
  } else {
2617
2522
  const ancestor = this.findAncestorAt(msg.id, resolution, chunksByMessageId, summariesById);
@@ -2627,6 +2532,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2627
2532
  content,
2628
2533
  });
2629
2534
  totalTokens += tokens;
2535
+ this.rsRaw('middleRaw', tokens);
2630
2536
  i++;
2631
2537
  continue;
2632
2538
  }
@@ -2653,12 +2559,14 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2653
2559
  entries.push(questionEntry);
2654
2560
  entries.push(answerEntry);
2655
2561
  totalTokens += pairTokens;
2562
+ this.rsSummary(ancestor.level, pairTokens);
2656
2563
  i++;
2657
2564
  }
2658
2565
  }
2659
2566
 
2660
2567
  // ----- 6. Emit tail entries newest-first eviction (matches existing behavior) -----
2661
- this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2568
+ const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2569
+ this.rsRaw('tail', tailStats.tokens, tailStats.messages);
2662
2570
 
2663
2571
  // ----- 7. Post-process: merge consecutive raw entries from the same bodyGroup -----
2664
2572
  // Both head and tail emission paths emit shards as separate ContextEntries.
@@ -2668,10 +2576,59 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2668
2576
  // it falls into (preserves KV cache through region transitions).
2669
2577
  const merged = this.mergeAdjacentBodyGroupRaw(entries, store);
2670
2578
 
2579
+ this.pruneToolEntries(merged);
2671
2580
  this.trimOrphanedToolUse(merged);
2581
+ // Strip stale images BEFORE placing markers and committing stats, so both
2582
+ // describe the post-strip context the agent actually receives.
2583
+ this.applyImageStripping(merged, store);
2584
+ // Place ≤4 cache breakpoints across the FINAL ordered entries so the
2585
+ // provider can reuse the stable folded prefix — not just the head. With a
2586
+ // single head marker the cache hit is ~2%; well-placed breakpoints take the
2587
+ // real strategy to ~50% (docs/kv-stable-context-control.md — marker
2588
+ // placement is the dominant KV lever).
2589
+ this.placeCacheMarkers(merged, headMessageIds, tailMessageIds);
2590
+ this.rsEnd();
2672
2591
  return merged;
2673
2592
  }
2674
2593
 
2594
+ /**
2595
+ * Place up to four `cache_control` breakpoints across the final ordered
2596
+ * entries: the head/system boundary, the end of the folded history (the
2597
+ * stable prefix that persists turn-to-turn — the most valuable), a mid-history
2598
+ * seam, and the very end (for pure-append reuse). Mirrors `placeMarkers` in
2599
+ * the adaptive layer but operates on emitted entries. Idempotent; clears any
2600
+ * pre-existing markers first.
2601
+ */
2602
+ protected placeCacheMarkers(
2603
+ entries: ContextEntry[],
2604
+ headMessageIds: ReadonlySet<MessageId>,
2605
+ tailMessageIds: ReadonlySet<MessageId>,
2606
+ ): void {
2607
+ for (const e of entries) if (e.cacheMarker) e.cacheMarker = false;
2608
+ const n = entries.length;
2609
+ if (n === 0) return;
2610
+
2611
+ let lastHead = -1;
2612
+ let firstTail = n;
2613
+ for (let i = 0; i < n; i++) {
2614
+ const sid = entries[i].sourceMessageId;
2615
+ if (sid && headMessageIds.has(sid)) lastHead = i;
2616
+ }
2617
+ for (let i = 0; i < n; i++) {
2618
+ const sid = entries[i].sourceMessageId;
2619
+ if (sid && tailMessageIds.has(sid)) { firstTail = i; break; }
2620
+ }
2621
+ const historyEnd = firstTail - 1; // last middle (folded-history) entry
2622
+
2623
+ const marks = new Set<number>();
2624
+ if (lastHead >= 0) marks.add(lastHead); // system / head block
2625
+ if (historyEnd > lastHead) marks.add(historyEnd); // stable folded prefix (the big one)
2626
+ if (historyEnd - lastHead > 2) marks.add(lastHead + Math.floor((historyEnd - lastHead) / 2)); // mid-history
2627
+ marks.add(n - 1); // end → pure-append reuse
2628
+
2629
+ for (const idx of marks) if (idx >= 0 && idx < n) entries[idx].cacheMarker = true;
2630
+ }
2631
+
2675
2632
  /**
2676
2633
  * Walk an entries array; for every run of consecutive entries that
2677
2634
  * (a) have sourceRelation: 'copy' (raw, not a synthesized recall pair)
@@ -2765,6 +2722,23 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2765
2722
  return this._adaptivePicker;
2766
2723
  }
2767
2724
 
2725
+ /**
2726
+ * Build the picker for this compile. Instance folding strategies (kv-stable)
2727
+ * need the per-compile `PickerInputs` at construction, so they're built fresh
2728
+ * here; stateless ones (flat-profile / oldest-first) reuse the memoized picker.
2729
+ */
2730
+ protected buildPicker(inputs: PickerInputs): Picker {
2731
+ if (this.config.foldingStrategy === 'kv-stable') {
2732
+ return new Picker(
2733
+ new KvStableStrategy(inputs, {
2734
+ reachTokens: this.config.kvStableReachTokens,
2735
+ mergeThreshold: this.config.mergeThreshold,
2736
+ }),
2737
+ );
2738
+ }
2739
+ return this.getAdaptivePicker();
2740
+ }
2741
+
2768
2742
  /**
2769
2743
  * Walk the summary tree to find the L_k ancestor of a message.
2770
2744
  * Returns null if no ancestor exists at that level (e.g., L_k not yet produced).
@@ -2794,6 +2768,48 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2794
2768
  return current;
2795
2769
  }
2796
2770
 
2771
+ /**
2772
+ * Recursively expand a summary's `sourceIds` down to the leaf message IDs
2773
+ * it covers, adding each leaf into `out`.
2774
+ *
2775
+ * Required because `SummaryEntry.sourceIds` are level-relative: an L1's
2776
+ * sourceIds are raw message IDs (sourceLevel=0), but an L2's sourceIds
2777
+ * are L1 IDs (sourceLevel=1), and so on. Any dedup that walks `sourceIds`
2778
+ * directly only works for L1s — once L2+ summaries enter the picture
2779
+ * (which happens as soon as `mergeThreshold` L1s accumulate during
2780
+ * interleaved compression+merge ticks), the dedup silently fails and
2781
+ * already-summarized messages leak back into the request as raw text.
2782
+ * That's how Bug 10 produced 200k+ token merge prompts on a 4234-msg
2783
+ * import.
2784
+ *
2785
+ * Callers should also expand merged L1s (not just the unmerged frontier)
2786
+ * as defense in depth — a stale `mergedInto` pointer or a partially
2787
+ * applied merge shouldn't surface raw messages.
2788
+ *
2789
+ * `visited` guards against pathological cycles in the summary graph (a
2790
+ * corrupted store or a future merge regression that lets a summary
2791
+ * reference itself). The hierarchy is a DAG by construction, but a
2792
+ * stack overflow during compression — exactly when the safety net is
2793
+ * supposed to save the session — is too steep a price for trusting that.
2794
+ */
2795
+ protected expandSummaryToLeafMessageIds(
2796
+ summary: SummaryEntry,
2797
+ summariesById: ReadonlyMap<string, SummaryEntry>,
2798
+ out: Set<MessageId>,
2799
+ visited: Set<string> = new Set(),
2800
+ ): void {
2801
+ if (visited.has(summary.id)) return;
2802
+ visited.add(summary.id);
2803
+ if (summary.sourceLevel === 0) {
2804
+ for (const id of summary.sourceIds) out.add(id);
2805
+ return;
2806
+ }
2807
+ for (const childId of summary.sourceIds) {
2808
+ const child = summariesById.get(childId);
2809
+ if (child) this.expandSummaryToLeafMessageIds(child, summariesById, out, visited);
2810
+ }
2811
+ }
2812
+
2797
2813
  // ============================================================================
2798
2814
  // Hierarchical (threshold-driven) path
2799
2815
  // ============================================================================
@@ -2803,6 +2819,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2803
2819
  * Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
2804
2820
  */
2805
2821
  protected selectHierarchical(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
2822
+ this.rsBegin();
2806
2823
  const entries: ContextEntry[] = [];
2807
2824
  const maxTokens = budget.maxTokens - budget.reserveForResponse;
2808
2825
  const messages = store.getAll();
@@ -2827,6 +2844,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2827
2844
  content,
2828
2845
  });
2829
2846
  totalTokens += tokens;
2847
+ this.rsRaw('head', tokens);
2830
2848
  }
2831
2849
  // Mark the last head entry as a cache boundary (even if budget truncated the window)
2832
2850
  if (entries.length > 0) {
@@ -2986,6 +3004,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2986
3004
  entries.push(questionEntry);
2987
3005
  entries.push(answerEntry);
2988
3006
  totalTokens += pairTokens;
3007
+ this.rsSummary(summary.level, pairTokens);
2989
3008
  } else {
2990
3009
  const msg = item.msg;
2991
3010
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
@@ -3001,6 +3020,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3001
3020
  content,
3002
3021
  });
3003
3022
  totalTokens += tokens;
3023
+ this.rsRaw('middleRaw', tokens);
3004
3024
  }
3005
3025
  }
3006
3026
  } else {
@@ -3034,6 +3054,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3034
3054
  entries.push(questionEntry);
3035
3055
  entries.push(answerEntry);
3036
3056
  totalTokens += pairTokens;
3057
+ for (const s of selectedSummaries) this.rsSummary(s.level, s.tokens);
3037
3058
  }
3038
3059
 
3039
3060
  // Sort by position so uncompressed-middle messages and pins both
@@ -3053,6 +3074,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3053
3074
  content,
3054
3075
  });
3055
3076
  totalTokens += tokens;
3077
+ this.rsRaw('middleRaw', tokens);
3056
3078
  }
3057
3079
  }
3058
3080
  }
@@ -3062,10 +3084,15 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3062
3084
  // budget, the latest messages (the ones the agent actually needs to act
3063
3085
  // on) are preserved and the oldest recent-window messages are dropped.
3064
3086
  const effectiveRecentStart = Math.max(recentStart, headEnd);
3065
- this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
3087
+ const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
3088
+ this.rsRaw('tail', tailStats.tokens, tailStats.messages);
3066
3089
 
3067
3090
  this.trimOrphanedToolUse(entries);
3068
3091
  this.pruneToolEntries(entries);
3092
+ // Strip stale images before committing stats so RenderStats.total reflects
3093
+ // the post-strip context (this path places no cache markers).
3094
+ this.applyImageStripping(entries, store);
3095
+ this.rsEnd();
3069
3096
  return entries;
3070
3097
  }
3071
3098
 
@@ -3320,6 +3347,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3320
3347
  };
3321
3348
 
3322
3349
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
3350
+ // Text-only on purpose: summarizer scratch thinking is not agent history
3323
3351
  return response.content
3324
3352
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
3325
3353
  .map(b => b.text)
@@ -3410,6 +3438,17 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3410
3438
  currentTokens >= this.config.targetChunkTokens &&
3411
3439
  currentChunk.length >= 4;
3412
3440
 
3441
+ // Don't close a chunk on a message containing a tool_use block —
3442
+ // the matching tool_result lives in the immediately-following user
3443
+ // message, and the Anthropic API rejects a request where a tool_use
3444
+ // isn't immediately followed by its tool_result. Defer the close
3445
+ // by one iteration so the result rides along in the same chunk.
3446
+ // The stripUnpairedToolBlocks runtime pass is a safety net for the
3447
+ // rare case where a tool_use is the very last message in the store.
3448
+ if (shouldClose && this.lastMessageContainsToolUse(currentChunk)) {
3449
+ continue;
3450
+ }
3451
+
3413
3452
  if (shouldClose) {
3414
3453
  const chunk = this.createChunk(
3415
3454
  this.chunks.length,
@@ -3448,6 +3487,19 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3448
3487
  }
3449
3488
  }
3450
3489
 
3490
+ /**
3491
+ * Returns true if the last message in the chunk-in-progress contains a
3492
+ * `tool_use` block. Used by `rebuildChunks` to defer chunk closure until
3493
+ * the matching `tool_result` (in the immediately-following user message)
3494
+ * is pulled into the same chunk. See `stripUnpairedToolBlocks` for the
3495
+ * runtime safety net.
3496
+ */
3497
+ protected lastMessageContainsToolUse(chunk: StoredMessage[]): boolean {
3498
+ const last = chunk[chunk.length - 1];
3499
+ if (!last) return false;
3500
+ return last.content.some((b) => b.type === 'tool_use');
3501
+ }
3502
+
3451
3503
  protected createChunk(
3452
3504
  index: number,
3453
3505
  startIndex: number,
@@ -3469,7 +3521,6 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3469
3521
  const existing = existingCompressed.get(key);
3470
3522
  if (existing) {
3471
3523
  chunk.compressed = true;
3472
- chunk.diary = existing.diary;
3473
3524
  chunk.summaryId = existing.summaryId;
3474
3525
  }
3475
3526
 
@@ -3491,6 +3542,98 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3491
3542
  return chunk.messages.map((m) => m.id).join(':');
3492
3543
  }
3493
3544
 
3545
+ /** True if any content block is a live image. */
3546
+ protected hasImageBlock(content: ContentBlock[]): boolean {
3547
+ return content.some((b) => b.type === 'image');
3548
+ }
3549
+
3550
+ /** Message index marking the image-strip depth boundary: walks newest→oldest
3551
+ * summing the same per-message estimate as getRecentWindowStart, and returns
3552
+ * the index of the first message still within `depthTokens`. Messages before
3553
+ * this index have their images stripped to placeholders. */
3554
+ protected getImageStripStart(store: MessageStoreView, depthTokens: number): number {
3555
+ const messages = store.getAll();
3556
+ let tokens = 0;
3557
+ for (let i = messages.length - 1; i >= 0; i--) {
3558
+ tokens += store.estimateTokens(messages[i]);
3559
+ if (tokens > depthTokens) return i + 1;
3560
+ }
3561
+ return 0;
3562
+ }
3563
+
3564
+ /** Text substituted for an image block once it leaves the live-image window. */
3565
+ private static readonly IMAGE_PLACEHOLDER = '[image dropped from live context]';
3566
+
3567
+ /** Post-pass over compiled entries: replace image blocks with a text
3568
+ * placeholder once they fall outside the live-image window — either deeper
3569
+ * than `imageStripDepthTokens` from the newest message, or beyond the
3570
+ * `maxLiveImages` most-recent images (counted newest-first). Summaries are
3571
+ * already text, so they're naturally unaffected. The adjacent
3572
+ * "[image attachment: <name>]" text added at ingest preserves the filename,
3573
+ * so the placeholder itself stays terse. Reduces tokens, so it never pushes
3574
+ * a compiled context back over budget.
3575
+ *
3576
+ * Runs INSIDE each select path, *before* `rsEnd()` and `placeCacheMarkers`,
3577
+ * so the committed render stats (and the cache breakpoints) describe the
3578
+ * post-strip context. As it strips, it decrements the matching raw bucket of
3579
+ * the in-progress render stats by the reclaimed tokens, keeping
3580
+ * `RenderStats.total` equal to the real rendered size. */
3581
+ protected applyImageStripping(entries: ContextEntry[], store: MessageStoreView): void {
3582
+ const maxLive = this.config.maxLiveImages ?? 0; // 0 = unlimited count
3583
+ const depthTokens = this.config.imageStripDepthTokens ?? 0; // 0 = no depth strip
3584
+ if (maxLive === 0 && depthTokens === 0) return; // policy disabled
3585
+
3586
+ const messages = store.getAll();
3587
+ const posById = new Map<string, number>();
3588
+ for (let i = 0; i < messages.length; i++) posById.set(messages[i].id, i);
3589
+ const stripStart = depthTokens > 0 ? this.getImageStripStart(store, depthTokens) : 0;
3590
+
3591
+ // Same region windows select() bucketed by, so a stripped image's reclaimed
3592
+ // tokens come back out of the bucket it was originally tallied into.
3593
+ const headStart = this.getHeadWindowStartIndex(store);
3594
+ const headEnd = this.getHeadWindowEnd(store);
3595
+ const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
3596
+ const bucketAt = (pos: number): 'head' | 'tail' | 'middleRaw' => {
3597
+ if (pos < 0) return 'middleRaw'; // no resolvable region — keep total == Σbuckets
3598
+ if (pos >= headStart && pos < headEnd) return 'head';
3599
+ if (pos >= recentStart) return 'tail';
3600
+ return 'middleRaw';
3601
+ };
3602
+ const placeholderTokens = Math.ceil(AutobiographicalStrategy.IMAGE_PLACEHOLDER.length / 4);
3603
+
3604
+ // Image-bearing entries, newest-first by source position. Entries with no
3605
+ // resolvable source position sort last (pos -1) and never count as "live".
3606
+ const ordered = entries
3607
+ .map((entry, idx) => ({
3608
+ idx,
3609
+ pos: entry.sourceMessageId !== undefined ? posById.get(entry.sourceMessageId) ?? -1 : -1,
3610
+ }))
3611
+ .filter(({ idx }) => this.hasImageBlock(entries[idx].content))
3612
+ .sort((a, b) => b.pos - a.pos);
3613
+
3614
+ let keptImages = 0;
3615
+ for (const { idx, pos } of ordered) {
3616
+ const entry = entries[idx];
3617
+ const tooDeep = depthTokens > 0 && (pos < 0 || pos < stripStart);
3618
+ const bucket = bucketAt(pos);
3619
+ entry.content = entry.content.map((block) => {
3620
+ if (block.type !== 'image') return block;
3621
+ const overCount = maxLive > 0 && keptImages >= maxLive;
3622
+ if (tooDeep || overCount) {
3623
+ // The renderer estimates an image at `tokenEstimate ?? 1600` (see
3624
+ // message-store/context-log). Hand that back to the bucket, less the
3625
+ // placeholder text that replaces it, so the stats match the output.
3626
+ const reclaimed =
3627
+ ((block as { tokenEstimate?: number }).tokenEstimate ?? 1600) - placeholderTokens;
3628
+ if (this._rs && reclaimed > 0) this._rs[bucket].tokens -= reclaimed;
3629
+ return { type: 'text', text: AutobiographicalStrategy.IMAGE_PLACEHOLDER } as ContentBlock;
3630
+ }
3631
+ keptImages++;
3632
+ return block;
3633
+ });
3634
+ }
3635
+ }
3636
+
3494
3637
  protected getRecentWindowStart(store: MessageStoreView): number {
3495
3638
  const messages = store.getAll();
3496
3639
  let tokens = 0;