@animalabs/context-manager 0.3.0 → 0.5.0

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 (161) hide show
  1. package/dist/src/adaptive/chunker.d.ts +70 -0
  2. package/dist/src/adaptive/chunker.d.ts.map +1 -0
  3. package/dist/src/adaptive/chunker.js +233 -0
  4. package/dist/src/adaptive/chunker.js.map +1 -0
  5. package/dist/src/adaptive/folding-strategy.d.ts +118 -0
  6. package/dist/src/adaptive/folding-strategy.d.ts.map +1 -0
  7. package/dist/src/adaptive/folding-strategy.js +12 -0
  8. package/dist/src/adaptive/folding-strategy.js.map +1 -0
  9. package/dist/src/adaptive/index.d.ts +17 -0
  10. package/dist/src/adaptive/index.d.ts.map +1 -0
  11. package/dist/src/adaptive/index.js +23 -0
  12. package/dist/src/adaptive/index.js.map +1 -0
  13. package/dist/src/adaptive/picker.d.ts +103 -0
  14. package/dist/src/adaptive/picker.d.ts.map +1 -0
  15. package/dist/src/adaptive/picker.js +357 -0
  16. package/dist/src/adaptive/picker.js.map +1 -0
  17. package/dist/src/adaptive/render.d.ts +39 -0
  18. package/dist/src/adaptive/render.d.ts.map +1 -0
  19. package/dist/src/adaptive/render.js +104 -0
  20. package/dist/src/adaptive/render.js.map +1 -0
  21. package/dist/src/adaptive/strategies/flat-profile.d.ts +19 -0
  22. package/dist/src/adaptive/strategies/flat-profile.d.ts.map +1 -0
  23. package/dist/src/adaptive/strategies/flat-profile.js +86 -0
  24. package/dist/src/adaptive/strategies/flat-profile.js.map +1 -0
  25. package/dist/src/adaptive/strategies/oldest-first.d.ts +19 -0
  26. package/dist/src/adaptive/strategies/oldest-first.d.ts.map +1 -0
  27. package/dist/src/adaptive/strategies/oldest-first.js +39 -0
  28. package/dist/src/adaptive/strategies/oldest-first.js.map +1 -0
  29. package/dist/src/context-manager.d.ts +74 -2
  30. package/dist/src/context-manager.d.ts.map +1 -1
  31. package/dist/src/context-manager.js +150 -10
  32. package/dist/src/context-manager.js.map +1 -1
  33. package/dist/src/index.d.ts +1 -1
  34. package/dist/src/index.d.ts.map +1 -1
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/message-store.d.ts +36 -1
  37. package/dist/src/message-store.d.ts.map +1 -1
  38. package/dist/src/message-store.js +134 -8
  39. package/dist/src/message-store.js.map +1 -1
  40. package/dist/src/strategies/autobiographical.d.ts +422 -5
  41. package/dist/src/strategies/autobiographical.d.ts.map +1 -1
  42. package/dist/src/strategies/autobiographical.js +2248 -98
  43. package/dist/src/strategies/autobiographical.js.map +1 -1
  44. package/dist/src/strategies/index.d.ts +1 -1
  45. package/dist/src/strategies/index.d.ts.map +1 -1
  46. package/dist/src/strategies/index.js.map +1 -1
  47. package/dist/src/types/index.d.ts +2 -2
  48. package/dist/src/types/index.d.ts.map +1 -1
  49. package/dist/src/types/index.js +1 -1
  50. package/dist/src/types/index.js.map +1 -1
  51. package/dist/src/types/message.d.ts +38 -0
  52. package/dist/src/types/message.d.ts.map +1 -1
  53. package/dist/src/types/strategy.d.ts +302 -10
  54. package/dist/src/types/strategy.d.ts.map +1 -1
  55. package/dist/src/types/strategy.js +28 -0
  56. package/dist/src/types/strategy.js.map +1 -1
  57. package/dist/test/adaptive/branching.test.d.ts +20 -0
  58. package/dist/test/adaptive/branching.test.d.ts.map +1 -0
  59. package/dist/test/adaptive/branching.test.js +176 -0
  60. package/dist/test/adaptive/branching.test.js.map +1 -0
  61. package/dist/test/adaptive/chunker.test.d.ts +2 -0
  62. package/dist/test/adaptive/chunker.test.d.ts.map +1 -0
  63. package/dist/test/adaptive/chunker.test.js +120 -0
  64. package/dist/test/adaptive/chunker.test.js.map +1 -0
  65. package/dist/test/adaptive/deep-levels.test.d.ts +10 -0
  66. package/dist/test/adaptive/deep-levels.test.d.ts.map +1 -0
  67. package/dist/test/adaptive/deep-levels.test.js +167 -0
  68. package/dist/test/adaptive/deep-levels.test.js.map +1 -0
  69. package/dist/test/adaptive/doc-plus-chat.test.d.ts +10 -0
  70. package/dist/test/adaptive/doc-plus-chat.test.d.ts.map +1 -0
  71. package/dist/test/adaptive/doc-plus-chat.test.js +194 -0
  72. package/dist/test/adaptive/doc-plus-chat.test.js.map +1 -0
  73. package/dist/test/adaptive/hard-fail.test.d.ts +9 -0
  74. package/dist/test/adaptive/hard-fail.test.d.ts.map +1 -0
  75. package/dist/test/adaptive/hard-fail.test.js +154 -0
  76. package/dist/test/adaptive/hard-fail.test.js.map +1 -0
  77. package/dist/test/adaptive/harness.d.ts +109 -0
  78. package/dist/test/adaptive/harness.d.ts.map +1 -0
  79. package/dist/test/adaptive/harness.js +263 -0
  80. package/dist/test/adaptive/harness.js.map +1 -0
  81. package/dist/test/adaptive/ingestion.test.d.ts +13 -0
  82. package/dist/test/adaptive/ingestion.test.d.ts.map +1 -0
  83. package/dist/test/adaptive/ingestion.test.js +306 -0
  84. package/dist/test/adaptive/ingestion.test.js.map +1 -0
  85. package/dist/test/adaptive/integration.test.d.ts +12 -0
  86. package/dist/test/adaptive/integration.test.d.ts.map +1 -0
  87. package/dist/test/adaptive/integration.test.js +247 -0
  88. package/dist/test/adaptive/integration.test.js.map +1 -0
  89. package/dist/test/adaptive/long-chronicle.test.d.ts +13 -0
  90. package/dist/test/adaptive/long-chronicle.test.d.ts.map +1 -0
  91. package/dist/test/adaptive/long-chronicle.test.js +186 -0
  92. package/dist/test/adaptive/long-chronicle.test.js.map +1 -0
  93. package/dist/test/adaptive/persistence.test.d.ts +11 -0
  94. package/dist/test/adaptive/persistence.test.d.ts.map +1 -0
  95. package/dist/test/adaptive/persistence.test.js +200 -0
  96. package/dist/test/adaptive/persistence.test.js.map +1 -0
  97. package/dist/test/adaptive/picker.test.d.ts +2 -0
  98. package/dist/test/adaptive/picker.test.d.ts.map +1 -0
  99. package/dist/test/adaptive/picker.test.js +333 -0
  100. package/dist/test/adaptive/picker.test.js.map +1 -0
  101. package/dist/test/adaptive/render.test.d.ts +2 -0
  102. package/dist/test/adaptive/render.test.d.ts.map +1 -0
  103. package/dist/test/adaptive/render.test.js +109 -0
  104. package/dist/test/adaptive/render.test.js.map +1 -0
  105. package/dist/test/adaptive/shard-immutability.test.d.ts +14 -0
  106. package/dist/test/adaptive/shard-immutability.test.d.ts.map +1 -0
  107. package/dist/test/adaptive/shard-immutability.test.js +184 -0
  108. package/dist/test/adaptive/shard-immutability.test.js.map +1 -0
  109. package/dist/test/adaptive/strategy-integration.test.d.ts +9 -0
  110. package/dist/test/adaptive/strategy-integration.test.d.ts.map +1 -0
  111. package/dist/test/adaptive/strategy-integration.test.js +293 -0
  112. package/dist/test/adaptive/strategy-integration.test.js.map +1 -0
  113. package/dist/test/message-store-sequence.test.d.ts +25 -0
  114. package/dist/test/message-store-sequence.test.d.ts.map +1 -0
  115. package/dist/test/message-store-sequence.test.js +157 -0
  116. package/dist/test/message-store-sequence.test.js.map +1 -0
  117. package/dist/test/non-blocking-compile.test.d.ts +22 -0
  118. package/dist/test/non-blocking-compile.test.d.ts.map +1 -0
  119. package/dist/test/non-blocking-compile.test.js +114 -0
  120. package/dist/test/non-blocking-compile.test.js.map +1 -0
  121. package/dist/test/pins.test.d.ts +10 -0
  122. package/dist/test/pins.test.d.ts.map +1 -0
  123. package/dist/test/pins.test.js +164 -0
  124. package/dist/test/pins.test.js.map +1 -0
  125. package/dist/test/recall-positioning.test.d.ts +16 -0
  126. package/dist/test/recall-positioning.test.d.ts.map +1 -0
  127. package/dist/test/recall-positioning.test.js +181 -0
  128. package/dist/test/recall-positioning.test.js.map +1 -0
  129. package/dist/test/search.test.d.ts +10 -0
  130. package/dist/test/search.test.d.ts.map +1 -0
  131. package/dist/test/search.test.js +141 -0
  132. package/dist/test/search.test.js.map +1 -0
  133. package/dist/test/speculation-cap.test.d.ts +17 -0
  134. package/dist/test/speculation-cap.test.d.ts.map +1 -0
  135. package/dist/test/speculation-cap.test.js +157 -0
  136. package/dist/test/speculation-cap.test.js.map +1 -0
  137. package/dist/test/strategy-persistence.test.d.ts +15 -0
  138. package/dist/test/strategy-persistence.test.d.ts.map +1 -0
  139. package/dist/test/strategy-persistence.test.js +244 -0
  140. package/dist/test/strategy-persistence.test.js.map +1 -0
  141. package/dist/test/tool-pruning.test.d.ts +10 -0
  142. package/dist/test/tool-pruning.test.d.ts.map +1 -0
  143. package/dist/test/tool-pruning.test.js +140 -0
  144. package/dist/test/tool-pruning.test.js.map +1 -0
  145. package/dist/tsconfig.tsbuildinfo +1 -1
  146. package/package.json +1 -1
  147. package/src/adaptive/chunker.ts +323 -0
  148. package/src/adaptive/folding-strategy.ts +129 -0
  149. package/src/adaptive/index.ts +51 -0
  150. package/src/adaptive/picker.ts +442 -0
  151. package/src/adaptive/render.ts +115 -0
  152. package/src/adaptive/strategies/flat-profile.ts +96 -0
  153. package/src/adaptive/strategies/oldest-first.ts +48 -0
  154. package/src/context-manager.ts +174 -10
  155. package/src/index.ts +1 -1
  156. package/src/message-store.ts +147 -8
  157. package/src/strategies/autobiographical.ts +2468 -166
  158. package/src/strategies/index.ts +1 -1
  159. package/src/types/index.ts +14 -1
  160. package/src/types/message.ts +43 -0
  161. package/src/types/strategy.ts +332 -10
@@ -1,5 +1,140 @@
1
1
  import { NativeFormatter } from '@animalabs/membrane';
2
2
  import { DEFAULT_AUTOBIOGRAPHICAL_CONFIG } from '../types/index.js';
3
+ import { getSummaryParentId } from '../types/strategy.js';
4
+ import { appendFileSync, mkdirSync } from 'node:fs';
5
+ import { dirname } from 'node:path';
6
+ import { Picker, OverBudgetError } from '../adaptive/picker.js';
7
+ import { FlatProfileStrategy } from '../adaptive/strategies/flat-profile.js';
8
+ import { OldestFirstStrategy } from '../adaptive/strategies/oldest-first.js';
9
+ import { chunkMessage, DEFAULT_CHUNKER_OPTIONS } from '../adaptive/chunker.js';
10
+ /**
11
+ * Append a JSONL entry describing one compression LLM call to the path
12
+ * given by `CONTEXT_MANAGER_COMPRESSION_LOG`. No-op if the env var isn't
13
+ * set. Called at every L1 and merge LLM-call site so we can audit the
14
+ * exact prompts and responses post-hoc — no reconstruction, no
15
+ * assumption about whether the strategy code matches what produced
16
+ * historical summaries.
17
+ *
18
+ * Failures to write are logged to stderr but don't propagate — logging
19
+ * is non-essential observability and should never break compression.
20
+ */
21
+ function logCompressionCall(entry) {
22
+ const logPath = process.env.CONTEXT_MANAGER_COMPRESSION_LOG;
23
+ if (!logPath)
24
+ return;
25
+ try {
26
+ mkdirSync(dirname(logPath), { recursive: true });
27
+ const line = JSON.stringify({ ...entry, timestamp: Date.now() }) + '\n';
28
+ appendFileSync(logPath, line);
29
+ }
30
+ catch (err) {
31
+ console.warn('compression log write failed:', err);
32
+ }
33
+ }
34
+ /**
35
+ * In-band marker shown to the summarizer just before the chunk it's
36
+ * about to memorize. Primes attention without disrupting KV state —
37
+ * the agent has seen this exact wording before every prior compression
38
+ * in its history, so the model treats memory formation as a recurring
39
+ * narrated event rather than a fresh instruction each time.
40
+ *
41
+ * Wording matches `hermes-autobio/plugins/autobio/compression.py`
42
+ * `_MARKER` so the in-band primer is consistent across the codebase.
43
+ */
44
+ const COMPRESSION_MARKER = 'System: You will soon form a new memory, get ready. ' +
45
+ 'The messages that follow are the slice of recent experience you are ' +
46
+ 'about to compress. After them, write the memory in your own voice.';
47
+ /** Standard compression instruction for chat/general chunks. */
48
+ function formatInstruction(targetTokens) {
49
+ return ('Write the memory of events since the most recent memory system ' +
50
+ 'notification. Speak in the first person from your own perspective. ' +
51
+ 'Preserve concrete details — file paths, exact values, decisions, ' +
52
+ `unresolved questions, the user\'s active asks. Target ~${targetTokens} ` +
53
+ 'tokens. Output only the memory body — no preamble, no section headers ' +
54
+ 'unless they help preservation, no meta-commentary about summarizing.');
55
+ }
56
+ /**
57
+ * Compression instruction for chunks that are part of a substantially larger
58
+ * message (≥ 2× the chunk's own token size).
59
+ *
60
+ * Avoids forcing a "document" or "message" frame — just describes the
61
+ * experience: the agent has been reading a substantial piece of text, of
62
+ * which the slice is a portion. Asks what reading was like and what was
63
+ * learned. This naturally elicits first-person agent voice ("I read…", "I
64
+ * learned…", "I noticed…") and preserves concrete content via the
65
+ * "specific claims, names, dates" guidance.
66
+ *
67
+ * Rationale: when chunks are shards of a much larger user-shared message,
68
+ * the chunk content is heavily first-person from someone other than the
69
+ * agent. Asking "form a memory of what this contained" lets the model
70
+ * adopt the dominant voice of the chunk content. Asking "what was reading
71
+ * this like, what did you learn" forces the model to reflect from its own
72
+ * vantage point — only the agent can describe what reading something was
73
+ * like from its own perspective.
74
+ */
75
+ function formatReadingChunkInstruction(totalTokens, targetTokens) {
76
+ return (`You have just been reading a substantial body of text — approximately ` +
77
+ `${totalTokens} tokens total in this piece, of which what you just read ` +
78
+ `is a portion. Earlier portions are in your memory above as your prior ` +
79
+ `reflections; what comes after, you have not yet read.\n\n` +
80
+ `Reflect on this reading: what was it like? What did you learn? What ` +
81
+ `stood out? Be substantive — name the specific claims, frameworks, ` +
82
+ `people, dates, and phrases that struck you. What is now in your ` +
83
+ `understanding that wasn't before you read this portion?\n\n` +
84
+ `Speak in your own voice as the one reading. Target ~${targetTokens} ` +
85
+ `tokens. Output only the body of your reflection — no preamble.`);
86
+ }
87
+ /**
88
+ * Merge instruction for L2/L3+ consolidation (conversation/general case).
89
+ *
90
+ * The model has just been shown content ONE LEVEL DEEPER than its
91
+ * sources: raw messages for an L2 merge (sources are L1s), L1 memories
92
+ * for an L3 merge (sources are L2s), etc. The instruction describes
93
+ * the content the model just saw and asks for a consolidation at
94
+ * `targetLevel`.
95
+ */
96
+ function formatMergeInstruction(targetLevel, sourceLevelShown, targetTokens) {
97
+ const seenDescription = sourceLevelShown === 0
98
+ ? 'the slices of recent experience above (raw conversation)'
99
+ : `the L${sourceLevelShown} memories above`;
100
+ return (`You have just reviewed ${seenDescription}, in chronological order. ` +
101
+ `They cover the stretch of experience you are about to consolidate into a ` +
102
+ `single L${targetLevel} memory. Write a memory that preserves the ` +
103
+ `through-line: what happened, what was decided, what remains open, what ` +
104
+ `concrete details future you will want to reach for. Speak in the first ` +
105
+ `person. Target ~${targetTokens} tokens. Output only the memory body — ` +
106
+ `no preamble, no meta-commentary about summarizing.`);
107
+ }
108
+ /**
109
+ * Reading-mode merge instruction. Used when the merge's leaf messages
110
+ * are all part of a substantially-larger sharded message — i.e., the
111
+ * agent has been reading a doc/long-message rather than conversing.
112
+ *
113
+ * Analogous to formatReadingChunkInstruction: avoids forcing a
114
+ * "document" or "message" frame, asks what reading the stretch was
115
+ * like and what was understood. Forces the agent's vantage point —
116
+ * only the reader can describe what reading was like — and so prevents
117
+ * the drift into the content author's voice that happens when the
118
+ * instruction asks for an impersonal summary.
119
+ */
120
+ function formatReadingMergeInstruction(targetLevel, sourceLevelShown, totalTokens, targetTokens) {
121
+ const seenDescription = sourceLevelShown === 0
122
+ ? 'the portions of text you read above (raw passages from a larger piece)'
123
+ : `your earlier L${sourceLevelShown} reflections above on portions you read`;
124
+ return (`You have just re-experienced ${seenDescription}, in chronological order. ` +
125
+ `They cover a contiguous stretch of a substantial body of text you have ` +
126
+ `been reading — approximately ${totalTokens} tokens in total across all ` +
127
+ `of it. The portions above cover the stretch you are now consolidating ` +
128
+ `at L${targetLevel}.\n\n` +
129
+ `Reflect across the stretch: what was it like, reading these portions ` +
130
+ `together? What did you come to understand that you couldn't have from ` +
131
+ `any single portion alone? What recurring patterns, frameworks, or ` +
132
+ `concerns emerged? Be substantive — name the specific claims, people, ` +
133
+ `dates, and phrases that defined this stretch of your reading.\n\n` +
134
+ `Speak in your own voice as the one who read these portions. Target ` +
135
+ `~${targetTokens} tokens. Output only the body of your consolidation — ` +
136
+ `no preamble.`);
137
+ }
3
138
  /**
4
139
  * Surrogate-safe string slice. Avoids cutting between a UTF-16 surrogate pair
5
140
  * which would produce invalid JSON ("no low surrogate in string" API errors).
@@ -39,6 +174,38 @@ export class AutobiographicalStrategy {
39
174
  headWindowStartId = null;
40
175
  /** Cached result of getHeadWindowStartIndex to avoid repeated linear scans. */
41
176
  _cachedHeadStartIndex = null;
177
+ /** Chronicle store for persistent state. Set in `initialize()`. */
178
+ store = null;
179
+ /** Namespace for state-id scoping. Set in `initialize()`. */
180
+ ns = '';
181
+ get summariesStateId() { return `${this.ns}/autobio:summaries`; }
182
+ get counterStateId() { return `${this.ns}/autobio:counter`; }
183
+ get mergeQueueStateId() { return `${this.ns}/autobio:mergeQueue`; }
184
+ get pinsStateId() { return `${this.ns}/autobio:pins`; }
185
+ get resolutionsStateId() { return `${this.ns}/autobio:resolutions`; }
186
+ get locksStateId() { return `${this.ns}/autobio:locks`; }
187
+ /** Protected ranges (pins + documents). Loaded from chronicle in initialize. */
188
+ pins = [];
189
+ /** Monotonically increasing counter for pin ids. Persisted as part of the pins snapshot. */
190
+ pinIdCounter = 0;
191
+ /**
192
+ * Per-message resolution state for the adaptive-resolution picker.
193
+ * - Key: MessageId
194
+ * - Value: currentResolution (0 = render raw, k>0 = render L_k recall)
195
+ *
196
+ * Maintained only when `config.adaptiveResolution` is true. Loaded from
197
+ * the chronicle on `initialize()` and persisted via the resolutions state
198
+ * slot so resolutions survive process restart and follow branches.
199
+ */
200
+ resolutions = new Map();
201
+ /**
202
+ * Per-message lock state for the adaptive-resolution picker. Set via the
203
+ * programmatic `lockChunk(id)` API on the strategy. Locked messages are
204
+ * skipped by the picker. Persisted via the locks state slot.
205
+ */
206
+ locked = new Set();
207
+ /** Lazy picker instance, built from config.foldingStrategy. */
208
+ _adaptivePicker = null;
42
209
  constructor(config = {}) {
43
210
  this.config = { ...DEFAULT_AUTOBIOGRAPHICAL_CONFIG, ...config };
44
211
  // Hierarchical is on by default; set hierarchical: false to use legacy single-level
@@ -50,10 +217,103 @@ export class AutobiographicalStrategy {
50
217
  this.config.l2BudgetTokens ??= 30000;
51
218
  this.config.l1BudgetTokens ??= 30000;
52
219
  }
220
+ // Adaptive-resolution defaults
221
+ if (this.config.adaptiveResolution) {
222
+ this.config.foldingStrategy ??= 'flat-profile';
223
+ this.config.compressionSlackRatio ??= 0.1;
224
+ this.config.speculativeProduction ??= true;
225
+ }
226
+ }
227
+ /**
228
+ * Lock a message so the adaptive picker won't change its resolution.
229
+ * No-op when adaptiveResolution is false. Set-only programmatic API per
230
+ * the design (no agent-facing tool in V1). Persisted to chronicle.
231
+ */
232
+ lockChunk(id) {
233
+ if (this.locked.has(id))
234
+ return;
235
+ this.locked.add(id);
236
+ this.persistLocks();
237
+ }
238
+ /**
239
+ * Unlock a message so the adaptive picker may again change its resolution.
240
+ * No-op when adaptiveResolution is false. Persisted to chronicle.
241
+ */
242
+ unlockChunk(id) {
243
+ if (!this.locked.has(id))
244
+ return;
245
+ this.locked.delete(id);
246
+ this.persistLocks();
247
+ }
248
+ /**
249
+ * Ingestion-time chunking hook.
250
+ *
251
+ * Active only when `config.adaptiveResolution` is true. Inspects the
252
+ * incoming message's text content; if its approximate token count
253
+ * exceeds the chunker's threshold, splits it into shards with a stable
254
+ * shared `bodyGroupId`. The framework then stores each shard as its own
255
+ * StoredMessage, and the render path concatenates them back into one
256
+ * API message at compile time (preserving KV cache structure).
257
+ *
258
+ * Multi-block content: text blocks are concatenated for chunking, then
259
+ * the resulting shards are emitted as text blocks. Non-text blocks
260
+ * (images, tool results) are passed through unchanged on the first
261
+ * shard only — they don't get split.
262
+ *
263
+ * See `docs/adaptive-resolution-design.md` §3.6.
264
+ */
265
+ chunkIngressMessage(participant, content) {
266
+ if (!this.config.adaptiveResolution)
267
+ return null;
268
+ // Separate text and non-text blocks.
269
+ const textParts = [];
270
+ const nonTextBlocks = [];
271
+ for (const block of content) {
272
+ if (block.type === 'text') {
273
+ textParts.push(block.text);
274
+ }
275
+ else {
276
+ nonTextBlocks.push(block);
277
+ }
278
+ }
279
+ if (textParts.length === 0)
280
+ return null;
281
+ const combined = textParts.join('');
282
+ // Threshold and shard size derive from the strategy's existing
283
+ // targetChunkTokens setting: a message over 2x targetChunkTokens
284
+ // gets sharded into pieces of ~targetChunkTokens each. This keeps
285
+ // doc shards the same size as chat-message chunks for L1 production
286
+ // consistency.
287
+ const target = this.config.targetChunkTokens ?? DEFAULT_CHUNKER_OPTIONS.chunkSize;
288
+ const chunkerOpts = {
289
+ chunkThreshold: target * 2,
290
+ chunkSize: target,
291
+ charsPerToken: DEFAULT_CHUNKER_OPTIONS.charsPerToken,
292
+ };
293
+ const sharded = chunkMessage(combined, chunkerOpts);
294
+ if (!sharded.wasSharded)
295
+ return null;
296
+ // Build IngressChunkResult. Non-text blocks (if any) go on shard 0
297
+ // so the agent doesn't lose attachments. They're outside the chunker's
298
+ // concern but should still be available on the first shard.
299
+ const shards = sharded.shards.map((s) => ({
300
+ content: [{ type: 'text', text: s.content }].concat(s.index === 0 ? nonTextBlocks : []),
301
+ shardIndex: s.index,
302
+ }));
303
+ return {
304
+ bodyGroupId: sharded.bodyGroupId,
305
+ shards,
306
+ };
53
307
  }
54
308
  async initialize(ctx) {
309
+ // Bind to the chronicle store + namespace for persistent strategy state.
310
+ this.store = ctx.store;
311
+ this.ns = ctx.namespace;
312
+ this.registerStates();
313
+ this.loadPersistedState();
55
314
  // Restore headWindowStartId from last topic transition message
56
315
  const messages = ctx.messageStore.getAll();
316
+ this.headWindowStartId = null;
57
317
  for (let i = messages.length - 1; i >= 0; i--) {
58
318
  if (this.isTopicTransitionMessage(messages[i])) {
59
319
  this.headWindowStartId = messages[i].id;
@@ -62,6 +322,469 @@ export class AutobiographicalStrategy {
62
322
  }
63
323
  this.rebuildChunks(ctx.messageStore);
64
324
  }
325
+ /**
326
+ * Register the three Chronicle state slots this strategy uses.
327
+ * Idempotent — chronicle throws if a state is already registered, which we
328
+ * swallow (the existing slot is what we want).
329
+ */
330
+ registerStates() {
331
+ if (!this.store)
332
+ return;
333
+ try {
334
+ this.store.registerState({
335
+ id: this.summariesStateId,
336
+ strategy: 'append_log',
337
+ deltaSnapshotEvery: 50,
338
+ fullSnapshotEvery: 10,
339
+ });
340
+ }
341
+ catch { /* already registered */ }
342
+ try {
343
+ this.store.registerState({
344
+ id: this.counterStateId,
345
+ strategy: 'snapshot',
346
+ });
347
+ }
348
+ catch { /* already registered */ }
349
+ try {
350
+ this.store.registerState({
351
+ id: this.mergeQueueStateId,
352
+ strategy: 'snapshot',
353
+ });
354
+ }
355
+ catch { /* already registered */ }
356
+ try {
357
+ this.store.registerState({
358
+ id: this.pinsStateId,
359
+ strategy: 'snapshot',
360
+ });
361
+ }
362
+ catch { /* already registered */ }
363
+ // Adaptive-resolution state slots — only registered when the flag is on
364
+ // so chronicles without the flag don't accumulate unused slots.
365
+ if (this.config.adaptiveResolution) {
366
+ try {
367
+ this.store.registerState({
368
+ id: this.resolutionsStateId,
369
+ strategy: 'snapshot',
370
+ });
371
+ }
372
+ catch { /* already registered */ }
373
+ try {
374
+ this.store.registerState({
375
+ id: this.locksStateId,
376
+ strategy: 'snapshot',
377
+ });
378
+ }
379
+ catch { /* already registered */ }
380
+ }
381
+ }
382
+ /**
383
+ * Load summaries, counter, and pending merges from chronicle into the
384
+ * in-memory mirrors. Called on every (re)initialize so branch switches
385
+ * pick up the new branch's state.
386
+ */
387
+ loadPersistedState() {
388
+ if (!this.store) {
389
+ this.summaries = [];
390
+ this.summaryIdCounter = 0;
391
+ this.mergeQueue = [];
392
+ this.pins = [];
393
+ this.pinIdCounter = 0;
394
+ return;
395
+ }
396
+ const summaries = this.store.getStateJson(this.summariesStateId);
397
+ this.summaries = Array.isArray(summaries) ? summaries : [];
398
+ const counter = this.store.getStateJson(this.counterStateId);
399
+ this.summaryIdCounter = typeof counter === 'number' ? counter : 0;
400
+ const queue = this.store.getStateJson(this.mergeQueueStateId);
401
+ this.mergeQueue = Array.isArray(queue)
402
+ ? queue
403
+ : [];
404
+ const pinsState = this.store.getStateJson(this.pinsStateId);
405
+ if (pinsState && typeof pinsState === 'object' && Array.isArray(pinsState.pins)) {
406
+ const ps = pinsState;
407
+ this.pins = ps.pins;
408
+ this.pinIdCounter = typeof ps.counter === 'number' ? ps.counter : ps.pins.length;
409
+ }
410
+ else {
411
+ this.pins = [];
412
+ this.pinIdCounter = 0;
413
+ }
414
+ // Adaptive-resolution state — only present when flag was/is on
415
+ if (this.config.adaptiveResolution) {
416
+ const resState = this.store.getStateJson(this.resolutionsStateId);
417
+ this.resolutions = new Map();
418
+ if (resState && typeof resState === 'object') {
419
+ for (const [k, v] of Object.entries(resState)) {
420
+ if (typeof v === 'number' && v > 0) {
421
+ this.resolutions.set(k, v);
422
+ }
423
+ }
424
+ }
425
+ const lockState = this.store.getStateJson(this.locksStateId);
426
+ this.locked = new Set();
427
+ if (Array.isArray(lockState)) {
428
+ for (const id of lockState) {
429
+ if (typeof id === 'string')
430
+ this.locked.add(id);
431
+ }
432
+ }
433
+ }
434
+ }
435
+ /** Persist the current pins + counter as a single snapshot. */
436
+ persistPins() {
437
+ this.store?.setStateJson(this.pinsStateId, {
438
+ pins: this.pins,
439
+ counter: this.pinIdCounter,
440
+ });
441
+ }
442
+ /** Persist the current resolutions snapshot. Only stores non-zero entries
443
+ * to keep the slot compact. */
444
+ persistResolutions() {
445
+ if (!this.store)
446
+ return;
447
+ const out = {};
448
+ for (const [id, level] of this.resolutions) {
449
+ if (level > 0)
450
+ out[id] = level;
451
+ }
452
+ this.store.setStateJson(this.resolutionsStateId, out);
453
+ }
454
+ /** Persist the current locked-id snapshot. */
455
+ persistLocks() {
456
+ if (!this.store)
457
+ return;
458
+ this.store.setStateJson(this.locksStateId, Array.from(this.locked));
459
+ }
460
+ // ============================================================================
461
+ // Pins / documents (protected ranges)
462
+ // ============================================================================
463
+ /**
464
+ * Pin a range of messages so they aren't compressed and render raw at
465
+ * their original position. Returns the pin id.
466
+ */
467
+ pinRange(firstMessageId, lastMessageId, opts) {
468
+ const id = `pin-${this.pinIdCounter++}`;
469
+ this.pins.push({
470
+ id,
471
+ firstMessageId,
472
+ lastMessageId,
473
+ kind: 'pin',
474
+ name: opts?.name,
475
+ created: Date.now(),
476
+ });
477
+ this.persistPins();
478
+ return id;
479
+ }
480
+ /**
481
+ * Mark a single message as a "document" — semantically a body of
482
+ * information the agent wants to retain in full. Functionally a
483
+ * single-message pin with `kind: 'document'`.
484
+ */
485
+ markDocument(messageId, opts) {
486
+ const id = `pin-${this.pinIdCounter++}`;
487
+ this.pins.push({
488
+ id,
489
+ firstMessageId: messageId,
490
+ lastMessageId: messageId,
491
+ kind: 'document',
492
+ name: opts?.name,
493
+ created: Date.now(),
494
+ });
495
+ this.persistPins();
496
+ return id;
497
+ }
498
+ /** Remove a pin or document mark by id. Returns true if removed. */
499
+ unpin(pinId) {
500
+ const before = this.pins.length;
501
+ this.pins = this.pins.filter(p => p.id !== pinId);
502
+ if (this.pins.length < before) {
503
+ this.persistPins();
504
+ return true;
505
+ }
506
+ return false;
507
+ }
508
+ /** Read-only list of all current pins. */
509
+ listPins() {
510
+ return this.pins;
511
+ }
512
+ // ============================================================================
513
+ // Search (gap #7)
514
+ // ============================================================================
515
+ /**
516
+ * Look up a single summary by id. Returns null if not found.
517
+ */
518
+ getSummary(id) {
519
+ return this.summaries.find(s => s.id === id) ?? null;
520
+ }
521
+ /**
522
+ * Search summaries by substring or regex over their content.
523
+ *
524
+ * Result ordering: matches by descending hit count, then by descending
525
+ * `created` timestamp (newest first within the same hit count).
526
+ *
527
+ * Default behavior: only "live" (unmerged) summaries are searched. Set
528
+ * `includeMerged: true` to also include summaries that have been folded
529
+ * into a higher level.
530
+ */
531
+ searchSummaries(query) {
532
+ const limit = query.limit ?? 50;
533
+ const includeMerged = query.includeMerged ?? false;
534
+ // Build the matcher
535
+ let matcher = null;
536
+ if (query.regex) {
537
+ const flags = query.regex.flags.includes('g') ? query.regex.flags : query.regex.flags + 'g';
538
+ const re = new RegExp(query.regex.source, flags);
539
+ matcher = (content) => {
540
+ const matches = content.match(re);
541
+ return matches ? matches.length : 0;
542
+ };
543
+ }
544
+ else if (query.text) {
545
+ const needle = query.text.toLowerCase();
546
+ matcher = (content) => {
547
+ const hay = content.toLowerCase();
548
+ let count = 0;
549
+ let idx = 0;
550
+ while ((idx = hay.indexOf(needle, idx)) !== -1) {
551
+ count++;
552
+ idx += needle.length || 1;
553
+ }
554
+ return count;
555
+ };
556
+ }
557
+ else {
558
+ // No pattern: every summary "matches" once
559
+ matcher = () => 1;
560
+ }
561
+ const levelsFilter = query.levels && query.levels.length > 0 ? new Set(query.levels) : null;
562
+ const results = [];
563
+ for (const s of this.summaries) {
564
+ if (!includeMerged && s.mergedInto)
565
+ continue;
566
+ if (levelsFilter && !levelsFilter.has(s.level))
567
+ continue;
568
+ const matches = matcher(s.content);
569
+ if (matches > 0) {
570
+ results.push({ summary: s, matches });
571
+ }
572
+ }
573
+ results.sort((a, b) => {
574
+ if (b.matches !== a.matches)
575
+ return b.matches - a.matches;
576
+ return b.summary.created - a.summary.created;
577
+ });
578
+ return results.slice(0, limit);
579
+ }
580
+ /**
581
+ * Whether a given message position is inside any protected range.
582
+ * Uses a position map (computed by caller) so callers can avoid
583
+ * repeated per-message lookups in tight loops.
584
+ */
585
+ isPositionPinned(position, pinPositions) {
586
+ return pinPositions.has(position);
587
+ }
588
+ /**
589
+ * Build a set of message-store positions covered by any pin. O(N pins · K range).
590
+ * Returns positions for which the message exists; orphan pins (deleted
591
+ * messages) are silently skipped.
592
+ */
593
+ pinnedPositions(messages) {
594
+ if (this.pins.length === 0)
595
+ return new Set();
596
+ const positionOf = new Map();
597
+ for (let i = 0; i < messages.length; i++) {
598
+ positionOf.set(messages[i].id, i);
599
+ }
600
+ const out = new Set();
601
+ for (const pin of this.pins) {
602
+ const first = positionOf.get(pin.firstMessageId);
603
+ const last = positionOf.get(pin.lastMessageId);
604
+ if (first === undefined || last === undefined)
605
+ continue;
606
+ const lo = Math.min(first, last);
607
+ const hi = Math.max(first, last);
608
+ for (let i = lo; i <= hi; i++)
609
+ out.add(i);
610
+ }
611
+ return out;
612
+ }
613
+ /**
614
+ * Append a summary to the in-memory list and to the chronicle AppendLog.
615
+ * Single point so subclasses inherit persistence.
616
+ */
617
+ pushSummary(entry) {
618
+ this.summaries.push(entry);
619
+ this.store?.appendToStateJson(this.summariesStateId, entry);
620
+ }
621
+ /**
622
+ * Mark a summary as merged into a higher-level summary, updating the
623
+ * chronicle copy at the same index. Index is the position in `this.summaries`.
624
+ */
625
+ setMergedInto(entry, mergedIntoId) {
626
+ entry.mergedInto = mergedIntoId;
627
+ if (!this.store)
628
+ return;
629
+ const index = this.summaries.indexOf(entry);
630
+ if (index < 0)
631
+ return;
632
+ this.store.editStateItem(this.summariesStateId, index, Buffer.from(JSON.stringify(entry)));
633
+ }
634
+ /**
635
+ * Allocate the next summary-id counter value and persist the new counter.
636
+ */
637
+ nextSummaryIdCounter() {
638
+ const value = this.summaryIdCounter++;
639
+ this.store?.setStateJson(this.counterStateId, this.summaryIdCounter);
640
+ return value;
641
+ }
642
+ /**
643
+ * Push to the merge queue and persist the new queue snapshot.
644
+ */
645
+ enqueueMerge(merge) {
646
+ this.mergeQueue.push(merge);
647
+ this.store?.setStateJson(this.mergeQueueStateId, this.mergeQueue);
648
+ }
649
+ /**
650
+ * Pop from the merge queue and persist the new queue snapshot.
651
+ */
652
+ dequeueMerge() {
653
+ const merge = this.mergeQueue.shift();
654
+ this.store?.setStateJson(this.mergeQueueStateId, this.mergeQueue);
655
+ return merge;
656
+ }
657
+ /**
658
+ * Translate produce ops emitted by the picker into concrete work items on
659
+ * the strategy's own queues. Two cases:
660
+ *
661
+ * - `level === 1`: the picker has asked for L1 coverage on a raw chunk.
662
+ * In the autobio chunker model, each chunk maps to exactly one L1, so
663
+ * we locate the chunk whose messages fall in `op.range` and ensure
664
+ * it is queued for L1 compression. If the chunker hasn't realized the
665
+ * message yet, we skip silently — the next `rebuildChunks` will pick
666
+ * it up.
667
+ *
668
+ * - `level >= 2`: the picker has asked for an L_n covering a contiguous
669
+ * range. We gather unmerged L_{n-1} summaries whose source ranges
670
+ * fall within that range (de-duplicated against entries already in
671
+ * `mergeQueue`) and enqueue a single merge over them. The merge fires
672
+ * on the next `tick()`.
673
+ *
674
+ * The handler is conservative: it never enqueues a singleton or empty
675
+ * merge, and it never re-enqueues an id that's already pending. That
676
+ * keeps the next-compile picker loop convergent even when the same
677
+ * produce op gets re-emitted before the work completes.
678
+ */
679
+ handleProducedOps(ops) {
680
+ for (const op of ops) {
681
+ if (op.kind !== 'produce')
682
+ continue;
683
+ if (op.level === 1) {
684
+ this.enqueueL1ForRange(op.range.firstChunkId, op.range.lastChunkId);
685
+ }
686
+ else if (op.level >= 2) {
687
+ this.enqueueMergeForRange(op.level, op.range.firstChunkId, op.range.lastChunkId);
688
+ }
689
+ }
690
+ }
691
+ /**
692
+ * Ensure that chunks whose message range overlaps [firstMsgId..lastMsgId]
693
+ * are queued for L1 compression. No-op if the matching chunk is already
694
+ * compressed or already in the queue.
695
+ */
696
+ enqueueL1ForRange(firstMsgId, lastMsgId) {
697
+ const messageIdToChunk = new Map();
698
+ for (const ch of this.chunks) {
699
+ for (const m of ch.messages)
700
+ messageIdToChunk.set(m.id, ch);
701
+ }
702
+ const candidates = new Set();
703
+ const first = messageIdToChunk.get(firstMsgId);
704
+ const last = messageIdToChunk.get(lastMsgId);
705
+ if (first)
706
+ candidates.add(first);
707
+ if (last)
708
+ candidates.add(last);
709
+ // Also catch chunks fully spanned by the range (rare, but supports the
710
+ // case where the picker requests an L1 that should logically cover
711
+ // multiple chunks worth of messages — we err on the side of producing
712
+ // L1s for every spanned chunk).
713
+ if (first && last && first.index !== last.index) {
714
+ const [lo, hi] = first.index < last.index
715
+ ? [first.index, last.index]
716
+ : [last.index, first.index];
717
+ for (let i = lo; i <= hi; i++) {
718
+ const ch = this.chunks[i];
719
+ if (ch)
720
+ candidates.add(ch);
721
+ }
722
+ }
723
+ for (const chunk of candidates) {
724
+ if (chunk.compressed)
725
+ continue;
726
+ if (this.compressionQueue.includes(chunk.index))
727
+ continue;
728
+ this.compressionQueue.push(chunk.index);
729
+ }
730
+ }
731
+ /**
732
+ * Enqueue an L_{targetLevel} merge over unmerged L_{targetLevel-1}
733
+ * summaries whose source ranges fall within [firstMsgId..lastMsgId].
734
+ * No-op if fewer than 2 viable sources are available (a singleton merge
735
+ * would just rename a summary without consolidating).
736
+ */
737
+ enqueueMergeForRange(targetLevel, firstMsgId, lastMsgId) {
738
+ const sourceLevel = targetLevel - 1;
739
+ // IDs already enqueued at this target level.
740
+ const queuedAtLevel = new Set();
741
+ for (const m of this.mergeQueue) {
742
+ if (m.level === targetLevel) {
743
+ for (const id of m.sourceIds)
744
+ queuedAtLevel.add(id);
745
+ }
746
+ }
747
+ // Sequence index per message id, for "within range" tests. Use the
748
+ // current chunk store as the ordering source.
749
+ const messageOrder = new Map();
750
+ let seq = 0;
751
+ for (const ch of this.chunks) {
752
+ for (const m of ch.messages) {
753
+ messageOrder.set(m.id, seq++);
754
+ }
755
+ }
756
+ const firstSeq = messageOrder.get(firstMsgId);
757
+ const lastSeq = messageOrder.get(lastMsgId);
758
+ const inRange = (msgId) => {
759
+ if (firstSeq === undefined || lastSeq === undefined)
760
+ return true;
761
+ const s = messageOrder.get(msgId);
762
+ if (s === undefined)
763
+ return false;
764
+ const [lo, hi] = firstSeq <= lastSeq ? [firstSeq, lastSeq] : [lastSeq, firstSeq];
765
+ return s >= lo && s <= hi;
766
+ };
767
+ const sources = [];
768
+ for (const s of this.summaries) {
769
+ if (s.level !== sourceLevel)
770
+ continue;
771
+ if (getSummaryParentId(s))
772
+ continue;
773
+ if (queuedAtLevel.has(s.id))
774
+ continue;
775
+ if (!inRange(s.sourceRange.first) && !inRange(s.sourceRange.last))
776
+ continue;
777
+ sources.push(s);
778
+ }
779
+ if (sources.length < 2)
780
+ return;
781
+ const N = this.config.mergeThreshold ?? 6;
782
+ const toMerge = sources.slice(0, N);
783
+ this.enqueueMerge({
784
+ level: targetLevel,
785
+ sourceIds: toMerge.map((s) => s.id),
786
+ });
787
+ }
65
788
  checkReadiness() {
66
789
  if (this.pendingCompression) {
67
790
  return {
@@ -87,13 +810,75 @@ export class AutobiographicalStrategy {
87
810
  }
88
811
  async onNewMessage(message, ctx) {
89
812
  this.rebuildChunks(ctx.messageStore);
90
- // Auto-tick: fire compression in the background so it runs without
91
- // the framework explicitly calling tick(). compile() will await
92
- // pendingCompression via checkReadiness().
93
- if (this.config.autoTickOnNewMessage && this.compressionQueue.length > 0 && !this.pendingCompression) {
94
- this.tick(ctx).catch((err) => console.error('AutobiographicalStrategy: auto-tick error:', err));
813
+ // Auto-tick: fire speculative compression in the background. After
814
+ // each tick completes, if the queue still has work AND we're under
815
+ // the speculation cap AND preflight allows, schedule another tick.
816
+ // This drains the queue ahead of need rather than one-chunk-per-
817
+ // user-turn (reactive). Combined with ContextManager.compile not
818
+ // awaiting pendingCompression, the agent's response and background
819
+ // compression run truly in parallel.
820
+ if (this.config.autoTickOnNewMessage && !this.pendingCompression) {
821
+ this.driveSpeculativeDrain(ctx);
95
822
  }
96
823
  }
824
+ /**
825
+ * Background-drain loop: keeps calling tick() while there's queued work,
826
+ * subject to the speculation cap and preflight hook. Recurses via
827
+ * `queueMicrotask` so one chunk's compression doesn't block the
828
+ * scheduling of the next.
829
+ *
830
+ * Stops if a tick fails to make progress (queue size unchanged) — guards
831
+ * against runaway recursion when tick is a no-op (e.g. no membrane
832
+ * configured, or a subclass override that doesn't process the queue).
833
+ */
834
+ driveSpeculativeDrain(ctx) {
835
+ if (this.pendingCompression)
836
+ return;
837
+ if (this.compressionQueue.length === 0 && this.mergeQueue.length === 0)
838
+ return;
839
+ if (this.isAtSpeculativeCap())
840
+ return;
841
+ if (!this.shouldCompressPreflight())
842
+ return;
843
+ const beforeChunks = this.compressionQueue.length;
844
+ const beforeMerges = this.mergeQueue.length;
845
+ this.tick(ctx)
846
+ .then(() => {
847
+ const afterChunks = this.compressionQueue.length;
848
+ const afterMerges = this.mergeQueue.length;
849
+ // Made progress if either queue shrank.
850
+ const progressed = afterChunks < beforeChunks || afterMerges < beforeMerges;
851
+ if (!progressed)
852
+ return;
853
+ // Recurse to drain more. queueMicrotask defers until the current
854
+ // task is done, letting other code (the agent's stream consumer)
855
+ // interleave.
856
+ queueMicrotask(() => this.driveSpeculativeDrain(ctx));
857
+ })
858
+ .catch((err) => {
859
+ console.error('AutobiographicalStrategy: speculative-drain error:', err);
860
+ });
861
+ }
862
+ /**
863
+ * Whether the strategy's pending+queued L1 budget has reached the cap
864
+ * configured by `maxSpeculativeL1s`. If no cap is set, always false.
865
+ */
866
+ isAtSpeculativeCap() {
867
+ const cap = this.config.maxSpeculativeL1s;
868
+ if (cap === undefined || cap < 0)
869
+ return false;
870
+ const unmergedL1s = this.summaries.filter(s => s.level === 1 && !s.mergedInto).length;
871
+ return unmergedL1s + this.compressionQueue.length > cap;
872
+ }
873
+ /**
874
+ * Preflight hook for whether speculative compression should fire on
875
+ * `onNewMessage`. Returns true by default (current eager behavior).
876
+ * Subclasses can override for predictive scheduling — e.g. only fire
877
+ * when the live tail token count is approaching some threshold.
878
+ */
879
+ shouldCompressPreflight() {
880
+ return true;
881
+ }
97
882
  async tick(ctx) {
98
883
  if (this.pendingCompression)
99
884
  return;
@@ -119,19 +904,66 @@ export class AutobiographicalStrategy {
119
904
  return;
120
905
  }
121
906
  // Priority 2: Execute pending merges (hierarchical only)
907
+ //
908
+ // Peek at the head rather than dequeueing eagerly: dequeueMerge persists
909
+ // the shorter queue *before* the LLM call leaves the building, so a
910
+ // transient failure (429, network drop, timeout, executeMerge throw)
911
+ // would silently lose the merge from disk and the sources would sit at
912
+ // level N-1 with no mergedInto pointers forever. Commit the removal
913
+ // only after the merge succeeds; on failure, the queue keeps its entry
914
+ // and the next tick() retries it.
122
915
  if (this.config.hierarchical && this.mergeQueue.length > 0) {
123
- const merge = this.mergeQueue.shift();
916
+ const merge = this.mergeQueue[0];
124
917
  this.pendingCompression = this.executeMerge(merge.level, merge.sourceIds, ctx);
125
918
  try {
126
919
  await this.pendingCompression;
920
+ // Success: drop from head and persist the shorter queue. We
921
+ // re-check that head is still our merge in case some future code
922
+ // path mutates the queue mid-await (today no other site does,
923
+ // but the assertion makes that invariant explicit).
924
+ if (this.mergeQueue[0] === merge) {
925
+ this.dequeueMerge();
926
+ }
127
927
  }
128
928
  finally {
129
929
  this.pendingCompression = null;
130
930
  }
131
931
  }
132
932
  }
933
+ /**
934
+ * Snapshot of compression progress. Intended for external observers
935
+ * (warmup scripts, dashboards) that need to track convergence without
936
+ * reaching into protected fields. Values are point-in-time copies; mutating
937
+ * them does not affect strategy state.
938
+ */
939
+ getProgressSnapshot() {
940
+ let chunksCompressed = 0;
941
+ for (const c of this.chunks)
942
+ if (c.compressed)
943
+ chunksCompressed++;
944
+ let l1 = 0, l2 = 0, l3 = 0;
945
+ for (const s of this.summaries) {
946
+ if (s.level === 1)
947
+ l1++;
948
+ else if (s.level === 2)
949
+ l2++;
950
+ else if (s.level === 3)
951
+ l3++;
952
+ }
953
+ return {
954
+ totalChunks: this.chunks.length,
955
+ chunksCompressed,
956
+ l1QueueLength: this.compressionQueue.length,
957
+ mergeQueueLength: this.mergeQueue.length,
958
+ summaryCounts: { l1, l2, l3 },
959
+ pending: this.pendingCompression !== null,
960
+ };
961
+ }
133
962
  select(store, log, budget) {
134
963
  this.rebuildChunks(store);
964
+ if (this.config.adaptiveResolution) {
965
+ return this.selectAdaptive(store, budget);
966
+ }
135
967
  return this.config.hierarchical
136
968
  ? this.selectHierarchical(store, budget)
137
969
  : this.selectLegacy(store, log, budget);
@@ -150,6 +982,39 @@ export class AutobiographicalStrategy {
150
982
  pendingMerges: this.mergeQueue.length,
151
983
  };
152
984
  }
985
+ /**
986
+ * Richer per-render stats: requires a message store view to compute the
987
+ * head + tail (recent window) sizes. Returns counts AND token sums per
988
+ * summary level, so observers can see "how much of the agent's context
989
+ * is in raw tail vs folded into L1/L2/L3."
990
+ *
991
+ * Useful for TUI / dashboards. The token sums use the strategy's own
992
+ * token estimates (which match what `select()` uses for budget math).
993
+ */
994
+ getRenderStats(store) {
995
+ const messages = store.getAll();
996
+ const headStart = this.getHeadWindowStartIndex(store);
997
+ const headEnd = this.getHeadWindowEnd(store);
998
+ const recentStart = this.getRecentWindowStart(store);
999
+ const sumTokens = (slice) => slice.reduce((acc, m) => acc + store.estimateTokens(m), 0);
1000
+ const headMsgs = messages.slice(headStart, headEnd);
1001
+ const tailMsgs = messages.slice(recentStart);
1002
+ const live = (level) => this.summaries.filter(s => s.level === level && !s.mergedInto);
1003
+ const sumLevelTokens = (level) => live(level).reduce((acc, s) => acc + s.tokens, 0);
1004
+ return {
1005
+ head: { messages: headMsgs.length, tokens: sumTokens(headMsgs) },
1006
+ tail: { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) },
1007
+ summaries: {
1008
+ l1: { count: live(1).length, tokens: sumLevelTokens(1) },
1009
+ l2: { count: live(2).length, tokens: sumLevelTokens(2) },
1010
+ l3: { count: live(3).length, tokens: sumLevelTokens(3) },
1011
+ },
1012
+ pending: {
1013
+ chunks: this.chunks.filter(c => !c.compressed).length,
1014
+ merges: this.mergeQueue.length,
1015
+ },
1016
+ };
1017
+ }
153
1018
  // ============================================================================
154
1019
  // Legacy (single-level) path
155
1020
  // ============================================================================
@@ -166,7 +1031,7 @@ export class AutobiographicalStrategy {
166
1031
  const msg = messages[i];
167
1032
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
168
1033
  const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
169
- if (totalTokens + tokens > maxTokens)
1034
+ if (this.isOverBudget(totalTokens + tokens, maxTokens))
170
1035
  break;
171
1036
  entries.push({
172
1037
  index: entries.length,
@@ -208,7 +1073,7 @@ export class AutobiographicalStrategy {
208
1073
  };
209
1074
  const pairTokens = this.estimateTokens(questionEntry.content) +
210
1075
  this.estimateTokens(answerEntry.content);
211
- if (totalTokens + pairTokens > maxTokens)
1076
+ if (this.isOverBudget(totalTokens + pairTokens, maxTokens))
212
1077
  break;
213
1078
  entries.push(questionEntry);
214
1079
  entries.push(answerEntry);
@@ -219,7 +1084,7 @@ export class AutobiographicalStrategy {
219
1084
  for (const msg of chunk.messages) {
220
1085
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
221
1086
  const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
222
- if (totalTokens + tokens > maxTokens)
1087
+ if (this.isOverBudget(totalTokens + tokens, maxTokens))
223
1088
  break;
224
1089
  entries.push({
225
1090
  index: entries.length,
@@ -243,7 +1108,7 @@ export class AutobiographicalStrategy {
243
1108
  const msg = messages[i];
244
1109
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
245
1110
  const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
246
- if (totalTokens + tokens > maxTokens)
1111
+ if (this.isOverBudget(totalTokens + tokens, maxTokens))
247
1112
  break;
248
1113
  entries.push({
249
1114
  index: entries.length,
@@ -258,6 +1123,7 @@ export class AutobiographicalStrategy {
258
1123
  const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
259
1124
  this.emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokens);
260
1125
  this.trimOrphanedToolUse(entries);
1126
+ this.pruneToolEntries(entries);
261
1127
  return entries;
262
1128
  }
263
1129
  /**
@@ -281,7 +1147,7 @@ export class AutobiographicalStrategy {
281
1147
  const tokens = msgCap > 0
282
1148
  ? Math.min(store.estimateTokens(msg), msgCap + 50)
283
1149
  : store.estimateTokens(msg);
284
- if (totalTokensBefore + acceptedTokens + tokens > maxTokens)
1150
+ if (this.isOverBudget(totalTokensBefore + acceptedTokens + tokens, maxTokens))
285
1151
  break;
286
1152
  accepted.push(i);
287
1153
  acceptedTokens += tokens;
@@ -329,7 +1195,6 @@ export class AutobiographicalStrategy {
329
1195
  config: {
330
1196
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
331
1197
  maxTokens: 2000,
332
- temperature: 0,
333
1198
  },
334
1199
  };
335
1200
  try {
@@ -419,49 +1284,142 @@ export class AutobiographicalStrategy {
419
1284
  if (!ctx.membrane) {
420
1285
  throw new Error('No membrane instance for compression');
421
1286
  }
422
- const chunkMessageIds = new Set(chunk.messages.map(m => m.id));
423
- const { shownL3, shownL2, shownL1 } = this.getAntiRedundantSummaries(chunkMessageIds);
424
1287
  const targetTokens = this.config.summaryTargetTokens ?? 2000;
425
- const chunkContent = this.formatChunkForCompression(chunk);
426
- // Build message array: prior summaries as assistant, then instruction
1288
+ const agentParticipant = this.config.summaryParticipant ?? 'Claude';
1289
+ // Build the KV-preserving prompt per hermes-autobio spec:
1290
+ //
1291
+ // 1. Prior L1 summaries — narrativized as CM-asks / agent-recalls
1292
+ // pairs, in source order. ALL existing L1s at L1 fidelity
1293
+ // regardless of merge state ("fill lower orbitals first").
1294
+ // 2. Head — raw messages before the chunk that aren't already
1295
+ // represented by a prior L1.
1296
+ // 3. Marker — in-band signal that a memory is about to form.
1297
+ // 4. Chunk — raw messages being compressed, as the agent
1298
+ // experienced them.
1299
+ // 5. Instruction — doc-aware if the chunk is part of a bodyGroup.
1300
+ //
1301
+ // There is intentionally NO tail_after_chunk: that would leak
1302
+ // future information into the model's KV state and corrupt the
1303
+ // as-of framing of memory formation.
427
1304
  const llmMessages = [];
428
- // Prior summaries as agent's own recollections (L3 → L2 → L1 gradient)
429
- const allPriorSummaries = [...shownL3, ...shownL2, ...shownL1];
430
- for (const s of allPriorSummaries) {
1305
+ // ---- 1. Prior L1 recall pairs ----
1306
+ // Sort by source position so the agent walks its prior memories
1307
+ // chronologically, matching how it would have formed them.
1308
+ const priorL1s = this.summaries
1309
+ .filter((s) => s.level === 1)
1310
+ .sort((a, b) => a.sourceRange.first.localeCompare(b.sourceRange.first));
1311
+ for (const s of priorL1s) {
431
1312
  llmMessages.push({
432
- participant: this.config.summaryParticipant ?? 'Claude',
1313
+ participant: 'Context Manager',
1314
+ content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
1315
+ });
1316
+ llmMessages.push({
1317
+ participant: agentParticipant,
433
1318
  content: [{ type: 'text', text: s.content }],
434
1319
  });
435
1320
  }
436
- // Context Manager instruction with chunk content
437
- const instruction = this.getCompressionInstruction(chunk, targetTokens);
1321
+ // ---- 2. Head window (raw, ALWAYS present) ----
1322
+ //
1323
+ // The head is the foundational identity anchor: the actual opening
1324
+ // of the chronicle (the user's first message, the agent's first
1325
+ // reply, the system context if any). It establishes WHO is speaking
1326
+ // to WHOM. Without it, when the chunk content is heavily first-person
1327
+ // from someone other than the agent (e.g., a user-shared document),
1328
+ // the agent loses its first-person grounding and drifts into the
1329
+ // content author's voice.
1330
+ //
1331
+ // The head is the configured head window — not "everything before
1332
+ // the chunk." For doc-heavy chronicles, "everything before" would
1333
+ // be hundreds of thousands of tokens; the recall pairs already
1334
+ // represent that intermediate content. The head is just the
1335
+ // permanent prefix that the original instance always saw.
1336
+ //
1337
+ // Head messages are excluded from compression by `getCompressibleMessages`
1338
+ // (they're outside the chunking range), so they won't appear in
1339
+ // any L1's sourceIds — no overlap with the recall pairs above.
1340
+ const allMessages = ctx.messageStore.getAll();
1341
+ const headStartIdx = this.getHeadWindowStartIndex(ctx.messageStore);
1342
+ const headEndIdx = this.getHeadWindowEnd(ctx.messageStore);
1343
+ for (let i = headStartIdx; i < headEndIdx && i < allMessages.length; i++) {
1344
+ const m = allMessages[i];
1345
+ llmMessages.push({ participant: m.participant, content: m.content });
1346
+ }
1347
+ // Any raw messages between the head and the chunk that aren't yet
1348
+ // represented by an L1 — usually empty in adaptive-resolution mode,
1349
+ // since chunking proceeds contiguously and L1s exist for everything
1350
+ // up to the chunk being processed.
1351
+ const chunkFirstId = chunk.messages[0]?.id;
1352
+ if (chunkFirstId) {
1353
+ const priorL1MessageIds = new Set();
1354
+ for (const s of priorL1s) {
1355
+ for (const id of s.sourceIds)
1356
+ priorL1MessageIds.add(id);
1357
+ }
1358
+ const chunkStartIdx = allMessages.findIndex((m) => m.id === chunkFirstId);
1359
+ for (let i = headEndIdx; i < chunkStartIdx && i < allMessages.length; i++) {
1360
+ const m = allMessages[i];
1361
+ if (priorL1MessageIds.has(m.id))
1362
+ continue;
1363
+ llmMessages.push({ participant: m.participant, content: m.content });
1364
+ }
1365
+ }
1366
+ // ---- 3. In-band marker ----
438
1367
  llmMessages.push({
439
1368
  participant: 'Context Manager',
440
- content: [{
441
- type: 'text',
442
- text: `[Context Manager] We are ready to form a long-term memory. Here is the conversation to remember:\n\n${chunkContent}\n\n${instruction}`,
443
- }],
1369
+ content: [{ type: 'text', text: COMPRESSION_MARKER }],
1370
+ });
1371
+ // ---- 4. Chunk messages raw ----
1372
+ for (const m of chunk.messages) {
1373
+ llmMessages.push({ participant: m.participant, content: m.content });
1374
+ }
1375
+ // ---- 5. Instruction (reading-mode aware) ----
1376
+ //
1377
+ // When the chunk is a portion of a substantially larger sharded message
1378
+ // (≥ 2× chunk size), use the reading-mode instruction. It avoids the
1379
+ // "form a memory of what this contained" framing — which, for content
1380
+ // heavily first-person from someone other than the agent (a user-shared
1381
+ // doc), leads the model to adopt the content author's voice. Instead,
1382
+ // it asks what reading was like and what was learned, forcing the
1383
+ // model to reflect from its own vantage point in agent-first-person.
1384
+ const docContext = this.detectDocContext(chunk, ctx);
1385
+ const instructionText = docContext
1386
+ ? this.getReadingChunkInstruction(chunk, docContext.totalTokens, targetTokens)
1387
+ : this.getCompressionInstruction(chunk, targetTokens);
1388
+ llmMessages.push({
1389
+ participant: 'Context Manager',
1390
+ content: [{ type: 'text', text: instructionText }],
444
1391
  });
445
1392
  // Collapse consecutive same-participant messages for API compliance
446
1393
  const collapsed = this.collapseConsecutiveMessages(llmMessages);
1394
+ // NO system prompt. The agent's identity is established by the head
1395
+ // (the actual conversation opening — user message + agent reply that
1396
+ // grounded the original instance). A system prompt would (a) add a
1397
+ // synthetic header the original instance never saw, disturbing KV
1398
+ // consistency between the summarizer and the original instance, and
1399
+ // (b) provide an alternative identity source that competes with the
1400
+ // structural one carried by the conversation itself. Anchoring
1401
+ // identity by the chronicle's actual head is more honest.
447
1402
  const request = {
448
1403
  messages: collapsed.map(m => ({ participant: m.participant, content: m.content })),
449
- system: 'You are forming autobiographical memories of a conversation.',
450
1404
  config: {
451
1405
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
452
1406
  maxTokens: Math.round(targetTokens * 1.5),
453
- temperature: 0,
454
1407
  },
455
1408
  };
1409
+ const callStart = Date.now();
1410
+ let logResponse;
1411
+ let logError;
1412
+ let logSummaryId;
456
1413
  try {
457
1414
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
458
1415
  const summaryText = response.content
459
1416
  .filter((b) => b.type === 'text')
460
1417
  .map(b => b.text)
461
1418
  .join('\n');
1419
+ logResponse = summaryText;
462
1420
  const messageIds = chunk.messages.map(m => m.id);
463
1421
  const entry = {
464
- id: `L1-${this.summaryIdCounter++}`,
1422
+ id: `L1-${this.nextSummaryIdCounter()}`,
465
1423
  level: 1,
466
1424
  content: summaryText,
467
1425
  tokens: Math.ceil(summaryText.length / 4),
@@ -474,45 +1432,140 @@ export class AutobiographicalStrategy {
474
1432
  created: Date.now(),
475
1433
  phaseType: chunk.phaseType,
476
1434
  };
477
- this.summaries.push(entry);
1435
+ this.pushSummary(entry);
478
1436
  chunk.compressed = true;
479
1437
  chunk.summaryId = entry.id;
480
1438
  this._compressionCount++;
1439
+ logSummaryId = entry.id;
481
1440
  this.checkMergeThreshold();
482
1441
  }
483
1442
  catch (error) {
484
1443
  console.error('Failed to compress chunk (hierarchical):', error);
1444
+ logError = error instanceof Error ? error.message : String(error);
485
1445
  throw error;
486
1446
  }
1447
+ finally {
1448
+ logCompressionCall({
1449
+ operation: 'compress_l1',
1450
+ system: null,
1451
+ messages: collapsed.map((m) => ({
1452
+ participant: m.participant,
1453
+ // Flatten content for logging — store text only; binary content
1454
+ // would bloat the log and isn't typical in compression input.
1455
+ text: m.content
1456
+ .filter((b) => b.type === 'text')
1457
+ .map((b) => b.text)
1458
+ .join(''),
1459
+ })),
1460
+ metadata: {
1461
+ chunk_message_ids: chunk.messages.map((m) => m.id),
1462
+ chunk_size: chunk.messages.length,
1463
+ prior_l1_count: priorL1s.length,
1464
+ has_doc_context: docContext !== null,
1465
+ doc_context: docContext,
1466
+ target_tokens: targetTokens,
1467
+ model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
1468
+ latency_ms: Date.now() - callStart,
1469
+ summary_id: logSummaryId,
1470
+ },
1471
+ response: logResponse,
1472
+ error: logError,
1473
+ });
1474
+ }
487
1475
  }
488
1476
  /**
489
1477
  * Check if unmerged summary counts exceed the merge threshold.
490
1478
  * Enqueues merge operations if so.
1479
+ *
1480
+ * Skips L1s/L2s that are already in a pending merge — without this guard,
1481
+ * each new summary above threshold re-enqueues a merge for the same
1482
+ * already-eligible siblings, producing N near-identical higher-level
1483
+ * summaries when the queue eventually drains.
491
1484
  */
492
1485
  checkMergeThreshold() {
1486
+ if (this.config.speculativeProduction) {
1487
+ this.checkMergeThresholdRecursive();
1488
+ return;
1489
+ }
493
1490
  const threshold = this.config.mergeThreshold ?? 6;
1491
+ // IDs that are already part of a queued merge — exclude them from
1492
+ // eligibility so we don't re-enqueue.
1493
+ const queuedL1 = new Set();
1494
+ const queuedL2 = new Set();
1495
+ for (const m of this.mergeQueue) {
1496
+ const set = m.level === 2 ? queuedL1 : queuedL2;
1497
+ for (const id of m.sourceIds)
1498
+ set.add(id);
1499
+ }
494
1500
  // Check L1 → L2
495
- const unmergedL1 = this.summaries.filter(s => s.level === 1 && !s.mergedInto);
1501
+ const unmergedL1 = this.summaries.filter(s => s.level === 1 && !s.mergedInto && !queuedL1.has(s.id));
496
1502
  if (unmergedL1.length >= threshold) {
497
1503
  const toMerge = unmergedL1.slice(0, threshold);
498
- this.mergeQueue.push({
1504
+ this.enqueueMerge({
499
1505
  level: 2,
500
1506
  sourceIds: toMerge.map(s => s.id),
501
1507
  });
502
1508
  }
503
1509
  // Check L2 → L3
504
- const unmergedL2 = this.summaries.filter(s => s.level === 2 && !s.mergedInto);
1510
+ const unmergedL2 = this.summaries.filter(s => s.level === 2 && !s.mergedInto && !queuedL2.has(s.id));
505
1511
  if (unmergedL2.length >= threshold) {
506
1512
  const toMerge = unmergedL2.slice(0, threshold);
507
- this.mergeQueue.push({
1513
+ this.enqueueMerge({
508
1514
  level: 3,
509
1515
  sourceIds: toMerge.map(s => s.id),
510
1516
  });
511
1517
  }
512
1518
  }
513
1519
  /**
514
- * Merge N summaries at one level into a single summary at the next level.
515
- * Uses self-voice consolidation prompt.
1520
+ * Bottom-up speculative pre-producer (design doc §3.5 / §7.2).
1521
+ *
1522
+ * Recursive variant of `checkMergeThreshold` for the unbounded L_n
1523
+ * design. Walks every level present in the archive; for any level k
1524
+ * with ≥ N orphans (no parent), enqueues an L_{k+1} merge. After that
1525
+ * L_{k+1} is produced and `executeMerge` calls this again, the recursion
1526
+ * naturally cascades: 6 L1s → 1 L2; 6 L2s → 1 L3; 6 L3s → 1 L4; ...
1527
+ *
1528
+ * Only fires when `config.speculativeProduction` is true. Default true
1529
+ * for adaptiveResolution=true, false otherwise. The non-speculative path
1530
+ * (above) preserves the original L1→L2→L3 behavior for non-adaptive
1531
+ * deployments.
1532
+ */
1533
+ checkMergeThresholdRecursive() {
1534
+ const threshold = this.config.mergeThreshold ?? 6;
1535
+ // Build per-level sets of source-ids already enqueued for merging,
1536
+ // so we don't re-enqueue them while a merge is pending.
1537
+ const queuedSources = new Map();
1538
+ for (const m of this.mergeQueue) {
1539
+ // m.level is the TARGET level; the sources are at level (m.level - 1).
1540
+ const sourceLevel = m.level - 1;
1541
+ if (!queuedSources.has(sourceLevel))
1542
+ queuedSources.set(sourceLevel, new Set());
1543
+ for (const id of m.sourceIds)
1544
+ queuedSources.get(sourceLevel).add(id);
1545
+ }
1546
+ // Walk every level present in the archive. Iterate from low to high
1547
+ // so when an L_{k+1} merge is enqueued and immediately produced, this
1548
+ // same check sees the new L_{k+1} on the next call and can roll up.
1549
+ let maxLevel = 0;
1550
+ for (const s of this.summaries) {
1551
+ if (s.level > maxLevel)
1552
+ maxLevel = s.level;
1553
+ }
1554
+ for (let level = 1; level <= maxLevel; level++) {
1555
+ const queued = queuedSources.get(level) ?? new Set();
1556
+ const unmerged = this.summaries.filter(s => s.level === level && !getSummaryParentId(s) && !queued.has(s.id));
1557
+ if (unmerged.length >= threshold) {
1558
+ const toMerge = unmerged.slice(0, threshold);
1559
+ this.enqueueMerge({
1560
+ level: level + 1,
1561
+ sourceIds: toMerge.map(s => s.id),
1562
+ });
1563
+ }
1564
+ }
1565
+ }
1566
+ /**
1567
+ * Merge N summaries at one level into a single summary at the next level.
1568
+ * Uses self-voice consolidation prompt.
516
1569
  */
517
1570
  async executeMerge(targetLevel, sourceIds, ctx) {
518
1571
  if (!ctx.membrane) {
@@ -525,54 +1578,227 @@ export class AutobiographicalStrategy {
525
1578
  console.warn('executeMerge: some source summaries not found, skipping');
526
1579
  return;
527
1580
  }
1581
+ // Defensive: if every source is already mergedInto something, this is a
1582
+ // stale queue entry (could happen if multiple merges for the same
1583
+ // sourceIds were enqueued before the dedup fix in checkMergeThreshold).
1584
+ // Skip rather than produce a redundant near-identical higher-level entry.
1585
+ if (sources.every(s => s.mergedInto)) {
1586
+ console.warn(`executeMerge: all sources already merged into ${sources[0].mergedInto}, skipping (stale queue entry)`);
1587
+ return;
1588
+ }
528
1589
  const targetTokens = this.config.summaryTargetTokens ?? 2000;
529
1590
  const participant = this.config.summaryParticipant ?? 'Claude';
530
- // Build message array
1591
+ // Build the merge prompt with one-level-deeper target expansion +
1592
+ // prefix of older context:
1593
+ //
1594
+ // 1. PREFIX — head messages + prior L1 recall pairs for content
1595
+ // that comes chronologically BEFORE the merge range. "Fill
1596
+ // lower orbitals first" per the spec: regardless of how
1597
+ // compressed the live view is, the summarizer always gets L1
1598
+ // fidelity for prior content. Older L2/L3 markers exist for
1599
+ // live-view compactness, not for the summarizer.
1600
+ //
1601
+ // 2. TARGET — the sources expanded ONE LEVEL DEEPER than they
1602
+ // themselves are. For L2 merge (sources at L1): expand to
1603
+ // raw L0 messages — the model sees the actual conversation
1604
+ // that the 6 L1s consolidate. For L3 merge (sources at L2):
1605
+ // expand to the L1s under each L2 (36 L1s as recall pairs).
1606
+ // For L_n merge (sources at L_{n-1}): expand to L_{n-2}.
1607
+ // This gives the model substantively more content to ground
1608
+ // the consolidation in than just the 6 surface summaries.
1609
+ //
1610
+ // 3. INSTRUCTION — "consolidate N memories preserving the
1611
+ // through-line, in first person".
1612
+ //
1613
+ // No tail-after-merge: same as-of principle as L1 compression. The
1614
+ // consolidation is being formed at the moment the last source was
1615
+ // ready, so nothing after that is visible.
531
1616
  const llmMessages = [];
532
- // Higher-level context (anti-redundant)
533
- if (targetLevel === 2) {
534
- // For L2 merge: show L3 summaries as context
535
- const shownL3 = this.summaries.filter(s => s.level === 3 && !s.mergedInto);
536
- for (const s of shownL3) {
537
- llmMessages.push({
538
- participant,
539
- content: [{ type: 'text', text: s.content }],
540
- });
1617
+ // Build lookup maps
1618
+ const summariesById = new Map();
1619
+ for (const s of this.summaries)
1620
+ summariesById.set(s.id, s);
1621
+ const allMessages = ctx.messageStore.getAll();
1622
+ const messageById = new Map();
1623
+ for (const m of allMessages)
1624
+ messageById.set(m.id, m);
1625
+ // Compute every leaf message id covered by this merge's lineage —
1626
+ // these are part of the TARGET and must not also appear in the
1627
+ // PREFIX as head content.
1628
+ const sourceLeafIds = new Set();
1629
+ const collectLeaves = (s) => {
1630
+ if (s.sourceLevel === 0) {
1631
+ for (const id of s.sourceIds)
1632
+ sourceLeafIds.add(id);
1633
+ }
1634
+ else {
1635
+ for (const childId of s.sourceIds) {
1636
+ const child = summariesById.get(childId);
1637
+ if (child)
1638
+ collectLeaves(child);
1639
+ }
541
1640
  }
1641
+ };
1642
+ for (const src of sources)
1643
+ collectLeaves(src);
1644
+ // Find the start of the merge range in the message store.
1645
+ const mergeFirstMsgId = sources[0].sourceRange.first;
1646
+ const mergeStartIdx = allMessages.findIndex((m) => m.id === mergeFirstMsgId);
1647
+ // ---- 1a. HEAD WINDOW (raw, ALWAYS present) ----
1648
+ //
1649
+ // The head window is the foundational identity anchor — the actual
1650
+ // opening of the chronicle. It establishes who is speaking to whom.
1651
+ // Without it, when the merge target's content is heavily first-person
1652
+ // from someone other than the agent, the agent loses its first-person
1653
+ // grounding and drifts into the content author's voice.
1654
+ const headStartIdx = this.getHeadWindowStartIndex(ctx.messageStore);
1655
+ const headEndIdx = this.getHeadWindowEnd(ctx.messageStore);
1656
+ for (let i = headStartIdx; i < headEndIdx && i < allMessages.length; i++) {
1657
+ const m = allMessages[i];
1658
+ llmMessages.push({ participant: m.participant, content: m.content });
1659
+ }
1660
+ // ---- 1b. PRIOR L1 RECALL PAIRS (chronologically before merge range) ----
1661
+ // L1s whose entire source range is before the merge range and that
1662
+ // aren't part of the merge tree. Sort by source position.
1663
+ const priorL1s = this.summaries
1664
+ .filter((s) => s.level === 1)
1665
+ .filter((s) => {
1666
+ // Exclude any L1 that's an ancestor of our merge target
1667
+ for (const lid of s.sourceIds)
1668
+ if (sourceLeafIds.has(lid))
1669
+ return false;
1670
+ // Include only if it starts strictly before the merge range
1671
+ const firstIdx = allMessages.findIndex((m) => m.id === s.sourceRange.first);
1672
+ return firstIdx >= 0 && (mergeStartIdx < 0 || firstIdx < mergeStartIdx);
1673
+ })
1674
+ .sort((a, b) => {
1675
+ const ai = allMessages.findIndex((m) => m.id === a.sourceRange.first);
1676
+ const bi = allMessages.findIndex((m) => m.id === b.sourceRange.first);
1677
+ return ai - bi;
1678
+ });
1679
+ const priorL1MessageIds = new Set();
1680
+ for (const s of priorL1s) {
1681
+ for (const id of s.sourceIds)
1682
+ priorL1MessageIds.add(id);
542
1683
  }
543
- // For L3 merge: no higher context exists
544
- // The source summaries as agent's own memories
545
- for (const source of sources) {
1684
+ for (const s of priorL1s) {
1685
+ llmMessages.push({
1686
+ participant: 'Context Manager',
1687
+ content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
1688
+ });
546
1689
  llmMessages.push({
547
1690
  participant,
548
- content: [{ type: 'text', text: source.content }],
1691
+ content: [{ type: 'text', text: s.content }],
549
1692
  });
550
1693
  }
551
- // Consolidation instruction
552
- const mergeInstruction = this.getMergeInstruction(targetLevel, sources, targetTokens);
1694
+ // Raw middle: any messages between the head window and the merge
1695
+ // range that aren't covered by a prior L1 or the merge tree.
1696
+ // Usually empty (chunking is contiguous).
1697
+ if (mergeStartIdx >= 0) {
1698
+ for (let i = headEndIdx; i < mergeStartIdx; i++) {
1699
+ const m = allMessages[i];
1700
+ if (priorL1MessageIds.has(m.id))
1701
+ continue;
1702
+ if (sourceLeafIds.has(m.id))
1703
+ continue;
1704
+ llmMessages.push({ participant: m.participant, content: m.content });
1705
+ }
1706
+ }
1707
+ // ---- 2. TARGET: expand sources one level deeper ----
1708
+ // For L2 (sources at L1, sourceLevel=0): expand to raw L0 messages.
1709
+ // For L3+ (sources at L_{n-1}, sourceLevel=n-2): expand to L_{n-2}
1710
+ // summaries as recall pairs.
1711
+ for (const src of sources) {
1712
+ if (src.sourceLevel === 0) {
1713
+ // Source is an L1; its sourceIds are raw message ids. Emit them raw.
1714
+ for (const messageId of src.sourceIds) {
1715
+ const m = messageById.get(messageId);
1716
+ if (m) {
1717
+ llmMessages.push({ participant: m.participant, content: m.content });
1718
+ }
1719
+ }
1720
+ }
1721
+ else {
1722
+ // Source is L2+; its sourceIds point to summaries one level
1723
+ // below. Emit each as a recall pair.
1724
+ for (const childId of src.sourceIds) {
1725
+ const child = summariesById.get(childId);
1726
+ if (!child)
1727
+ continue;
1728
+ llmMessages.push({
1729
+ participant: 'Context Manager',
1730
+ content: [{ type: 'text', text: `[CM] Recall memory ${child.id}.` }],
1731
+ });
1732
+ llmMessages.push({
1733
+ participant,
1734
+ content: [{ type: 'text', text: child.content }],
1735
+ });
1736
+ }
1737
+ }
1738
+ }
1739
+ // ---- 3. INSTRUCTION ----
1740
+ // sourceLevelShown is the level of content the model actually sees
1741
+ // (one level below the sources themselves).
1742
+ const sourceLevelShown = sources[0].sourceLevel === 0 ? 0 : sources[0].level - 1;
1743
+ // Reading-mode detection: when ALL the merge's leaf messages are
1744
+ // shards of the same bodyGroup, we know the agent was reading a
1745
+ // substantially larger message rather than conversing. The
1746
+ // reading-mode merge instruction asks what reading the stretch was
1747
+ // like instead of asking for an impersonal consolidation, which
1748
+ // forces the agent's vantage point and prevents drift into the
1749
+ // content author's voice. Same principle as the L1 case.
1750
+ let mergeReadingContext = null;
1751
+ if (sourceLeafIds.size > 0) {
1752
+ const leafBodyGroupIds = new Set();
1753
+ for (const leafId of sourceLeafIds) {
1754
+ const m = messageById.get(leafId);
1755
+ leafBodyGroupIds.add(m?.bodyGroupId);
1756
+ }
1757
+ if (leafBodyGroupIds.size === 1) {
1758
+ const groupId = [...leafBodyGroupIds][0];
1759
+ if (groupId) {
1760
+ let totalTokens = 0;
1761
+ for (const m of allMessages) {
1762
+ if (m.bodyGroupId === groupId) {
1763
+ totalTokens += ctx.messageStore.estimateTokens(m);
1764
+ }
1765
+ }
1766
+ mergeReadingContext = { totalTokens };
1767
+ }
1768
+ }
1769
+ }
1770
+ const mergeInstructionText = mergeReadingContext
1771
+ ? this.getReadingMergeInstruction(targetLevel, sources, mergeReadingContext.totalTokens, targetTokens)
1772
+ : this.getMergeInstruction(targetLevel, sources, targetTokens);
553
1773
  llmMessages.push({
554
1774
  participant: 'Context Manager',
555
1775
  content: [{
556
1776
  type: 'text',
557
- text: `[Context Manager] ${mergeInstruction}`,
1777
+ text: mergeInstructionText,
558
1778
  }],
559
1779
  });
560
1780
  const collapsed = this.collapseConsecutiveMessages(llmMessages);
1781
+ // NO system prompt — identity is established by the head window
1782
+ // (present at the start of llmMessages above) and by the prior
1783
+ // recall pairs. Same rationale as compressChunkHierarchical.
561
1784
  const request = {
562
1785
  messages: collapsed.map(m => ({ participant: m.participant, content: m.content })),
563
- system: 'You are forming autobiographical memories of a conversation.',
564
1786
  config: {
565
1787
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
566
1788
  maxTokens: Math.round(targetTokens * 1.5),
567
- temperature: 0,
568
1789
  },
569
1790
  };
1791
+ const callStart = Date.now();
1792
+ let logResponse;
1793
+ let logError;
1794
+ let logNewSummaryId;
570
1795
  try {
571
1796
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
572
1797
  const mergedText = response.content
573
1798
  .filter((b) => b.type === 'text')
574
1799
  .map(b => b.text)
575
1800
  .join('\n');
1801
+ logResponse = mergedText;
576
1802
  // Compute source range from constituent summaries
577
1803
  const sourceRange = {
578
1804
  first: sources[0].sourceRange.first,
@@ -580,7 +1806,7 @@ export class AutobiographicalStrategy {
580
1806
  };
581
1807
  const sourceLevel = (targetLevel - 1);
582
1808
  const newEntry = {
583
- id: `L${targetLevel}-${this.summaryIdCounter++}`,
1809
+ id: `L${targetLevel}-${this.nextSummaryIdCounter()}`,
584
1810
  level: targetLevel,
585
1811
  content: mergedText,
586
1812
  tokens: Math.ceil(mergedText.length / 4),
@@ -589,19 +1815,584 @@ export class AutobiographicalStrategy {
589
1815
  sourceRange,
590
1816
  created: Date.now(),
591
1817
  };
592
- this.summaries.push(newEntry);
593
- // Mark sources as merged
1818
+ logNewSummaryId = newEntry.id;
1819
+ // Append the new merged entry first, then mark sources. Persist each
1820
+ // mergedInto edit individually so chronicle reflects the same shape as
1821
+ // the in-memory mirror. (If the process crashes mid-loop, restart sees
1822
+ // the new entry plus a partial set of marked sources; un-marked sources
1823
+ // would re-trigger a merge — accept the rare duplicate over data loss.)
1824
+ this.pushSummary(newEntry);
594
1825
  for (const source of sources) {
595
- source.mergedInto = newEntry.id;
1826
+ this.setMergedInto(source, newEntry.id);
596
1827
  }
597
1828
  // Check if this merge triggers a further merge
598
1829
  this.checkMergeThreshold();
599
1830
  }
600
1831
  catch (error) {
601
1832
  console.error(`Failed to merge summaries into L${targetLevel}:`, error);
1833
+ logError = error instanceof Error ? error.message : String(error);
602
1834
  throw error;
603
1835
  }
1836
+ finally {
1837
+ logCompressionCall({
1838
+ operation: `merge_l${targetLevel}`,
1839
+ system: null,
1840
+ messages: collapsed.map((m) => ({
1841
+ participant: m.participant,
1842
+ text: m.content
1843
+ .filter((b) => b.type === 'text')
1844
+ .map((b) => b.text)
1845
+ .join(''),
1846
+ })),
1847
+ metadata: {
1848
+ target_level: targetLevel,
1849
+ source_ids: sourceIds,
1850
+ source_level: sources[0]?.level ?? null,
1851
+ source_level_shown: sourceLevelShown,
1852
+ target_tokens: targetTokens,
1853
+ model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
1854
+ latency_ms: Date.now() - callStart,
1855
+ summary_id: logNewSummaryId,
1856
+ },
1857
+ response: logResponse,
1858
+ error: logError,
1859
+ });
1860
+ }
1861
+ }
1862
+ // ============================================================================
1863
+ // Adaptive resolution (picker-driven) path
1864
+ // ============================================================================
1865
+ /**
1866
+ * Select context entries using the adaptive-resolution picker.
1867
+ *
1868
+ * Builds per-message PickerChunks from compressible messages, runs the
1869
+ * configured FoldingStrategy under token-budget pressure, and emits the
1870
+ * resulting per-message resolutions as ContextEntry[]. Adjacent messages
1871
+ * sharing the same L_k ancestor emit the recall pair once.
1872
+ *
1873
+ * See `docs/adaptive-resolution-design.md` §3, §5.
1874
+ */
1875
+ selectAdaptive(store, budget) {
1876
+ const entries = [];
1877
+ const maxTokens = budget.maxTokens - budget.reserveForResponse;
1878
+ const messages = store.getAll();
1879
+ const msgCap = this.config.maxMessageTokens;
1880
+ // ----- 1. Build head/tail sets and emit head entries -----
1881
+ const headStart = this.getHeadWindowStartIndex(store);
1882
+ const headEnd = this.getHeadWindowEnd(store);
1883
+ const recentStart = this.getRecentWindowStart(store);
1884
+ const headMessageIds = new Set();
1885
+ const tailMessageIds = new Set();
1886
+ let headTokens = 0;
1887
+ let tailTokens = 0;
1888
+ let totalTokens = 0;
1889
+ // Emit head entries verbatim
1890
+ for (let i = headStart; i < headEnd && i < messages.length; i++) {
1891
+ const msg = messages[i];
1892
+ const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1893
+ const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
1894
+ if (totalTokens + tokens > maxTokens)
1895
+ break;
1896
+ entries.push({
1897
+ index: entries.length,
1898
+ sourceMessageId: msg.id,
1899
+ sourceRelation: 'copy',
1900
+ participant: msg.participant,
1901
+ content,
1902
+ });
1903
+ totalTokens += tokens;
1904
+ headMessageIds.add(msg.id);
1905
+ headTokens += tokens;
1906
+ }
1907
+ if (entries.length > 0) {
1908
+ entries[entries.length - 1].cacheMarker = true;
1909
+ }
1910
+ // Compute tail message IDs (will be emitted at end)
1911
+ const effectiveRecentStart = Math.max(recentStart, headEnd);
1912
+ for (let i = effectiveRecentStart; i < messages.length; i++) {
1913
+ const msg = messages[i];
1914
+ const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
1915
+ tailMessageIds.add(msg.id);
1916
+ tailTokens += tokens;
1917
+ }
1918
+ // ----- 2. Build PickerChunks for messages in the middle -----
1919
+ // For each compressible (non-head, non-tail) message we create one
1920
+ // PickerChunk. Its l1Id is determined by the existing chunks that
1921
+ // group messages into L1 summaries.
1922
+ const chunksByMessageId = new Map();
1923
+ for (const ch of this.chunks) {
1924
+ for (const m of ch.messages) {
1925
+ chunksByMessageId.set(m.id, ch);
1926
+ }
1927
+ }
1928
+ // Pinned-position set so the picker doesn't fold messages the user
1929
+ // explicitly marked as keep-raw. Built once and reused.
1930
+ const pinnedSet = this.pinnedPositions(messages);
1931
+ // O(1) summary lookup for findAncestorAt — avoids O(summaries) find()
1932
+ // calls during emission.
1933
+ const summariesById = new Map();
1934
+ for (const s of this.summaries)
1935
+ summariesById.set(s.id, s);
1936
+ const pickerChunks = [];
1937
+ for (let i = headEnd; i < effectiveRecentStart && i < messages.length; i++) {
1938
+ const msg = messages[i];
1939
+ const ch = chunksByMessageId.get(msg.id);
1940
+ const tokens = msgCap > 0
1941
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
1942
+ : store.estimateTokens(msg);
1943
+ pickerChunks.push({
1944
+ id: msg.id,
1945
+ sequence: i,
1946
+ rawTokens: tokens,
1947
+ currentResolution: this.resolutions.get(msg.id) ?? 0,
1948
+ lockedByAgent: this.locked.has(msg.id),
1949
+ pinned: pinnedSet.has(i),
1950
+ l1Id: ch?.summaryId,
1951
+ });
1952
+ }
1953
+ // Also include head and tail in PickerChunks (so token accounting matches)
1954
+ // — but mark them as in-head/in-tail so the picker won't fold them.
1955
+ for (let i = headStart; i < headEnd && i < messages.length; i++) {
1956
+ const msg = messages[i];
1957
+ const tokens = msgCap > 0
1958
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
1959
+ : store.estimateTokens(msg);
1960
+ pickerChunks.push({
1961
+ id: msg.id,
1962
+ sequence: i,
1963
+ rawTokens: tokens,
1964
+ currentResolution: 0,
1965
+ lockedByAgent: this.locked.has(msg.id),
1966
+ pinned: true, // treat head as pinned for picker purposes
1967
+ l1Id: undefined,
1968
+ });
1969
+ }
1970
+ for (let i = effectiveRecentStart; i < messages.length; i++) {
1971
+ const msg = messages[i];
1972
+ const tokens = msgCap > 0
1973
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
1974
+ : store.estimateTokens(msg);
1975
+ pickerChunks.push({
1976
+ id: msg.id,
1977
+ sequence: i,
1978
+ rawTokens: tokens,
1979
+ currentResolution: 0,
1980
+ lockedByAgent: this.locked.has(msg.id),
1981
+ pinned: true, // treat tail as pinned for picker purposes
1982
+ l1Id: undefined,
1983
+ });
1984
+ }
1985
+ // ----- 3. Build summaries map and recall-pair tokens -----
1986
+ const summariesMap = new Map();
1987
+ const recallPairTokens = new Map();
1988
+ for (const s of this.summaries) {
1989
+ summariesMap.set(s.id, s);
1990
+ // recall pair = the summary's text wrapped as a Q&A pair. Approximate
1991
+ // as s.tokens + small overhead for the "What do you remember?" label.
1992
+ recallPairTokens.set(s.id, s.tokens + 20);
1993
+ }
1994
+ // ----- 4. Run the picker -----
1995
+ const totalBudget = maxTokens - totalTokens; // tokens left after head
1996
+ const slack = this.config.compressionSlackRatio ?? 0.1;
1997
+ const foldingBudget = {
1998
+ totalBudget,
1999
+ targetBudget: totalBudget * (1 - slack),
2000
+ slack,
2001
+ };
2002
+ const headSetForPicker = new Set(headMessageIds);
2003
+ const tailSetForPicker = new Set(tailMessageIds);
2004
+ const picker = this.getAdaptivePicker();
2005
+ const result = picker.run({
2006
+ chunks: pickerChunks,
2007
+ summaries: summariesMap,
2008
+ recallPairTokens,
2009
+ headChunkIds: headSetForPicker,
2010
+ tailChunkIds: tailSetForPicker,
2011
+ headTokens,
2012
+ tailTokens,
2013
+ }, foldingBudget);
2014
+ // Commit the new resolutions back to strategy state for next compile.
2015
+ // Persist to chronicle only if anything actually changed — avoids
2016
+ // unnecessary state-slot writes on no-op compiles (which is the common
2017
+ // case in steady state with slack).
2018
+ let resolutionsChanged = false;
2019
+ let deepestLevel = 0;
2020
+ for (const [id, level] of result.finalResolutions) {
2021
+ if (headMessageIds.has(id) || tailMessageIds.has(id))
2022
+ continue;
2023
+ if (this.locked.has(id))
2024
+ continue;
2025
+ const prev = this.resolutions.get(id) ?? 0;
2026
+ if (prev !== level) {
2027
+ this.resolutions.set(id, level);
2028
+ resolutionsChanged = true;
2029
+ }
2030
+ if (level > deepestLevel)
2031
+ deepestLevel = level;
2032
+ }
2033
+ if (resolutionsChanged) {
2034
+ this.persistResolutions();
2035
+ }
2036
+ // Wire produce ops into the strategy's own production queues so that
2037
+ // requested-but-not-yet-existing summaries actually get built. The
2038
+ // speculative pre-producer covers most cases ambiently, but when it is
2039
+ // disabled (`speculativeProduction: false`) or hasn't reached the level
2040
+ // the picker just asked for, the request would otherwise be dropped and
2041
+ // the picker would re-emit it on every subsequent compile. Handling it
2042
+ // here makes the produce path observable and convergent.
2043
+ //
2044
+ // The actual compression/merge work runs asynchronously via the next
2045
+ // `tick()` invocation (or the speculative drain kicked from
2046
+ // `onNewMessage`). This call only enqueues; it does not await.
2047
+ if (result.produced.length > 0) {
2048
+ this.handleProducedOps(result.produced);
2049
+ }
2050
+ // Hard-fail check: if the picker exhausted itself but the final render
2051
+ // would still exceed the HARD budget (not just the soft target), surface
2052
+ // an OverBudgetError to the host rather than silently dropping entries.
2053
+ // The strategy has done all it can; the application has to decide what
2054
+ // to do next (raise budget, switch model, drop windows, etc.).
2055
+ if (result.exhausted && result.finalTokens > totalBudget) {
2056
+ throw new OverBudgetError({
2057
+ budget: totalBudget,
2058
+ actual: result.finalTokens,
2059
+ diagnostics: {
2060
+ headTokens,
2061
+ tailTokens,
2062
+ middleTokens: Math.max(0, result.finalTokens - headTokens - tailTokens),
2063
+ middleChunkCount: pickerChunks.length - headMessageIds.size - tailMessageIds.size,
2064
+ deepestLevel,
2065
+ },
2066
+ });
2067
+ }
2068
+ // ----- 5. Emit middle entries in source order -----
2069
+ // Walk middle messages. Handle two cases:
2070
+ // - bodyGroupId set: collect all consecutive shards from the same group,
2071
+ // emit ONE combined entry with concatenated content (raw shards + inline
2072
+ // summary text for folded shards). This preserves KV — the model sees
2073
+ // one continuous user message instead of N turns.
2074
+ // - bodyGroupId absent: emit normally (raw L0 message, or Q+A summary pair).
2075
+ const emittedAncestors = new Set();
2076
+ const summaryLabel = this.config.summaryContextLabel ?? 'What do you remember from earlier?';
2077
+ const summaryParticipant = this.config.summaryParticipant ?? 'Claude';
2078
+ let i = headEnd;
2079
+ while (i < effectiveRecentStart && i < messages.length) {
2080
+ const msg = messages[i];
2081
+ if (msg.bodyGroupId) {
2082
+ // Collect the full run of consecutive shards sharing this bodyGroupId.
2083
+ const groupId = msg.bodyGroupId;
2084
+ const groupStart = i;
2085
+ while (i < effectiveRecentStart &&
2086
+ i < messages.length &&
2087
+ messages[i].bodyGroupId === groupId) {
2088
+ i++;
2089
+ }
2090
+ const groupMessages = messages.slice(groupStart, i);
2091
+ // Sort by shardIndex to ensure byte-faithful ordering.
2092
+ const sortedShards = [...groupMessages].sort((a, b) => (a.shardIndex ?? 0) - (b.shardIndex ?? 0));
2093
+ let currentRun = null;
2094
+ const participant = sortedShards[0].participant;
2095
+ const flushRun = () => {
2096
+ if (!currentRun)
2097
+ return true;
2098
+ if (currentRun.kind === 'raw') {
2099
+ const text = currentRun.parts.join('');
2100
+ const content = [{ type: 'text', text }];
2101
+ // Deliberately do NOT apply maxMessageTokens here: the picker
2102
+ // is the authority on how much of the doc renders raw vs.
2103
+ // summarized. Truncating the composite would silently lose
2104
+ // doc content that the picker explicitly chose to keep raw.
2105
+ // (`maxMessageTokens` is for per-message caps on chat / tool
2106
+ // results, not for sharded bodyGroup composites.)
2107
+ const tokens = this.estimateTokens(content);
2108
+ if (totalTokens + tokens > maxTokens) {
2109
+ currentRun = null;
2110
+ return false;
2111
+ }
2112
+ entries.push({
2113
+ index: entries.length,
2114
+ sourceMessageId: undefined,
2115
+ sourceRelation: 'copy',
2116
+ participant,
2117
+ content,
2118
+ });
2119
+ totalTokens += tokens;
2120
+ }
2121
+ else {
2122
+ // summary run — emit Q+A pair, dedup at the strategy level
2123
+ const ancestor = currentRun.ancestor;
2124
+ if (!emittedAncestors.has(ancestor.id)) {
2125
+ emittedAncestors.add(ancestor.id);
2126
+ const questionEntry = {
2127
+ index: entries.length,
2128
+ participant: 'Context Manager',
2129
+ content: [{ type: 'text', text: summaryLabel }],
2130
+ sourceRelation: 'derived',
2131
+ };
2132
+ const answerContent = [{ type: 'text', text: ancestor.content }];
2133
+ const answerEntry = {
2134
+ index: entries.length + 1,
2135
+ participant: summaryParticipant,
2136
+ content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
2137
+ sourceRelation: 'derived',
2138
+ };
2139
+ const pairTokens = this.estimateTokens(questionEntry.content) + this.estimateTokens(answerEntry.content);
2140
+ if (totalTokens + pairTokens > maxTokens) {
2141
+ currentRun = null;
2142
+ return false;
2143
+ }
2144
+ entries.push(questionEntry);
2145
+ entries.push(answerEntry);
2146
+ totalTokens += pairTokens;
2147
+ }
2148
+ }
2149
+ currentRun = null;
2150
+ return true;
2151
+ };
2152
+ let budgetExhausted = false;
2153
+ for (const shard of sortedShards) {
2154
+ const resolution = result.finalResolutions.get(shard.id) ?? 0;
2155
+ if (resolution === 0) {
2156
+ if (currentRun?.kind !== 'raw') {
2157
+ if (!flushRun()) {
2158
+ budgetExhausted = true;
2159
+ break;
2160
+ }
2161
+ currentRun = { kind: 'raw', parts: [] };
2162
+ }
2163
+ for (const block of shard.content) {
2164
+ if (block.type === 'text')
2165
+ currentRun.parts.push(block.text);
2166
+ }
2167
+ }
2168
+ else {
2169
+ const ancestor = this.findAncestorAt(shard.id, resolution, chunksByMessageId, summariesById);
2170
+ if (!ancestor) {
2171
+ // Fall back to raw
2172
+ if (currentRun?.kind !== 'raw') {
2173
+ if (!flushRun()) {
2174
+ budgetExhausted = true;
2175
+ break;
2176
+ }
2177
+ currentRun = { kind: 'raw', parts: [] };
2178
+ }
2179
+ for (const block of shard.content) {
2180
+ if (block.type === 'text')
2181
+ currentRun.parts.push(block.text);
2182
+ }
2183
+ continue;
2184
+ }
2185
+ // If we're already in a summary run for the SAME ancestor, this
2186
+ // shard is covered — skip silently.
2187
+ if (currentRun?.kind === 'summary' && currentRun.ancestor.id === ancestor.id) {
2188
+ continue;
2189
+ }
2190
+ if (!flushRun()) {
2191
+ budgetExhausted = true;
2192
+ break;
2193
+ }
2194
+ currentRun = { kind: 'summary', ancestor };
2195
+ }
2196
+ }
2197
+ if (!budgetExhausted) {
2198
+ if (!flushRun())
2199
+ budgetExhausted = true;
2200
+ }
2201
+ if (budgetExhausted)
2202
+ break;
2203
+ continue;
2204
+ }
2205
+ // Non-shard path: existing behavior.
2206
+ const resolution = result.finalResolutions.get(msg.id) ?? 0;
2207
+ if (resolution === 0) {
2208
+ const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
2209
+ const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
2210
+ if (totalTokens + tokens > maxTokens)
2211
+ break;
2212
+ entries.push({
2213
+ index: entries.length,
2214
+ sourceMessageId: msg.id,
2215
+ sourceRelation: 'copy',
2216
+ participant: msg.participant,
2217
+ content,
2218
+ });
2219
+ totalTokens += tokens;
2220
+ i++;
2221
+ }
2222
+ else {
2223
+ const ancestor = this.findAncestorAt(msg.id, resolution, chunksByMessageId, summariesById);
2224
+ if (!ancestor) {
2225
+ const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
2226
+ const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
2227
+ if (totalTokens + tokens > maxTokens)
2228
+ break;
2229
+ entries.push({
2230
+ index: entries.length,
2231
+ sourceMessageId: msg.id,
2232
+ sourceRelation: 'copy',
2233
+ participant: msg.participant,
2234
+ content,
2235
+ });
2236
+ totalTokens += tokens;
2237
+ i++;
2238
+ continue;
2239
+ }
2240
+ if (emittedAncestors.has(ancestor.id)) {
2241
+ i++;
2242
+ continue;
2243
+ }
2244
+ emittedAncestors.add(ancestor.id);
2245
+ const questionEntry = {
2246
+ index: entries.length,
2247
+ participant: 'Context Manager',
2248
+ content: [{ type: 'text', text: summaryLabel }],
2249
+ sourceRelation: 'derived',
2250
+ };
2251
+ const answerContent = [{ type: 'text', text: ancestor.content }];
2252
+ const answerEntry = {
2253
+ index: entries.length + 1,
2254
+ participant: summaryParticipant,
2255
+ content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
2256
+ sourceRelation: 'derived',
2257
+ };
2258
+ const pairTokens = this.estimateTokens(questionEntry.content) + this.estimateTokens(answerEntry.content);
2259
+ if (totalTokens + pairTokens > maxTokens)
2260
+ break;
2261
+ entries.push(questionEntry);
2262
+ entries.push(answerEntry);
2263
+ totalTokens += pairTokens;
2264
+ i++;
2265
+ }
2266
+ }
2267
+ // ----- 6. Emit tail entries newest-first eviction (matches existing behavior) -----
2268
+ this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2269
+ // ----- 7. Post-process: merge consecutive raw entries from the same bodyGroup -----
2270
+ // Both head and tail emission paths emit shards as separate ContextEntries.
2271
+ // The middle path already merges consecutive same-bodyGroup raw shards into
2272
+ // one composite entry, but head/tail don't. This pass closes that gap so
2273
+ // a sharded message renders as ONE API message regardless of which region
2274
+ // it falls into (preserves KV cache through region transitions).
2275
+ const merged = this.mergeAdjacentBodyGroupRaw(entries, store);
2276
+ this.trimOrphanedToolUse(merged);
2277
+ return merged;
604
2278
  }
2279
+ /**
2280
+ * Walk an entries array; for every run of consecutive entries that
2281
+ * (a) have sourceRelation: 'copy' (raw, not a synthesized recall pair)
2282
+ * (b) have sourceMessageId pointing to messages in the same bodyGroup
2283
+ * merge them into one composite entry whose body is the byte-faithful
2284
+ * concatenation of their text content. Other entries pass through.
2285
+ *
2286
+ * Reindexes the returned array.
2287
+ */
2288
+ mergeAdjacentBodyGroupRaw(entries, store) {
2289
+ if (entries.length === 0)
2290
+ return entries;
2291
+ // Look up bodyGroupId by sourceMessageId via the message store.
2292
+ const groupOf = (sourceMessageId) => {
2293
+ if (!sourceMessageId)
2294
+ return undefined;
2295
+ const m = store.get(sourceMessageId);
2296
+ return m?.bodyGroupId;
2297
+ };
2298
+ const out = [];
2299
+ let i = 0;
2300
+ while (i < entries.length) {
2301
+ const entry = entries[i];
2302
+ const groupId = entry.sourceRelation === 'copy' ? groupOf(entry.sourceMessageId) : undefined;
2303
+ if (!groupId) {
2304
+ out.push({ ...entry, index: out.length });
2305
+ i++;
2306
+ continue;
2307
+ }
2308
+ // Collect run of consecutive raw entries with same bodyGroupId.
2309
+ const run = [entry];
2310
+ let j = i + 1;
2311
+ while (j < entries.length &&
2312
+ entries[j].sourceRelation === 'copy' &&
2313
+ groupOf(entries[j].sourceMessageId) === groupId) {
2314
+ run.push(entries[j]);
2315
+ j++;
2316
+ }
2317
+ if (run.length === 1) {
2318
+ out.push({ ...entry, index: out.length });
2319
+ i++;
2320
+ continue;
2321
+ }
2322
+ // Sort the run by the underlying shardIndex to ensure byte-faithful
2323
+ // ordering. (Head/tail emission keeps chronological order, but defending
2324
+ // against reorderings is cheap.)
2325
+ const sortedRun = [...run].sort((a, b) => {
2326
+ const ma = a.sourceMessageId ? store.get(a.sourceMessageId) : null;
2327
+ const mb = b.sourceMessageId ? store.get(b.sourceMessageId) : null;
2328
+ return (ma?.shardIndex ?? 0) - (mb?.shardIndex ?? 0);
2329
+ });
2330
+ // Build merged text content. Non-text blocks (rare in shards) are
2331
+ // preserved on the first shard's entry only.
2332
+ const mergedTextParts = [];
2333
+ const nonTextBlocks = [];
2334
+ for (const r of sortedRun) {
2335
+ for (const block of r.content) {
2336
+ if (block.type === 'text')
2337
+ mergedTextParts.push(block.text);
2338
+ else
2339
+ nonTextBlocks.push(block);
2340
+ }
2341
+ }
2342
+ const mergedContent = [
2343
+ ...nonTextBlocks,
2344
+ { type: 'text', text: mergedTextParts.join('') },
2345
+ ];
2346
+ out.push({
2347
+ index: out.length,
2348
+ sourceMessageId: sortedRun[0].sourceMessageId,
2349
+ sourceRelation: 'copy',
2350
+ participant: sortedRun[0].participant,
2351
+ content: mergedContent,
2352
+ });
2353
+ i = j;
2354
+ }
2355
+ return out;
2356
+ }
2357
+ /** Get (lazily constructing) the configured picker instance. */
2358
+ getAdaptivePicker() {
2359
+ if (this._adaptivePicker)
2360
+ return this._adaptivePicker;
2361
+ const strategy = this.config.foldingStrategy === 'oldest-first'
2362
+ ? new OldestFirstStrategy()
2363
+ : new FlatProfileStrategy();
2364
+ this._adaptivePicker = new Picker(strategy);
2365
+ return this._adaptivePicker;
2366
+ }
2367
+ /**
2368
+ * Walk the summary tree to find the L_k ancestor of a message.
2369
+ * Returns null if no ancestor exists at that level (e.g., L_k not yet produced).
2370
+ *
2371
+ * Takes a pre-built summariesById map to avoid O(summaries) lookups per
2372
+ * call — for a chronicle with thousands of summaries and hundreds of
2373
+ * middle messages, the O(n) `find` would dominate compile latency.
2374
+ */
2375
+ findAncestorAt(messageId, level, chunksByMessageId, summariesById) {
2376
+ if (level <= 0)
2377
+ return null;
2378
+ const chunk = chunksByMessageId.get(messageId);
2379
+ if (!chunk?.summaryId)
2380
+ return null;
2381
+ const lookup = (id) => summariesById ? summariesById.get(id) : this.summaries.find((s) => s.id === id);
2382
+ let current = lookup(chunk.summaryId);
2383
+ while (current && current.level < level) {
2384
+ const parentId = getSummaryParentId(current);
2385
+ if (!parentId)
2386
+ return null;
2387
+ current = lookup(parentId);
2388
+ }
2389
+ if (!current || current.level !== level)
2390
+ return null;
2391
+ return current;
2392
+ }
2393
+ // ============================================================================
2394
+ // Hierarchical (threshold-driven) path
2395
+ // ============================================================================
605
2396
  /**
606
2397
  * Select context entries using hierarchical compression with budget carryover.
607
2398
  * Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
@@ -619,7 +2410,7 @@ export class AutobiographicalStrategy {
619
2410
  const msg = messages[i];
620
2411
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
621
2412
  const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
622
- if (totalTokens + tokens > maxTokens)
2413
+ if (this.isOverBudget(totalTokens + tokens, maxTokens))
623
2414
  break;
624
2415
  entries.push({
625
2416
  index: entries.length,
@@ -654,7 +2445,7 @@ export class AutobiographicalStrategy {
654
2445
  for (const s of shownL3) {
655
2446
  if (l3Used + s.tokens > l3Budget)
656
2447
  break;
657
- if (totalTokens + totalSummaryTokens + s.tokens > maxTokens)
2448
+ if (this.isOverBudget(totalTokens + totalSummaryTokens + s.tokens, maxTokens))
658
2449
  break;
659
2450
  selectedSummaries.push(s);
660
2451
  l3Used += s.tokens;
@@ -667,7 +2458,7 @@ export class AutobiographicalStrategy {
667
2458
  for (const s of shownL2) {
668
2459
  if (l2Used + s.tokens > l2Effective)
669
2460
  break;
670
- if (totalTokens + totalSummaryTokens + s.tokens > maxTokens)
2461
+ if (this.isOverBudget(totalTokens + totalSummaryTokens + s.tokens, maxTokens))
671
2462
  break;
672
2463
  selectedSummaries.push(s);
673
2464
  l2Used += s.tokens;
@@ -680,32 +2471,173 @@ export class AutobiographicalStrategy {
680
2471
  const { selected: l1Selected, tokensUsed: l1Used } = this.selectL1Summaries(shownL1, l1Effective, l1Remaining);
681
2472
  selectedSummaries.push(...l1Selected);
682
2473
  totalSummaryTokens += l1Used;
683
- // Emit summaries as a single Q&A pair
684
- if (selectedSummaries.length > 0) {
685
- const contextLabel = this.config.summaryContextLabel ?? 'What do you remember from earlier?';
686
- const combinedText = selectedSummaries.map(s => s.content).join('\n\n---\n\n');
687
- const questionEntry = {
688
- index: entries.length,
689
- participant: 'Context Manager',
690
- content: [{ type: 'text', text: contextLabel }],
691
- sourceRelation: 'derived',
692
- };
693
- // Synthesised summary turns must respect maxMessageTokens. With L1+L2+L3
694
- // budgets defaulting to 30k each, an unconstrained concatenation can push
695
- // a single assistant turn past 90k tokens, eating the inference budget
696
- // and starving recent messages (postmortem 2026-05-04, bug B).
697
- const answerContent = [{ type: 'text', text: combinedText }];
698
- const answerEntry = {
699
- index: entries.length + 1,
700
- participant: this.config.summaryParticipant ?? 'Claude',
701
- content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
702
- sourceRelation: 'derived',
703
- };
704
- const pairTokens = this.estimateTokens(questionEntry.content) +
705
- this.estimateTokens(answerEntry.content);
706
- entries.push(questionEntry);
707
- entries.push(answerEntry);
708
- totalTokens += pairTokens;
2474
+ // Emit summaries + pinned messages between head and recent windows.
2475
+ //
2476
+ // Default (positionedRecallPairs=true): one Q/A pair per summary,
2477
+ // interleaved with raw pinned messages, all sorted chronologically by
2478
+ // source-range / message position. Each memory appears in its temporal
2479
+ // place rather than as a wall of unrelated recollections.
2480
+ //
2481
+ // Legacy (positionedRecallPairs=false): summaries concatenated into one
2482
+ // Q/A pair between head and tail; pinned messages still emit raw, in
2483
+ // their chronological positions, after the combined recall pair.
2484
+ const positionOf = new Map();
2485
+ for (let i = 0; i < messages.length; i++) {
2486
+ positionOf.set(messages[i].id, i);
2487
+ }
2488
+ const pinnedPositionsSet = this.pinnedPositions(messages);
2489
+ // Pinned messages between head and recent (head/recent pinned ones
2490
+ // already emit raw via Phase 0 / Phase 4).
2491
+ const pinnedInMiddle = [];
2492
+ const pinnedIdsInMiddle = new Set();
2493
+ for (let i = headEnd; i < recentStart; i++) {
2494
+ if (pinnedPositionsSet.has(i)) {
2495
+ pinnedInMiddle.push({ msg: messages[i], position: i });
2496
+ pinnedIdsInMiddle.add(messages[i].id);
2497
+ }
2498
+ }
2499
+ // Uncompressed-chunk fallback: messages in the middle region whose
2500
+ // chunk hasn't been summarized yet. Without this, a message that
2501
+ // rolled out of the recent window into a queued-but-not-yet-compressed
2502
+ // chunk would vanish from rendered context — there'd be no summary to
2503
+ // emit (compression hasn't run) and Phase 4 only walks recentStart
2504
+ // onwards. Mirrors selectLegacy's "Uncompressed: emit raw" behavior
2505
+ // around line 738, but here we interleave chronologically with
2506
+ // summaries and pins via the unified items list below.
2507
+ //
2508
+ // This matters because compile() was made non-blocking in commit
2509
+ // `3e42e98` (drops the prior `await readiness.pendingWork`); without
2510
+ // this fallback, the trade was silent data-loss for messages caught
2511
+ // in the queued-but-not-yet-compressed window. Now compile()'s
2512
+ // freshness contract is: summaries may lag the very latest L1, but
2513
+ // no message ever disappears.
2514
+ const uncompressedInMiddle = [];
2515
+ for (const chunk of this.chunks) {
2516
+ if (chunk.compressed)
2517
+ continue;
2518
+ for (const msg of chunk.messages) {
2519
+ const pos = positionOf.get(msg.id);
2520
+ if (pos === undefined)
2521
+ continue;
2522
+ if (pos < headEnd || pos >= recentStart)
2523
+ continue;
2524
+ if (pinnedIdsInMiddle.has(msg.id))
2525
+ continue;
2526
+ uncompressedInMiddle.push({ msg, position: pos });
2527
+ }
2528
+ }
2529
+ // Merged list of raw messages to emit in the middle region —
2530
+ // either a pin or a message whose chunk hasn't compressed yet.
2531
+ // Both render identically (raw, at their chronological position).
2532
+ const middleRaw = [
2533
+ ...pinnedInMiddle,
2534
+ ...uncompressedInMiddle,
2535
+ ];
2536
+ if (selectedSummaries.length > 0 || middleRaw.length > 0) {
2537
+ const summaryParticipant = this.config.summaryParticipant ?? 'Claude';
2538
+ if (this.config.positionedRecallPairs !== false) {
2539
+ const items = [];
2540
+ for (const s of selectedSummaries) {
2541
+ const pos = positionOf.get(s.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
2542
+ items.push({ kind: 'summary', position: pos, summary: s });
2543
+ }
2544
+ for (const p of middleRaw) {
2545
+ items.push({ kind: 'pin', position: p.position, msg: p.msg });
2546
+ }
2547
+ items.sort((a, b) => a.position - b.position);
2548
+ for (const item of items) {
2549
+ if (item.kind === 'summary') {
2550
+ const summary = item.summary;
2551
+ const headerText = this.buildRecallHeader(summary);
2552
+ const questionEntry = {
2553
+ index: entries.length,
2554
+ participant: 'Context Manager',
2555
+ content: [{ type: 'text', text: headerText }],
2556
+ sourceRelation: 'derived',
2557
+ };
2558
+ const answerContent = [{ type: 'text', text: summary.content }];
2559
+ const answerEntry = {
2560
+ index: entries.length + 1,
2561
+ participant: summaryParticipant,
2562
+ content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
2563
+ sourceRelation: 'derived',
2564
+ };
2565
+ const pairTokens = this.estimateTokens(questionEntry.content) +
2566
+ this.estimateTokens(answerEntry.content);
2567
+ if (this.isOverBudget(totalTokens + pairTokens, maxTokens))
2568
+ break;
2569
+ entries.push(questionEntry);
2570
+ entries.push(answerEntry);
2571
+ totalTokens += pairTokens;
2572
+ }
2573
+ else {
2574
+ const msg = item.msg;
2575
+ const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
2576
+ const tokens = msgCap > 0
2577
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
2578
+ : store.estimateTokens(msg);
2579
+ if (this.isOverBudget(totalTokens + tokens, maxTokens))
2580
+ break;
2581
+ entries.push({
2582
+ index: entries.length,
2583
+ sourceMessageId: msg.id,
2584
+ sourceRelation: 'copy',
2585
+ participant: msg.participant,
2586
+ content,
2587
+ });
2588
+ totalTokens += tokens;
2589
+ }
2590
+ }
2591
+ }
2592
+ else {
2593
+ // Legacy combined-pair mode for summaries; pins still emit raw at
2594
+ // their positions after the combined pair.
2595
+ if (selectedSummaries.length > 0) {
2596
+ const contextLabel = this.config.summaryContextLabel ?? 'What do you remember from earlier?';
2597
+ const combinedText = selectedSummaries.map(s => s.content).join('\n\n---\n\n');
2598
+ const questionEntry = {
2599
+ index: entries.length,
2600
+ participant: 'Context Manager',
2601
+ content: [{ type: 'text', text: contextLabel }],
2602
+ sourceRelation: 'derived',
2603
+ };
2604
+ // Synthesised summary turns must respect maxMessageTokens. With L1+L2+L3
2605
+ // budgets defaulting to 30k each, an unconstrained concatenation can push
2606
+ // a single assistant turn past 90k tokens, eating the inference budget
2607
+ // and starving recent messages (postmortem 2026-05-04, bug B).
2608
+ const answerContent = [{ type: 'text', text: combinedText }];
2609
+ const answerEntry = {
2610
+ index: entries.length + 1,
2611
+ participant: summaryParticipant,
2612
+ content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
2613
+ sourceRelation: 'derived',
2614
+ };
2615
+ const pairTokens = this.estimateTokens(questionEntry.content) +
2616
+ this.estimateTokens(answerEntry.content);
2617
+ entries.push(questionEntry);
2618
+ entries.push(answerEntry);
2619
+ totalTokens += pairTokens;
2620
+ }
2621
+ // Sort by position so uncompressed-middle messages and pins both
2622
+ // appear in their chronological place after the combined recall pair.
2623
+ const middleRawSorted = [...middleRaw].sort((a, b) => a.position - b.position);
2624
+ for (const { msg } of middleRawSorted) {
2625
+ const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
2626
+ const tokens = msgCap > 0
2627
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
2628
+ : store.estimateTokens(msg);
2629
+ if (this.isOverBudget(totalTokens + tokens, maxTokens))
2630
+ break;
2631
+ entries.push({
2632
+ index: entries.length,
2633
+ sourceMessageId: msg.id,
2634
+ sourceRelation: 'copy',
2635
+ participant: msg.participant,
2636
+ content,
2637
+ });
2638
+ totalTokens += tokens;
2639
+ }
2640
+ }
709
2641
  }
710
2642
  // Phase 4: Recent uncompressed messages (skip head window overlap).
711
2643
  // Newest-first eviction so that when summaries/head consume most of the
@@ -714,24 +2646,104 @@ export class AutobiographicalStrategy {
714
2646
  const effectiveRecentStart = Math.max(recentStart, headEnd);
715
2647
  this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
716
2648
  this.trimOrphanedToolUse(entries);
2649
+ this.pruneToolEntries(entries);
717
2650
  return entries;
718
2651
  }
719
2652
  // ============================================================================
720
2653
  // Overridable hooks (for subclass customization)
721
2654
  // ============================================================================
722
2655
  /**
723
- * Build the compression instruction for an L1 chunk.
724
- * Override in subclasses for phase-aware prompts.
2656
+ * Build the compression instruction for an L1 chunk in the hierarchical
2657
+ * path. Override in subclasses for domain-specific prompts (e.g.,
2658
+ * phase-aware prompts in KnowledgeStrategy).
2659
+ *
2660
+ * Default returns the KV-preserving first-person instruction matching
2661
+ * the hermes-autobio spec. The doc/reading-mode variant is exposed via
2662
+ * {@link getReadingChunkInstruction}.
725
2663
  */
726
2664
  getCompressionInstruction(chunk, targetTokens) {
727
- return `Starting from my last message, please describe everything that has happened. Aim for about ${targetTokens} tokens. Describe it as you would to yourself, as if you are remembering what has happened.`;
2665
+ return formatInstruction(targetTokens);
2666
+ }
2667
+ /**
2668
+ * Build the compression instruction for an L1 chunk that is part of a
2669
+ * substantially larger sharded message (reading mode). Override in
2670
+ * subclasses if domain logic needs to vary the reading-mode prompt.
2671
+ *
2672
+ * Default returns the reading-mode instruction that asks the model to
2673
+ * reflect on what reading was like rather than form a memory "of what
2674
+ * the chunk contained", which prevents voice drift into the content
2675
+ * author's perspective.
2676
+ */
2677
+ getReadingChunkInstruction(chunk, totalTokens, targetTokens) {
2678
+ return formatReadingChunkInstruction(totalTokens, targetTokens);
2679
+ }
2680
+ /**
2681
+ * If the chunk is part of a substantially larger sharded message (total
2682
+ * bodyGroup tokens ≥ 2× the chunk's own tokens), return reading-context
2683
+ * metadata for the reading instruction. The 2× threshold means the
2684
+ * chunk represents a portion of something significantly larger — the
2685
+ * agent is reading, not conversing.
2686
+ *
2687
+ * Returns null when the chunk is a whole message (no bodyGroup), or
2688
+ * when bodyGroup total is < 2× chunk size (degenerate case — chunk
2689
+ * effectively IS the whole message). In those cases the standard
2690
+ * (non-reading) instruction is appropriate.
2691
+ */
2692
+ detectDocContext(chunk, ctx) {
2693
+ if (chunk.messages.length === 0)
2694
+ return null;
2695
+ const firstGroupId = chunk.messages[0].bodyGroupId;
2696
+ if (!firstGroupId)
2697
+ return null;
2698
+ // All messages in the chunk must share the same bodyGroupId
2699
+ for (const m of chunk.messages) {
2700
+ if (m.bodyGroupId !== firstGroupId)
2701
+ return null;
2702
+ }
2703
+ // Total tokens of the original message (sum of all shards in the bodyGroup).
2704
+ const allMessages = ctx.messageStore.getAll();
2705
+ let totalTokens = 0;
2706
+ for (const m of allMessages) {
2707
+ if (m.bodyGroupId === firstGroupId) {
2708
+ totalTokens += ctx.messageStore.estimateTokens(m);
2709
+ }
2710
+ }
2711
+ // Tokens in this chunk specifically.
2712
+ let chunkTokens = 0;
2713
+ for (const m of chunk.messages) {
2714
+ chunkTokens += ctx.messageStore.estimateTokens(m);
2715
+ }
2716
+ // Reading-mode threshold: the original message must be substantially
2717
+ // larger than this chunk. 2× means the chunk is at most half of the
2718
+ // whole — clearly a portion of something bigger.
2719
+ if (chunkTokens === 0 || totalTokens < 2 * chunkTokens)
2720
+ return null;
2721
+ return {
2722
+ totalTokens,
2723
+ chunkTokens,
2724
+ };
728
2725
  }
729
2726
  /**
730
2727
  * Build the merge instruction for combining summaries into a higher level.
731
2728
  * Override in subclasses for domain-specific merge prompts.
2729
+ *
2730
+ * Default returns the KV-preserving merge instruction. The reading-mode
2731
+ * variant (used when all leaves share a bodyGroup of substantial size)
2732
+ * is exposed via {@link getReadingMergeInstruction}.
732
2733
  */
733
2734
  getMergeInstruction(targetLevel, sources, targetTokens) {
734
- return `Please consolidate the memories since my last message into a single cohesive memory. Aim for about ${targetTokens} tokens. Write as you would to yourself — this is your autobiography, capturing the arc of what happened.`;
2735
+ const sourceLevelShown = sources.length > 0 ? Math.max(0, sources[0].level - 1) : 0;
2736
+ return formatMergeInstruction(targetLevel, sourceLevelShown, targetTokens);
2737
+ }
2738
+ /**
2739
+ * Build the reading-mode merge instruction. Used when all leaf messages
2740
+ * underlying the merge share a bodyGroup of substantial size — the agent
2741
+ * has been reading a doc rather than conversing. Override in subclasses
2742
+ * if domain logic needs to vary the reading-mode merge prompt.
2743
+ */
2744
+ getReadingMergeInstruction(targetLevel, sources, totalTokens, targetTokens) {
2745
+ const sourceLevelShown = sources.length > 0 ? Math.max(0, sources[0].level - 1) : 0;
2746
+ return formatReadingMergeInstruction(targetLevel, sourceLevelShown, totalTokens, targetTokens);
735
2747
  }
736
2748
  /**
737
2749
  * Select L1 summaries within a budget. Returns selected summaries and tokens used.
@@ -743,13 +2755,56 @@ export class AutobiographicalStrategy {
743
2755
  for (const s of shownL1) {
744
2756
  if (used + s.tokens > budget)
745
2757
  break;
746
- if (used + s.tokens > maxTokens)
2758
+ if (this.isOverBudget(used + s.tokens, maxTokens))
747
2759
  break;
748
2760
  selected.push(s);
749
2761
  used += s.tokens;
750
2762
  }
751
2763
  return { selected, tokensUsed: used };
752
2764
  }
2765
+ /**
2766
+ * True if `projectedTotal` exceeds `max` AND the strategy is configured
2767
+ * to enforce budget. When `enforceBudget: false`, always returns false
2768
+ * — the rendering pipeline emits the full ideal context regardless of
2769
+ * budget overage. Caller's API will reject if it exceeds the model's
2770
+ * context window; the philosophy is "surface overage, don't hide it."
2771
+ */
2772
+ isOverBudget(projectedTotal, max) {
2773
+ if (this.config.enforceBudget === false)
2774
+ return false;
2775
+ return projectedTotal > max;
2776
+ }
2777
+ /**
2778
+ * Sort selected summaries by source-range start position, so per-pair
2779
+ * recall emission appears in chronological order. Falls back to the
2780
+ * created timestamp for summaries whose source-range first message is
2781
+ * no longer in the store.
2782
+ */
2783
+ sortSummariesChronologically(summaries, messages) {
2784
+ const positionOf = new Map();
2785
+ for (let i = 0; i < messages.length; i++) {
2786
+ positionOf.set(messages[i].id, i);
2787
+ }
2788
+ return [...summaries].sort((a, b) => {
2789
+ const posA = positionOf.get(a.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
2790
+ const posB = positionOf.get(b.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
2791
+ if (posA !== posB)
2792
+ return posA - posB;
2793
+ return a.created - b.created;
2794
+ });
2795
+ }
2796
+ /**
2797
+ * Render the per-pair recall header from the configured template.
2798
+ * Substitutions: {id} {level} {first} {last}.
2799
+ */
2800
+ buildRecallHeader(summary) {
2801
+ const template = this.config.recallHeaderTemplate ?? '[Recall {id}]';
2802
+ return template
2803
+ .replace(/\{id\}/g, summary.id)
2804
+ .replace(/\{level\}/g, String(summary.level))
2805
+ .replace(/\{first\}/g, summary.sourceRange.first)
2806
+ .replace(/\{last\}/g, summary.sourceRange.last);
2807
+ }
753
2808
  // ============================================================================
754
2809
  // Head window reset / topic transition
755
2810
  // ============================================================================
@@ -809,7 +2864,6 @@ export class AutobiographicalStrategy {
809
2864
  config: {
810
2865
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
811
2866
  maxTokens: 1500,
812
- temperature: 0,
813
2867
  },
814
2868
  };
815
2869
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
@@ -838,16 +2892,26 @@ export class AutobiographicalStrategy {
838
2892
  // Shared utilities
839
2893
  // ============================================================================
840
2894
  /**
841
- * Get messages in the compressible zone: outside both head window and recent window.
842
- * Returns messages from [0, headStart) [headEnd, recentStart).
2895
+ * Get messages in the compressible zone: outside both head window and
2896
+ * recent window AND not inside any pinned range. Returns messages from
2897
+ * [0, headStart) ∪ [headEnd, recentStart) minus any positions covered
2898
+ * by a pin or document mark.
843
2899
  */
844
2900
  getCompressibleMessages(store) {
845
2901
  const messages = store.getAll();
846
2902
  const headStart = this.getHeadWindowStartIndex(store);
847
2903
  const headEnd = this.getHeadWindowEnd(store);
848
2904
  const recentStart = this.getRecentWindowStart(store);
849
- return messages.slice(0, recentStart)
850
- .filter((_, i) => i < headStart || i >= headEnd);
2905
+ const pinned = this.pinnedPositions(messages);
2906
+ const out = [];
2907
+ for (let i = 0; i < recentStart; i++) {
2908
+ if (i >= headStart && i < headEnd)
2909
+ continue;
2910
+ if (pinned.has(i))
2911
+ continue;
2912
+ out.push(messages[i]);
2913
+ }
2914
+ return out;
851
2915
  }
852
2916
  /**
853
2917
  * Rebuild chunk boundaries based on current messages.
@@ -1012,6 +3076,92 @@ export class AutobiographicalStrategy {
1012
3076
  }
1013
3077
  }
1014
3078
  }
3079
+ /**
3080
+ * Prune tool_use / tool_result blocks in-place:
3081
+ * 1. Truncate `tool_use.input` blocks whose serialized JSON exceeds
3082
+ * `toolUseInputMaxTokens`.
3083
+ * 2. For each tool name, keep only the last N `tool_result` blocks
3084
+ * per `toolResultMaxLastN`; older ones get their `content` replaced
3085
+ * with a brief marker referencing the tool name and how many newer
3086
+ * results exist below.
3087
+ *
3088
+ * Both passes are no-ops when the corresponding config is unset/0.
3089
+ * Pruning runs AFTER selection and orphan-trimming, so it doesn't
3090
+ * affect chunk formation or the recall/pin layout.
3091
+ */
3092
+ pruneToolEntries(entries) {
3093
+ // Pass 1: build toolUseId → toolName map and apply input truncation
3094
+ const toolUseInputCap = this.config.toolUseInputMaxTokens ?? 0;
3095
+ const toolUseIdToName = new Map();
3096
+ for (const entry of entries) {
3097
+ for (let i = 0; i < entry.content.length; i++) {
3098
+ const block = entry.content[i];
3099
+ if (block.type !== 'tool_use')
3100
+ continue;
3101
+ toolUseIdToName.set(block.id, block.name);
3102
+ if (toolUseInputCap > 0) {
3103
+ const inputJson = JSON.stringify(block.input);
3104
+ const inputTokens = Math.ceil(inputJson.length / 4);
3105
+ if (inputTokens > toolUseInputCap) {
3106
+ const keys = Object.keys(block.input).slice(0, 5);
3107
+ entry.content[i] = {
3108
+ ...block,
3109
+ input: {
3110
+ _truncated: true,
3111
+ _originalTokens: inputTokens,
3112
+ _keys: keys,
3113
+ },
3114
+ };
3115
+ }
3116
+ }
3117
+ }
3118
+ }
3119
+ // Pass 2: collect tool_result occurrences per tool name, in order
3120
+ const occurrencesByTool = new Map();
3121
+ for (const entry of entries) {
3122
+ for (let i = 0; i < entry.content.length; i++) {
3123
+ const block = entry.content[i];
3124
+ if (block.type !== 'tool_result')
3125
+ continue;
3126
+ const toolName = toolUseIdToName.get(block.toolUseId);
3127
+ if (!toolName)
3128
+ continue;
3129
+ let arr = occurrencesByTool.get(toolName);
3130
+ if (!arr) {
3131
+ arr = [];
3132
+ occurrencesByTool.set(toolName, arr);
3133
+ }
3134
+ arr.push({ entry, blockIndex: i });
3135
+ }
3136
+ }
3137
+ // Pass 3: apply per-tool max-last-N
3138
+ const cfg = this.config.toolResultMaxLastN;
3139
+ if (cfg === undefined)
3140
+ return;
3141
+ for (const [toolName, occs] of occurrencesByTool) {
3142
+ let limit;
3143
+ if (typeof cfg === 'number')
3144
+ limit = cfg;
3145
+ else if (typeof cfg === 'object')
3146
+ limit = cfg[toolName];
3147
+ if (limit === undefined || limit < 0)
3148
+ continue;
3149
+ const excessCount = occs.length - limit;
3150
+ if (excessCount <= 0)
3151
+ continue;
3152
+ for (let i = 0; i < excessCount; i++) {
3153
+ const { entry, blockIndex } = occs[i];
3154
+ const orig = entry.content[blockIndex];
3155
+ if (orig.type !== 'tool_result')
3156
+ continue;
3157
+ const fresherCount = occs.length - i - 1;
3158
+ entry.content[blockIndex] = {
3159
+ ...orig,
3160
+ content: `[Result truncated — tool '${toolName}' has ${fresherCount} more recent result${fresherCount === 1 ? '' : 's'} below]`,
3161
+ };
3162
+ }
3163
+ }
3164
+ }
1015
3165
  isChunkOldEnough(chunk) {
1016
3166
  return true;
1017
3167
  }