@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
@@ -6,6 +6,7 @@ import { appendFileSync, mkdirSync } from 'node:fs';
6
6
  import { dirname } from 'node:path';
7
7
  import { Picker, OverBudgetError } from '../adaptive/picker.js';
8
8
  import { FlatProfileStrategy } from '../adaptive/strategies/flat-profile.js';
9
+ import { KvStableStrategy } from '../adaptive/strategies/kv-stable.js';
9
10
  import { OldestFirstStrategy } from '../adaptive/strategies/oldest-first.js';
10
11
  import { chunkMessage, DEFAULT_CHUNKER_OPTIONS } from '../adaptive/chunker.js';
11
12
  /**
@@ -923,9 +924,7 @@ export class AutobiographicalStrategy {
923
924
  const chunk = this.chunks[chunkIndex];
924
925
  if (!chunk || chunk.compressed)
925
926
  return;
926
- this.pendingCompression = this.config.hierarchical
927
- ? this.compressChunkHierarchical(chunk, ctx)
928
- : this.compressChunkLegacy(chunk, ctx);
927
+ this.pendingCompression = this.compressChunkHierarchical(chunk, ctx);
929
928
  try {
930
929
  await this.pendingCompression;
931
930
  }
@@ -992,12 +991,13 @@ export class AutobiographicalStrategy {
992
991
  }
993
992
  select(store, log, budget) {
994
993
  this.rebuildChunks(store);
994
+ // Image stripping runs inside each select path (before stats commit / cache
995
+ // markers), so the returned entries are already bounded — see
996
+ // applyImageStripping.
995
997
  if (this.config.adaptiveResolution) {
996
998
  return this.selectAdaptive(store, budget);
997
999
  }
998
- return this.config.hierarchical
999
- ? this.selectHierarchical(store, budget)
1000
- : this.selectLegacy(store, log, budget);
1000
+ return this.selectHierarchical(store, budget);
1001
1001
  }
1002
1002
  /**
1003
1003
  * Get summary statistics for observability.
@@ -1022,7 +1022,85 @@ export class AutobiographicalStrategy {
1022
1022
  * Useful for TUI / dashboards. The token sums use the strategy's own
1023
1023
  * token estimates (which match what `select()` uses for budget math).
1024
1024
  */
1025
+ // ===========================================================================
1026
+ // Render-stats instrumentation — inspect, don't reconstruct.
1027
+ //
1028
+ // selectAdaptive/selectHierarchical tally each entry into `_rs` AS THEY EMIT
1029
+ // it (raw head/tail, raw middle the picker kept verbatim, and recall pairs
1030
+ // bucketed by the ancestor summary's level), using the same token numbers the
1031
+ // renderer uses for budget math. `getRenderStats()` returns that committed
1032
+ // snapshot, so it reflects what the last compile actually rendered rather than
1033
+ // re-deriving the full pyramid (which, under adaptive resolution, bears little
1034
+ // resemblance to the folded output).
1035
+ // ===========================================================================
1036
+ _rs = null;
1037
+ _lastRenderStats = null;
1038
+ /** Begin a render-stats accumulation for one select() pass. */
1039
+ rsBegin() {
1040
+ this._rs = {
1041
+ head: { messages: 0, tokens: 0 },
1042
+ tail: { messages: 0, tokens: 0 },
1043
+ middleRaw: { messages: 0, tokens: 0 },
1044
+ summaries: {
1045
+ l1: { count: 0, tokens: 0 },
1046
+ l2: { count: 0, tokens: 0 },
1047
+ l3: { count: 0, tokens: 0 },
1048
+ },
1049
+ pending: { chunks: 0, merges: 0 },
1050
+ total: { messages: 0, tokens: 0 },
1051
+ };
1052
+ }
1053
+ /** Tally one (or `count`) raw message(s) into a raw bucket. */
1054
+ rsRaw(bucket, tokens, count = 1) {
1055
+ const r = this._rs;
1056
+ if (!r)
1057
+ return;
1058
+ r[bucket].messages += count;
1059
+ r[bucket].tokens += tokens;
1060
+ }
1061
+ /** Tally one emitted recall pair under its ancestor's level (>=3 folds into l3). */
1062
+ rsSummary(level, tokens) {
1063
+ const r = this._rs;
1064
+ if (!r)
1065
+ return;
1066
+ const k = level <= 1 ? 'l1' : level === 2 ? 'l2' : 'l3';
1067
+ r.summaries[k].count += 1;
1068
+ r.summaries[k].tokens += tokens;
1069
+ }
1070
+ /** Commit the accumulated stats as the last-render snapshot. */
1071
+ rsEnd() {
1072
+ const r = this._rs;
1073
+ if (!r)
1074
+ return;
1075
+ r.pending = {
1076
+ chunks: this.chunks.filter(c => !c.compressed).length,
1077
+ merges: this.mergeQueue.length,
1078
+ };
1079
+ const s = r.summaries;
1080
+ const summaryMsgs = (s.l1.count + s.l2.count + s.l3.count) * 2; // Q/A pair each
1081
+ r.total = {
1082
+ messages: r.head.messages + r.tail.messages + r.middleRaw.messages + summaryMsgs,
1083
+ tokens: r.head.tokens + r.tail.tokens + r.middleRaw.tokens +
1084
+ s.l1.tokens + s.l2.tokens + s.l3.tokens,
1085
+ };
1086
+ this._lastRenderStats = r;
1087
+ this._rs = null;
1088
+ }
1089
+ /**
1090
+ * Stats describing the LAST rendered context. Returns the inspected snapshot
1091
+ * captured during the most recent `select()`. Before any compile has run (no
1092
+ * snapshot yet), falls back to a reconstructed pyramid view so callers still
1093
+ * get a non-null shape.
1094
+ */
1025
1095
  getRenderStats(store) {
1096
+ return this._lastRenderStats ?? this.reconstructRenderStats(store);
1097
+ }
1098
+ /**
1099
+ * Pre-render fallback: re-derive head/tail windows + the full live pyramid.
1100
+ * NOTE: this is the old "reconstruct" behavior and does NOT reflect adaptive
1101
+ * folding — used only until the first compile populates the inspected stats.
1102
+ */
1103
+ reconstructRenderStats(store) {
1026
1104
  const messages = store.getAll();
1027
1105
  const headStart = this.getHeadWindowStartIndex(store);
1028
1106
  const headEnd = this.getHeadWindowEnd(store);
@@ -1032,131 +1110,30 @@ export class AutobiographicalStrategy {
1032
1110
  const tailMsgs = messages.slice(recentStart);
1033
1111
  const live = (level) => this.summaries.filter(s => s.level === level && !s.mergedInto);
1034
1112
  const sumLevelTokens = (level) => live(level).reduce((acc, s) => acc + s.tokens, 0);
1113
+ const head = { messages: headMsgs.length, tokens: sumTokens(headMsgs) };
1114
+ const tail = { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) };
1115
+ const summaries = {
1116
+ l1: { count: live(1).length, tokens: sumLevelTokens(1) },
1117
+ l2: { count: live(2).length, tokens: sumLevelTokens(2) },
1118
+ l3: { count: live(3).length, tokens: sumLevelTokens(3) },
1119
+ };
1035
1120
  return {
1036
- head: { messages: headMsgs.length, tokens: sumTokens(headMsgs) },
1037
- tail: { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) },
1038
- summaries: {
1039
- l1: { count: live(1).length, tokens: sumLevelTokens(1) },
1040
- l2: { count: live(2).length, tokens: sumLevelTokens(2) },
1041
- l3: { count: live(3).length, tokens: sumLevelTokens(3) },
1042
- },
1121
+ head,
1122
+ tail,
1123
+ middleRaw: { messages: 0, tokens: 0 },
1124
+ summaries,
1043
1125
  pending: {
1044
1126
  chunks: this.chunks.filter(c => !c.compressed).length,
1045
1127
  merges: this.mergeQueue.length,
1046
1128
  },
1129
+ total: {
1130
+ messages: head.messages + tail.messages
1131
+ + (summaries.l1.count + summaries.l2.count + summaries.l3.count) * 2,
1132
+ tokens: head.tokens + tail.tokens
1133
+ + summaries.l1.tokens + summaries.l2.tokens + summaries.l3.tokens,
1134
+ },
1047
1135
  };
1048
1136
  }
1049
- // ============================================================================
1050
- // Legacy (single-level) path
1051
- // ============================================================================
1052
- selectLegacy(store, _log, budget) {
1053
- const entries = [];
1054
- const maxTokens = budget.maxTokens - budget.reserveForResponse;
1055
- let totalTokens = 0;
1056
- const messages = store.getAll();
1057
- const msgCap = this.config.maxMessageTokens;
1058
- // 1. Head window: preserved verbatim as raw copies
1059
- const headStart = this.getHeadWindowStartIndex(store);
1060
- const headEnd = this.getHeadWindowEnd(store);
1061
- for (let i = headStart; i < headEnd && i < messages.length; i++) {
1062
- const msg = messages[i];
1063
- const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1064
- const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
1065
- if (this.isOverBudget(totalTokens + tokens, maxTokens))
1066
- break;
1067
- entries.push({
1068
- index: entries.length,
1069
- sourceMessageId: msg.id,
1070
- sourceRelation: 'copy',
1071
- participant: msg.participant,
1072
- content,
1073
- });
1074
- totalTokens += tokens;
1075
- }
1076
- // Mark the last head entry as a cache boundary (even if budget truncated the window)
1077
- if (entries.length > 0) {
1078
- entries[entries.length - 1].cacheMarker = true;
1079
- }
1080
- // 2. Middle zone: compressed chunks as diary pairs, uncompressed as raw messages.
1081
- const rawRecentStart = this.getRecentWindowStart(store);
1082
- // Track which message IDs are covered by chunks
1083
- const coveredByChunks = new Set();
1084
- for (const chunk of this.chunks) {
1085
- for (const m of chunk.messages)
1086
- coveredByChunks.add(m.id);
1087
- if (chunk.compressed && chunk.diary) {
1088
- const contextLabel = this.config.summaryContextLabel ?? 'Here is a summary of earlier conversation context:';
1089
- const summaryParticipant = this.config.summaryParticipant ?? 'Summary';
1090
- const questionEntry = {
1091
- index: entries.length,
1092
- participant: 'Context Manager',
1093
- content: [{ type: 'text', text: contextLabel }],
1094
- sourceRelation: 'derived',
1095
- };
1096
- // Synthesised summary turns must respect maxMessageTokens just like raw
1097
- // copies do — otherwise a runaway diary can starve recent messages.
1098
- const answerContent = [{ type: 'text', text: chunk.diary }];
1099
- const answerEntry = {
1100
- index: entries.length + 1,
1101
- participant: summaryParticipant,
1102
- content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
1103
- sourceRelation: 'derived',
1104
- };
1105
- const pairTokens = this.estimateTokens(questionEntry.content) +
1106
- this.estimateTokens(answerEntry.content);
1107
- if (this.isOverBudget(totalTokens + pairTokens, maxTokens))
1108
- break;
1109
- entries.push(questionEntry);
1110
- entries.push(answerEntry);
1111
- totalTokens += pairTokens;
1112
- }
1113
- else {
1114
- // Uncompressed: emit raw messages so they aren't lost
1115
- for (const msg of chunk.messages) {
1116
- const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1117
- const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
1118
- if (this.isOverBudget(totalTokens + tokens, maxTokens))
1119
- break;
1120
- entries.push({
1121
- index: entries.length,
1122
- sourceMessageId: msg.id,
1123
- sourceRelation: 'copy',
1124
- participant: msg.participant,
1125
- content,
1126
- });
1127
- totalTokens += tokens;
1128
- }
1129
- }
1130
- }
1131
- // Emit gap messages in the compressible zone not covered by any chunk.
1132
- // Compressible zone: [0, headStart) ∪ [headEnd, rawRecentStart)
1133
- for (let i = 0; i < rawRecentStart && i < messages.length; i++) {
1134
- // Skip head window messages (already emitted verbatim above)
1135
- if (i >= headStart && i < headEnd)
1136
- continue;
1137
- if (coveredByChunks.has(messages[i].id))
1138
- continue;
1139
- const msg = messages[i];
1140
- const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1141
- const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
1142
- if (this.isOverBudget(totalTokens + tokens, maxTokens))
1143
- break;
1144
- entries.push({
1145
- index: entries.length,
1146
- sourceMessageId: msg.id,
1147
- sourceRelation: 'copy',
1148
- participant: msg.participant,
1149
- content,
1150
- });
1151
- totalTokens += tokens;
1152
- }
1153
- // 3. Recent uncompressed messages (skip those already in head window)
1154
- const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
1155
- this.emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokens);
1156
- this.trimOrphanedToolUse(entries);
1157
- this.pruneToolEntries(entries);
1158
- return entries;
1159
- }
1160
1137
  /**
1161
1138
  * Emit recent-window messages, evicting OLDEST-first when the budget is tight.
1162
1139
  *
@@ -1170,7 +1147,7 @@ export class AutobiographicalStrategy {
1170
1147
  */
1171
1148
  emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokensBefore) {
1172
1149
  if (recentStart >= messages.length)
1173
- return;
1150
+ return { messages: 0, tokens: 0 };
1174
1151
  const accepted = [];
1175
1152
  let acceptedTokens = 0;
1176
1153
  for (let i = messages.length - 1; i >= recentStart; i--) {
@@ -1190,9 +1167,13 @@ export class AutobiographicalStrategy {
1190
1167
  !this.hasToolUse(messages[accepted[0]])) {
1191
1168
  accepted.shift();
1192
1169
  }
1170
+ let emittedTokens = 0;
1193
1171
  for (const i of accepted) {
1194
1172
  const msg = messages[i];
1195
1173
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1174
+ const tokens = msgCap > 0
1175
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
1176
+ : store.estimateTokens(msg);
1196
1177
  entries.push({
1197
1178
  index: entries.length,
1198
1179
  sourceMessageId: msg.id,
@@ -1200,86 +1181,9 @@ export class AutobiographicalStrategy {
1200
1181
  participant: msg.participant,
1201
1182
  content,
1202
1183
  });
1184
+ emittedTokens += tokens;
1203
1185
  }
1204
- }
1205
- async compressChunkLegacy(chunk, ctx) {
1206
- if (!ctx.membrane) {
1207
- throw new Error('No membrane instance for compression');
1208
- }
1209
- const priorContext = this.buildPriorContextLegacy(chunk, ctx);
1210
- const chunkContent = this.formatChunkForCompression(chunk);
1211
- const prompt = this.config.diaryUserPrompt ?? this.config.summaryUserPrompt;
1212
- const systemPrompt = this.config.diarySystemPrompt ?? this.config.summarySystemPrompt;
1213
- const messages = [
1214
- ...priorContext,
1215
- {
1216
- participant: 'Context Manager',
1217
- content: [{ type: 'text', text: prompt.replace('{content}', chunkContent) }],
1218
- },
1219
- ];
1220
- const request = {
1221
- messages: messages.map((m) => ({
1222
- participant: m.participant,
1223
- content: m.content,
1224
- })),
1225
- system: systemPrompt,
1226
- config: {
1227
- model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
1228
- maxTokens: 2000,
1229
- },
1230
- };
1231
- try {
1232
- const response = await ctx.membrane.complete(request);
1233
- const diaryText = response.content
1234
- .filter((b) => b.type === 'text')
1235
- .map((b) => b.text)
1236
- .join('\n');
1237
- chunk.compressed = true;
1238
- chunk.diary = diaryText;
1239
- this._compressionCount++;
1240
- }
1241
- catch (error) {
1242
- console.error('Failed to compress chunk:', error);
1243
- throw error;
1244
- }
1245
- }
1246
- buildPriorContextLegacy(chunk, ctx) {
1247
- const context = [];
1248
- for (const prevChunk of this.chunks) {
1249
- if (prevChunk.index >= chunk.index)
1250
- break;
1251
- if (!prevChunk.compressed || !prevChunk.diary)
1252
- continue;
1253
- context.push({
1254
- participant: 'Context Manager',
1255
- content: [{ type: 'text', text: this.config.summaryContextLabel ?? 'Summary of earlier context:' }],
1256
- });
1257
- context.push({
1258
- participant: this.config.summaryParticipant ?? 'Summary',
1259
- content: [{ type: 'text', text: prevChunk.diary }],
1260
- });
1261
- }
1262
- // Find the actual position of this chunk's first message in the full array
1263
- const messages = ctx.messageStore.getAll();
1264
- const firstMsgId = chunk.messages[0]?.id;
1265
- const chunkAbsStart = firstMsgId
1266
- ? messages.findIndex(m => m.id === firstMsgId)
1267
- : -1;
1268
- if (chunkAbsStart > 0) {
1269
- const precedingStart = Math.max(0, chunkAbsStart - 50);
1270
- let tokens = 0;
1271
- for (let i = chunkAbsStart - 1; i >= precedingStart && tokens < 15000; i--) {
1272
- const msg = messages[i];
1273
- if (!msg)
1274
- break;
1275
- tokens += ctx.messageStore.estimateTokens(msg);
1276
- context.unshift({
1277
- participant: msg.participant,
1278
- content: msg.content,
1279
- });
1280
- }
1281
- }
1282
- return context;
1186
+ return { messages: accepted.length, tokens: emittedTokens };
1283
1187
  }
1284
1188
  // ============================================================================
1285
1189
  // Hierarchical (L1/L2/L3) path
@@ -1493,6 +1397,10 @@ export class AutobiographicalStrategy {
1493
1397
  let logSummaryId;
1494
1398
  try {
1495
1399
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
1400
+ // Text-only on purpose: this is the SUMMARIZER's one-shot response —
1401
+ // its thinking/redacted_thinking blocks are scratch work, not part of
1402
+ // the agent's history, and signed thinking is never valid inside a
1403
+ // rewritten summary anyway.
1496
1404
  const summaryText = response.content
1497
1405
  .filter((b) => b.type === 'text')
1498
1406
  .map(b => b.text)
@@ -1913,6 +1821,7 @@ export class AutobiographicalStrategy {
1913
1821
  let logNewSummaryId;
1914
1822
  try {
1915
1823
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
1824
+ // Text-only on purpose: summarizer scratch thinking is not agent history
1916
1825
  const mergedText = response.content
1917
1826
  .filter((b) => b.type === 'text')
1918
1827
  .map(b => b.text)
@@ -1992,6 +1901,7 @@ export class AutobiographicalStrategy {
1992
1901
  * See `docs/adaptive-resolution-design.md` §3, §5.
1993
1902
  */
1994
1903
  selectAdaptive(store, budget) {
1904
+ this.rsBegin();
1995
1905
  const entries = [];
1996
1906
  const maxTokens = budget.maxTokens - budget.reserveForResponse;
1997
1907
  const messages = store.getAll();
@@ -2022,10 +1932,11 @@ export class AutobiographicalStrategy {
2022
1932
  totalTokens += tokens;
2023
1933
  headMessageIds.add(msg.id);
2024
1934
  headTokens += tokens;
1935
+ this.rsRaw('head', tokens);
2025
1936
  }
2026
- if (entries.length > 0) {
2027
- entries[entries.length - 1].cacheMarker = true;
2028
- }
1937
+ // (Cache breakpoints are placed in one pass over the FINAL ordered entries
1938
+ // below see placeCacheMarkers — capturing the stable folded prefix, not
1939
+ // just the head boundary.)
2029
1940
  // Compute tail message IDs (will be emitted at end)
2030
1941
  const effectiveRecentStart = Math.max(recentStart, headEnd);
2031
1942
  for (let i = effectiveRecentStart; i < messages.length; i++) {
@@ -2120,8 +2031,7 @@ export class AutobiographicalStrategy {
2120
2031
  };
2121
2032
  const headSetForPicker = new Set(headMessageIds);
2122
2033
  const tailSetForPicker = new Set(tailMessageIds);
2123
- const picker = this.getAdaptivePicker();
2124
- const result = picker.run({
2034
+ const pickerInputs = {
2125
2035
  chunks: pickerChunks,
2126
2036
  summaries: summariesMap,
2127
2037
  recallPairTokens,
@@ -2129,7 +2039,9 @@ export class AutobiographicalStrategy {
2129
2039
  tailChunkIds: tailSetForPicker,
2130
2040
  headTokens,
2131
2041
  tailTokens,
2132
- }, foldingBudget);
2042
+ };
2043
+ const picker = this.buildPicker(pickerInputs);
2044
+ const result = picker.run(pickerInputs, foldingBudget);
2133
2045
  // Commit the new resolutions back to strategy state for next compile.
2134
2046
  // Persist to chronicle only if anything actually changed — avoids
2135
2047
  // unnecessary state-slot writes on no-op compiles (which is the common
@@ -2236,6 +2148,7 @@ export class AutobiographicalStrategy {
2236
2148
  content,
2237
2149
  });
2238
2150
  totalTokens += tokens;
2151
+ this.rsRaw('middleRaw', tokens);
2239
2152
  }
2240
2153
  else {
2241
2154
  // summary run — emit Q+A pair, dedup at the strategy level
@@ -2263,6 +2176,7 @@ export class AutobiographicalStrategy {
2263
2176
  entries.push(questionEntry);
2264
2177
  entries.push(answerEntry);
2265
2178
  totalTokens += pairTokens;
2179
+ this.rsSummary(ancestor.level, pairTokens);
2266
2180
  }
2267
2181
  }
2268
2182
  currentRun = null;
@@ -2336,6 +2250,7 @@ export class AutobiographicalStrategy {
2336
2250
  content,
2337
2251
  });
2338
2252
  totalTokens += tokens;
2253
+ this.rsRaw('middleRaw', tokens);
2339
2254
  i++;
2340
2255
  }
2341
2256
  else {
@@ -2353,6 +2268,7 @@ export class AutobiographicalStrategy {
2353
2268
  content,
2354
2269
  });
2355
2270
  totalTokens += tokens;
2271
+ this.rsRaw('middleRaw', tokens);
2356
2272
  i++;
2357
2273
  continue;
2358
2274
  }
@@ -2380,11 +2296,13 @@ export class AutobiographicalStrategy {
2380
2296
  entries.push(questionEntry);
2381
2297
  entries.push(answerEntry);
2382
2298
  totalTokens += pairTokens;
2299
+ this.rsSummary(ancestor.level, pairTokens);
2383
2300
  i++;
2384
2301
  }
2385
2302
  }
2386
2303
  // ----- 6. Emit tail entries newest-first eviction (matches existing behavior) -----
2387
- this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2304
+ const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2305
+ this.rsRaw('tail', tailStats.tokens, tailStats.messages);
2388
2306
  // ----- 7. Post-process: merge consecutive raw entries from the same bodyGroup -----
2389
2307
  // Both head and tail emission paths emit shards as separate ContextEntries.
2390
2308
  // The middle path already merges consecutive same-bodyGroup raw shards into
@@ -2392,9 +2310,62 @@ export class AutobiographicalStrategy {
2392
2310
  // a sharded message renders as ONE API message regardless of which region
2393
2311
  // it falls into (preserves KV cache through region transitions).
2394
2312
  const merged = this.mergeAdjacentBodyGroupRaw(entries, store);
2313
+ this.pruneToolEntries(merged);
2395
2314
  this.trimOrphanedToolUse(merged);
2315
+ // Strip stale images BEFORE placing markers and committing stats, so both
2316
+ // describe the post-strip context the agent actually receives.
2317
+ this.applyImageStripping(merged, store);
2318
+ // Place ≤4 cache breakpoints across the FINAL ordered entries so the
2319
+ // provider can reuse the stable folded prefix — not just the head. With a
2320
+ // single head marker the cache hit is ~2%; well-placed breakpoints take the
2321
+ // real strategy to ~50% (docs/kv-stable-context-control.md — marker
2322
+ // placement is the dominant KV lever).
2323
+ this.placeCacheMarkers(merged, headMessageIds, tailMessageIds);
2324
+ this.rsEnd();
2396
2325
  return merged;
2397
2326
  }
2327
+ /**
2328
+ * Place up to four `cache_control` breakpoints across the final ordered
2329
+ * entries: the head/system boundary, the end of the folded history (the
2330
+ * stable prefix that persists turn-to-turn — the most valuable), a mid-history
2331
+ * seam, and the very end (for pure-append reuse). Mirrors `placeMarkers` in
2332
+ * the adaptive layer but operates on emitted entries. Idempotent; clears any
2333
+ * pre-existing markers first.
2334
+ */
2335
+ placeCacheMarkers(entries, headMessageIds, tailMessageIds) {
2336
+ for (const e of entries)
2337
+ if (e.cacheMarker)
2338
+ e.cacheMarker = false;
2339
+ const n = entries.length;
2340
+ if (n === 0)
2341
+ return;
2342
+ let lastHead = -1;
2343
+ let firstTail = n;
2344
+ for (let i = 0; i < n; i++) {
2345
+ const sid = entries[i].sourceMessageId;
2346
+ if (sid && headMessageIds.has(sid))
2347
+ lastHead = i;
2348
+ }
2349
+ for (let i = 0; i < n; i++) {
2350
+ const sid = entries[i].sourceMessageId;
2351
+ if (sid && tailMessageIds.has(sid)) {
2352
+ firstTail = i;
2353
+ break;
2354
+ }
2355
+ }
2356
+ const historyEnd = firstTail - 1; // last middle (folded-history) entry
2357
+ const marks = new Set();
2358
+ if (lastHead >= 0)
2359
+ marks.add(lastHead); // system / head block
2360
+ if (historyEnd > lastHead)
2361
+ marks.add(historyEnd); // stable folded prefix (the big one)
2362
+ if (historyEnd - lastHead > 2)
2363
+ marks.add(lastHead + Math.floor((historyEnd - lastHead) / 2)); // mid-history
2364
+ marks.add(n - 1); // end → pure-append reuse
2365
+ for (const idx of marks)
2366
+ if (idx >= 0 && idx < n)
2367
+ entries[idx].cacheMarker = true;
2368
+ }
2398
2369
  /**
2399
2370
  * Walk an entries array; for every run of consecutive entries that
2400
2371
  * (a) have sourceRelation: 'copy' (raw, not a synthesized recall pair)
@@ -2483,6 +2454,20 @@ export class AutobiographicalStrategy {
2483
2454
  this._adaptivePicker = new Picker(strategy);
2484
2455
  return this._adaptivePicker;
2485
2456
  }
2457
+ /**
2458
+ * Build the picker for this compile. Instance folding strategies (kv-stable)
2459
+ * need the per-compile `PickerInputs` at construction, so they're built fresh
2460
+ * here; stateless ones (flat-profile / oldest-first) reuse the memoized picker.
2461
+ */
2462
+ buildPicker(inputs) {
2463
+ if (this.config.foldingStrategy === 'kv-stable') {
2464
+ return new Picker(new KvStableStrategy(inputs, {
2465
+ reachTokens: this.config.kvStableReachTokens,
2466
+ mergeThreshold: this.config.mergeThreshold,
2467
+ }));
2468
+ }
2469
+ return this.getAdaptivePicker();
2470
+ }
2486
2471
  /**
2487
2472
  * Walk the summary tree to find the L_k ancestor of a message.
2488
2473
  * Returns null if no ancestor exists at that level (e.g., L_k not yet produced).
@@ -2556,6 +2541,7 @@ export class AutobiographicalStrategy {
2556
2541
  * Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
2557
2542
  */
2558
2543
  selectHierarchical(store, budget) {
2544
+ this.rsBegin();
2559
2545
  const entries = [];
2560
2546
  const maxTokens = budget.maxTokens - budget.reserveForResponse;
2561
2547
  const messages = store.getAll();
@@ -2578,6 +2564,7 @@ export class AutobiographicalStrategy {
2578
2564
  content,
2579
2565
  });
2580
2566
  totalTokens += tokens;
2567
+ this.rsRaw('head', tokens);
2581
2568
  }
2582
2569
  // Mark the last head entry as a cache boundary (even if budget truncated the window)
2583
2570
  if (entries.length > 0) {
@@ -2727,6 +2714,7 @@ export class AutobiographicalStrategy {
2727
2714
  entries.push(questionEntry);
2728
2715
  entries.push(answerEntry);
2729
2716
  totalTokens += pairTokens;
2717
+ this.rsSummary(summary.level, pairTokens);
2730
2718
  }
2731
2719
  else {
2732
2720
  const msg = item.msg;
@@ -2744,6 +2732,7 @@ export class AutobiographicalStrategy {
2744
2732
  content,
2745
2733
  });
2746
2734
  totalTokens += tokens;
2735
+ this.rsRaw('middleRaw', tokens);
2747
2736
  }
2748
2737
  }
2749
2738
  }
@@ -2775,6 +2764,8 @@ export class AutobiographicalStrategy {
2775
2764
  entries.push(questionEntry);
2776
2765
  entries.push(answerEntry);
2777
2766
  totalTokens += pairTokens;
2767
+ for (const s of selectedSummaries)
2768
+ this.rsSummary(s.level, s.tokens);
2778
2769
  }
2779
2770
  // Sort by position so uncompressed-middle messages and pins both
2780
2771
  // appear in their chronological place after the combined recall pair.
@@ -2794,6 +2785,7 @@ export class AutobiographicalStrategy {
2794
2785
  content,
2795
2786
  });
2796
2787
  totalTokens += tokens;
2788
+ this.rsRaw('middleRaw', tokens);
2797
2789
  }
2798
2790
  }
2799
2791
  }
@@ -2802,9 +2794,14 @@ export class AutobiographicalStrategy {
2802
2794
  // budget, the latest messages (the ones the agent actually needs to act
2803
2795
  // on) are preserved and the oldest recent-window messages are dropped.
2804
2796
  const effectiveRecentStart = Math.max(recentStart, headEnd);
2805
- this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2797
+ const tailStats = this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2798
+ this.rsRaw('tail', tailStats.tokens, tailStats.messages);
2806
2799
  this.trimOrphanedToolUse(entries);
2807
2800
  this.pruneToolEntries(entries);
2801
+ // Strip stale images before committing stats so RenderStats.total reflects
2802
+ // the post-strip context (this path places no cache markers).
2803
+ this.applyImageStripping(entries, store);
2804
+ this.rsEnd();
2808
2805
  return entries;
2809
2806
  }
2810
2807
  // ============================================================================
@@ -3025,6 +3022,7 @@ export class AutobiographicalStrategy {
3025
3022
  },
3026
3023
  };
3027
3024
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
3025
+ // Text-only on purpose: summarizer scratch thinking is not agent history
3028
3026
  return response.content
3029
3027
  .filter((b) => b.type === 'text')
3030
3028
  .map(b => b.text)
@@ -3156,7 +3154,6 @@ export class AutobiographicalStrategy {
3156
3154
  const existing = existingCompressed.get(key);
3157
3155
  if (existing) {
3158
3156
  chunk.compressed = true;
3159
- chunk.diary = existing.diary;
3160
3157
  chunk.summaryId = existing.summaryId;
3161
3158
  }
3162
3159
  // In hierarchical mode, also check if a summary exists for this chunk
@@ -3172,6 +3169,97 @@ export class AutobiographicalStrategy {
3172
3169
  chunkKey(chunk) {
3173
3170
  return chunk.messages.map((m) => m.id).join(':');
3174
3171
  }
3172
+ /** True if any content block is a live image. */
3173
+ hasImageBlock(content) {
3174
+ return content.some((b) => b.type === 'image');
3175
+ }
3176
+ /** Message index marking the image-strip depth boundary: walks newest→oldest
3177
+ * summing the same per-message estimate as getRecentWindowStart, and returns
3178
+ * the index of the first message still within `depthTokens`. Messages before
3179
+ * this index have their images stripped to placeholders. */
3180
+ getImageStripStart(store, depthTokens) {
3181
+ const messages = store.getAll();
3182
+ let tokens = 0;
3183
+ for (let i = messages.length - 1; i >= 0; i--) {
3184
+ tokens += store.estimateTokens(messages[i]);
3185
+ if (tokens > depthTokens)
3186
+ return i + 1;
3187
+ }
3188
+ return 0;
3189
+ }
3190
+ /** Text substituted for an image block once it leaves the live-image window. */
3191
+ static IMAGE_PLACEHOLDER = '[image dropped from live context]';
3192
+ /** Post-pass over compiled entries: replace image blocks with a text
3193
+ * placeholder once they fall outside the live-image window — either deeper
3194
+ * than `imageStripDepthTokens` from the newest message, or beyond the
3195
+ * `maxLiveImages` most-recent images (counted newest-first). Summaries are
3196
+ * already text, so they're naturally unaffected. The adjacent
3197
+ * "[image attachment: <name>]" text added at ingest preserves the filename,
3198
+ * so the placeholder itself stays terse. Reduces tokens, so it never pushes
3199
+ * a compiled context back over budget.
3200
+ *
3201
+ * Runs INSIDE each select path, *before* `rsEnd()` and `placeCacheMarkers`,
3202
+ * so the committed render stats (and the cache breakpoints) describe the
3203
+ * post-strip context. As it strips, it decrements the matching raw bucket of
3204
+ * the in-progress render stats by the reclaimed tokens, keeping
3205
+ * `RenderStats.total` equal to the real rendered size. */
3206
+ applyImageStripping(entries, store) {
3207
+ const maxLive = this.config.maxLiveImages ?? 0; // 0 = unlimited count
3208
+ const depthTokens = this.config.imageStripDepthTokens ?? 0; // 0 = no depth strip
3209
+ if (maxLive === 0 && depthTokens === 0)
3210
+ return; // policy disabled
3211
+ const messages = store.getAll();
3212
+ const posById = new Map();
3213
+ for (let i = 0; i < messages.length; i++)
3214
+ posById.set(messages[i].id, i);
3215
+ const stripStart = depthTokens > 0 ? this.getImageStripStart(store, depthTokens) : 0;
3216
+ // Same region windows select() bucketed by, so a stripped image's reclaimed
3217
+ // tokens come back out of the bucket it was originally tallied into.
3218
+ const headStart = this.getHeadWindowStartIndex(store);
3219
+ const headEnd = this.getHeadWindowEnd(store);
3220
+ const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
3221
+ const bucketAt = (pos) => {
3222
+ if (pos < 0)
3223
+ return 'middleRaw'; // no resolvable region — keep total == Σbuckets
3224
+ if (pos >= headStart && pos < headEnd)
3225
+ return 'head';
3226
+ if (pos >= recentStart)
3227
+ return 'tail';
3228
+ return 'middleRaw';
3229
+ };
3230
+ const placeholderTokens = Math.ceil(AutobiographicalStrategy.IMAGE_PLACEHOLDER.length / 4);
3231
+ // Image-bearing entries, newest-first by source position. Entries with no
3232
+ // resolvable source position sort last (pos -1) and never count as "live".
3233
+ const ordered = entries
3234
+ .map((entry, idx) => ({
3235
+ idx,
3236
+ pos: entry.sourceMessageId !== undefined ? posById.get(entry.sourceMessageId) ?? -1 : -1,
3237
+ }))
3238
+ .filter(({ idx }) => this.hasImageBlock(entries[idx].content))
3239
+ .sort((a, b) => b.pos - a.pos);
3240
+ let keptImages = 0;
3241
+ for (const { idx, pos } of ordered) {
3242
+ const entry = entries[idx];
3243
+ const tooDeep = depthTokens > 0 && (pos < 0 || pos < stripStart);
3244
+ const bucket = bucketAt(pos);
3245
+ entry.content = entry.content.map((block) => {
3246
+ if (block.type !== 'image')
3247
+ return block;
3248
+ const overCount = maxLive > 0 && keptImages >= maxLive;
3249
+ if (tooDeep || overCount) {
3250
+ // The renderer estimates an image at `tokenEstimate ?? 1600` (see
3251
+ // message-store/context-log). Hand that back to the bucket, less the
3252
+ // placeholder text that replaces it, so the stats match the output.
3253
+ const reclaimed = (block.tokenEstimate ?? 1600) - placeholderTokens;
3254
+ if (this._rs && reclaimed > 0)
3255
+ this._rs[bucket].tokens -= reclaimed;
3256
+ return { type: 'text', text: AutobiographicalStrategy.IMAGE_PLACEHOLDER };
3257
+ }
3258
+ keptImages++;
3259
+ return block;
3260
+ });
3261
+ }
3262
+ }
3175
3263
  getRecentWindowStart(store) {
3176
3264
  const messages = store.getAll();
3177
3265
  let tokens = 0;