@animalabs/context-manager 0.5.3 → 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 (90) 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/message-store.js +1 -1
  33. package/dist/src/message-store.js.map +1 -1
  34. package/dist/src/strategies/autobiographical.d.ts +68 -39
  35. package/dist/src/strategies/autobiographical.d.ts.map +1 -1
  36. package/dist/src/strategies/autobiographical.js +301 -213
  37. package/dist/src/strategies/autobiographical.js.map +1 -1
  38. package/dist/src/types/strategy.d.ts +40 -1
  39. package/dist/src/types/strategy.d.ts.map +1 -1
  40. package/dist/src/types/strategy.js +2 -0
  41. package/dist/src/types/strategy.js.map +1 -1
  42. package/dist/test/adaptive/kv-cache-sim.test.d.ts +16 -0
  43. package/dist/test/adaptive/kv-cache-sim.test.d.ts.map +1 -0
  44. package/dist/test/adaptive/kv-cache-sim.test.js +173 -0
  45. package/dist/test/adaptive/kv-cache-sim.test.js.map +1 -0
  46. package/dist/test/adaptive/kv-control.test.d.ts +14 -0
  47. package/dist/test/adaptive/kv-control.test.d.ts.map +1 -0
  48. package/dist/test/adaptive/kv-control.test.js +130 -0
  49. package/dist/test/adaptive/kv-control.test.js.map +1 -0
  50. package/dist/test/adaptive/kv-replay.test.d.ts +15 -0
  51. package/dist/test/adaptive/kv-replay.test.d.ts.map +1 -0
  52. package/dist/test/adaptive/kv-replay.test.js +112 -0
  53. package/dist/test/adaptive/kv-replay.test.js.map +1 -0
  54. package/dist/test/adaptive/kv-stable-strategy.test.d.ts +10 -0
  55. package/dist/test/adaptive/kv-stable-strategy.test.d.ts.map +1 -0
  56. package/dist/test/adaptive/kv-stable-strategy.test.js +89 -0
  57. package/dist/test/adaptive/kv-stable-strategy.test.js.map +1 -0
  58. package/dist/test/adaptive/render-offsets.test.d.ts +11 -0
  59. package/dist/test/adaptive/render-offsets.test.d.ts.map +1 -0
  60. package/dist/test/adaptive/render-offsets.test.js +85 -0
  61. package/dist/test/adaptive/render-offsets.test.js.map +1 -0
  62. package/dist/test/adaptive/summary-tree.test.d.ts +5 -0
  63. package/dist/test/adaptive/summary-tree.test.d.ts.map +1 -0
  64. package/dist/test/adaptive/summary-tree.test.js +72 -0
  65. package/dist/test/adaptive/summary-tree.test.js.map +1 -0
  66. package/dist/test/image-strip-render-stats.test.d.ts +2 -0
  67. package/dist/test/image-strip-render-stats.test.d.ts.map +1 -0
  68. package/dist/test/image-strip-render-stats.test.js +130 -0
  69. package/dist/test/image-strip-render-stats.test.js.map +1 -0
  70. package/dist/test/image-stripping.test.d.ts +13 -0
  71. package/dist/test/image-stripping.test.d.ts.map +1 -0
  72. package/dist/test/image-stripping.test.js +104 -0
  73. package/dist/test/image-stripping.test.js.map +1 -0
  74. package/dist/test/render-stats.test.d.ts +2 -0
  75. package/dist/test/render-stats.test.d.ts.map +1 -0
  76. package/dist/test/render-stats.test.js +91 -0
  77. package/dist/test/render-stats.test.js.map +1 -0
  78. package/dist/tsconfig.tsbuildinfo +1 -1
  79. package/package.json +2 -2
  80. package/src/adaptive/index.ts +48 -0
  81. package/src/adaptive/kv-cache-sim.ts +0 -0
  82. package/src/adaptive/kv-control.ts +497 -0
  83. package/src/adaptive/kv-replay.ts +238 -0
  84. package/src/adaptive/render-offsets.ts +116 -0
  85. package/src/adaptive/strategies/kv-stable.ts +123 -0
  86. package/src/adaptive/summary-tree.ts +252 -0
  87. package/src/context-log.ts +7 -2
  88. package/src/message-store.ts +1 -1
  89. package/src/strategies/autobiographical.ts +314 -263
  90. 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
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
  // ============================================================================
@@ -1731,6 +1602,10 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1731
1602
 
1732
1603
  try {
1733
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.
1734
1609
  const summaryText = response.content
1735
1610
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
1736
1611
  .map(b => b.text)
@@ -2188,6 +2063,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2188
2063
 
2189
2064
  try {
2190
2065
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
2066
+ // Text-only on purpose: summarizer scratch thinking is not agent history
2191
2067
  const mergedText = response.content
2192
2068
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
2193
2069
  .map(b => b.text)
@@ -2272,6 +2148,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2272
2148
  * See `docs/adaptive-resolution-design.md` §3, §5.
2273
2149
  */
2274
2150
  protected selectAdaptive(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
2151
+ this.rsBegin();
2275
2152
  const entries: ContextEntry[] = [];
2276
2153
  const maxTokens = budget.maxTokens - budget.reserveForResponse;
2277
2154
  const messages = store.getAll();
@@ -2305,10 +2182,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2305
2182
  totalTokens += tokens;
2306
2183
  headMessageIds.add(msg.id);
2307
2184
  headTokens += tokens;
2185
+ this.rsRaw('head', tokens);
2308
2186
  }
2309
- if (entries.length > 0) {
2310
- entries[entries.length - 1].cacheMarker = true;
2311
- }
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.)
2312
2190
 
2313
2191
  // Compute tail message IDs (will be emitted at end)
2314
2192
  const effectiveRecentStart = Math.max(recentStart, headEnd);
@@ -2412,19 +2290,17 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2412
2290
  const headSetForPicker = new Set<ChunkId>(headMessageIds);
2413
2291
  const tailSetForPicker = new Set<ChunkId>(tailMessageIds);
2414
2292
 
2415
- const picker = this.getAdaptivePicker();
2416
- const result = picker.run(
2417
- {
2418
- chunks: pickerChunks,
2419
- summaries: summariesMap,
2420
- recallPairTokens,
2421
- headChunkIds: headSetForPicker,
2422
- tailChunkIds: tailSetForPicker,
2423
- headTokens,
2424
- tailTokens,
2425
- },
2426
- foldingBudget
2427
- );
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);
2428
2304
 
2429
2305
  // Commit the new resolutions back to strategy state for next compile.
2430
2306
  // Persist to chronicle only if anything actually changed — avoids
@@ -2552,6 +2428,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2552
2428
  content,
2553
2429
  });
2554
2430
  totalTokens += tokens;
2431
+ this.rsRaw('middleRaw', tokens);
2555
2432
  } else {
2556
2433
  // summary run — emit Q+A pair, dedup at the strategy level
2557
2434
  const ancestor = currentRun.ancestor;
@@ -2578,6 +2455,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2578
2455
  entries.push(questionEntry);
2579
2456
  entries.push(answerEntry);
2580
2457
  totalTokens += pairTokens;
2458
+ this.rsSummary(ancestor.level, pairTokens);
2581
2459
  }
2582
2460
  }
2583
2461
  currentRun = null;
@@ -2638,6 +2516,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2638
2516
  content,
2639
2517
  });
2640
2518
  totalTokens += tokens;
2519
+ this.rsRaw('middleRaw', tokens);
2641
2520
  i++;
2642
2521
  } else {
2643
2522
  const ancestor = this.findAncestorAt(msg.id, resolution, chunksByMessageId, summariesById);
@@ -2653,6 +2532,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2653
2532
  content,
2654
2533
  });
2655
2534
  totalTokens += tokens;
2535
+ this.rsRaw('middleRaw', tokens);
2656
2536
  i++;
2657
2537
  continue;
2658
2538
  }
@@ -2679,12 +2559,14 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2679
2559
  entries.push(questionEntry);
2680
2560
  entries.push(answerEntry);
2681
2561
  totalTokens += pairTokens;
2562
+ this.rsSummary(ancestor.level, pairTokens);
2682
2563
  i++;
2683
2564
  }
2684
2565
  }
2685
2566
 
2686
2567
  // ----- 6. Emit tail entries newest-first eviction (matches existing behavior) -----
2687
- 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);
2688
2570
 
2689
2571
  // ----- 7. Post-process: merge consecutive raw entries from the same bodyGroup -----
2690
2572
  // Both head and tail emission paths emit shards as separate ContextEntries.
@@ -2694,10 +2576,59 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2694
2576
  // it falls into (preserves KV cache through region transitions).
2695
2577
  const merged = this.mergeAdjacentBodyGroupRaw(entries, store);
2696
2578
 
2579
+ this.pruneToolEntries(merged);
2697
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();
2698
2591
  return merged;
2699
2592
  }
2700
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
+
2701
2632
  /**
2702
2633
  * Walk an entries array; for every run of consecutive entries that
2703
2634
  * (a) have sourceRelation: 'copy' (raw, not a synthesized recall pair)
@@ -2791,6 +2722,23 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2791
2722
  return this._adaptivePicker;
2792
2723
  }
2793
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
+
2794
2742
  /**
2795
2743
  * Walk the summary tree to find the L_k ancestor of a message.
2796
2744
  * Returns null if no ancestor exists at that level (e.g., L_k not yet produced).
@@ -2871,6 +2819,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2871
2819
  * Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
2872
2820
  */
2873
2821
  protected selectHierarchical(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
2822
+ this.rsBegin();
2874
2823
  const entries: ContextEntry[] = [];
2875
2824
  const maxTokens = budget.maxTokens - budget.reserveForResponse;
2876
2825
  const messages = store.getAll();
@@ -2895,6 +2844,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2895
2844
  content,
2896
2845
  });
2897
2846
  totalTokens += tokens;
2847
+ this.rsRaw('head', tokens);
2898
2848
  }
2899
2849
  // Mark the last head entry as a cache boundary (even if budget truncated the window)
2900
2850
  if (entries.length > 0) {
@@ -3054,6 +3004,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3054
3004
  entries.push(questionEntry);
3055
3005
  entries.push(answerEntry);
3056
3006
  totalTokens += pairTokens;
3007
+ this.rsSummary(summary.level, pairTokens);
3057
3008
  } else {
3058
3009
  const msg = item.msg;
3059
3010
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
@@ -3069,6 +3020,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3069
3020
  content,
3070
3021
  });
3071
3022
  totalTokens += tokens;
3023
+ this.rsRaw('middleRaw', tokens);
3072
3024
  }
3073
3025
  }
3074
3026
  } else {
@@ -3102,6 +3054,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3102
3054
  entries.push(questionEntry);
3103
3055
  entries.push(answerEntry);
3104
3056
  totalTokens += pairTokens;
3057
+ for (const s of selectedSummaries) this.rsSummary(s.level, s.tokens);
3105
3058
  }
3106
3059
 
3107
3060
  // Sort by position so uncompressed-middle messages and pins both
@@ -3121,6 +3074,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3121
3074
  content,
3122
3075
  });
3123
3076
  totalTokens += tokens;
3077
+ this.rsRaw('middleRaw', tokens);
3124
3078
  }
3125
3079
  }
3126
3080
  }
@@ -3130,10 +3084,15 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3130
3084
  // budget, the latest messages (the ones the agent actually needs to act
3131
3085
  // on) are preserved and the oldest recent-window messages are dropped.
3132
3086
  const effectiveRecentStart = Math.max(recentStart, headEnd);
3133
- 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);
3134
3089
 
3135
3090
  this.trimOrphanedToolUse(entries);
3136
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();
3137
3096
  return entries;
3138
3097
  }
3139
3098
 
@@ -3388,6 +3347,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3388
3347
  };
3389
3348
 
3390
3349
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
3350
+ // Text-only on purpose: summarizer scratch thinking is not agent history
3391
3351
  return response.content
3392
3352
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
3393
3353
  .map(b => b.text)
@@ -3561,7 +3521,6 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3561
3521
  const existing = existingCompressed.get(key);
3562
3522
  if (existing) {
3563
3523
  chunk.compressed = true;
3564
- chunk.diary = existing.diary;
3565
3524
  chunk.summaryId = existing.summaryId;
3566
3525
  }
3567
3526
 
@@ -3583,6 +3542,98 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3583
3542
  return chunk.messages.map((m) => m.id).join(':');
3584
3543
  }
3585
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
+
3586
3637
  protected getRecentWindowStart(store: MessageStoreView): number {
3587
3638
  const messages = store.getAll();
3588
3639
  let tokens = 0;