@animalabs/context-manager 0.4.0 → 0.5.1

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 (128) 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 +5 -0
  30. package/dist/src/context-manager.d.ts.map +1 -1
  31. package/dist/src/context-manager.js +22 -0
  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 +217 -2
  41. package/dist/src/strategies/autobiographical.d.ts.map +1 -1
  42. package/dist/src/strategies/autobiographical.js +1525 -37
  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/message.d.ts +38 -0
  48. package/dist/src/types/message.d.ts.map +1 -1
  49. package/dist/src/types/strategy.d.ts +106 -10
  50. package/dist/src/types/strategy.d.ts.map +1 -1
  51. package/dist/src/types/strategy.js +7 -0
  52. package/dist/src/types/strategy.js.map +1 -1
  53. package/dist/test/adaptive/branching.test.d.ts +20 -0
  54. package/dist/test/adaptive/branching.test.d.ts.map +1 -0
  55. package/dist/test/adaptive/branching.test.js +176 -0
  56. package/dist/test/adaptive/branching.test.js.map +1 -0
  57. package/dist/test/adaptive/chunker.test.d.ts +2 -0
  58. package/dist/test/adaptive/chunker.test.d.ts.map +1 -0
  59. package/dist/test/adaptive/chunker.test.js +120 -0
  60. package/dist/test/adaptive/chunker.test.js.map +1 -0
  61. package/dist/test/adaptive/deep-levels.test.d.ts +10 -0
  62. package/dist/test/adaptive/deep-levels.test.d.ts.map +1 -0
  63. package/dist/test/adaptive/deep-levels.test.js +167 -0
  64. package/dist/test/adaptive/deep-levels.test.js.map +1 -0
  65. package/dist/test/adaptive/doc-plus-chat.test.d.ts +10 -0
  66. package/dist/test/adaptive/doc-plus-chat.test.d.ts.map +1 -0
  67. package/dist/test/adaptive/doc-plus-chat.test.js +194 -0
  68. package/dist/test/adaptive/doc-plus-chat.test.js.map +1 -0
  69. package/dist/test/adaptive/hard-fail.test.d.ts +9 -0
  70. package/dist/test/adaptive/hard-fail.test.d.ts.map +1 -0
  71. package/dist/test/adaptive/hard-fail.test.js +154 -0
  72. package/dist/test/adaptive/hard-fail.test.js.map +1 -0
  73. package/dist/test/adaptive/harness.d.ts +109 -0
  74. package/dist/test/adaptive/harness.d.ts.map +1 -0
  75. package/dist/test/adaptive/harness.js +263 -0
  76. package/dist/test/adaptive/harness.js.map +1 -0
  77. package/dist/test/adaptive/ingestion.test.d.ts +13 -0
  78. package/dist/test/adaptive/ingestion.test.d.ts.map +1 -0
  79. package/dist/test/adaptive/ingestion.test.js +306 -0
  80. package/dist/test/adaptive/ingestion.test.js.map +1 -0
  81. package/dist/test/adaptive/integration.test.d.ts +12 -0
  82. package/dist/test/adaptive/integration.test.d.ts.map +1 -0
  83. package/dist/test/adaptive/integration.test.js +247 -0
  84. package/dist/test/adaptive/integration.test.js.map +1 -0
  85. package/dist/test/adaptive/long-chronicle.test.d.ts +13 -0
  86. package/dist/test/adaptive/long-chronicle.test.d.ts.map +1 -0
  87. package/dist/test/adaptive/long-chronicle.test.js +186 -0
  88. package/dist/test/adaptive/long-chronicle.test.js.map +1 -0
  89. package/dist/test/adaptive/persistence.test.d.ts +11 -0
  90. package/dist/test/adaptive/persistence.test.d.ts.map +1 -0
  91. package/dist/test/adaptive/persistence.test.js +200 -0
  92. package/dist/test/adaptive/persistence.test.js.map +1 -0
  93. package/dist/test/adaptive/picker.test.d.ts +2 -0
  94. package/dist/test/adaptive/picker.test.d.ts.map +1 -0
  95. package/dist/test/adaptive/picker.test.js +333 -0
  96. package/dist/test/adaptive/picker.test.js.map +1 -0
  97. package/dist/test/adaptive/render.test.d.ts +2 -0
  98. package/dist/test/adaptive/render.test.d.ts.map +1 -0
  99. package/dist/test/adaptive/render.test.js +109 -0
  100. package/dist/test/adaptive/render.test.js.map +1 -0
  101. package/dist/test/adaptive/shard-immutability.test.d.ts +14 -0
  102. package/dist/test/adaptive/shard-immutability.test.d.ts.map +1 -0
  103. package/dist/test/adaptive/shard-immutability.test.js +184 -0
  104. package/dist/test/adaptive/shard-immutability.test.js.map +1 -0
  105. package/dist/test/adaptive/strategy-integration.test.d.ts +9 -0
  106. package/dist/test/adaptive/strategy-integration.test.d.ts.map +1 -0
  107. package/dist/test/adaptive/strategy-integration.test.js +293 -0
  108. package/dist/test/adaptive/strategy-integration.test.js.map +1 -0
  109. package/dist/test/message-store-sequence.test.d.ts +25 -0
  110. package/dist/test/message-store-sequence.test.d.ts.map +1 -0
  111. package/dist/test/message-store-sequence.test.js +157 -0
  112. package/dist/test/message-store-sequence.test.js.map +1 -0
  113. package/dist/tsconfig.tsbuildinfo +1 -1
  114. package/package.json +1 -1
  115. package/src/adaptive/chunker.ts +323 -0
  116. package/src/adaptive/folding-strategy.ts +129 -0
  117. package/src/adaptive/index.ts +51 -0
  118. package/src/adaptive/picker.ts +442 -0
  119. package/src/adaptive/render.ts +115 -0
  120. package/src/adaptive/strategies/flat-profile.ts +96 -0
  121. package/src/adaptive/strategies/oldest-first.ts +48 -0
  122. package/src/context-manager.ts +32 -0
  123. package/src/index.ts +1 -1
  124. package/src/message-store.ts +147 -8
  125. package/src/strategies/autobiographical.ts +1635 -38
  126. package/src/strategies/index.ts +1 -1
  127. package/src/types/message.ts +43 -0
  128. package/src/types/strategy.ts +122 -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).
@@ -47,10 +182,30 @@ export class AutobiographicalStrategy {
47
182
  get counterStateId() { return `${this.ns}/autobio:counter`; }
48
183
  get mergeQueueStateId() { return `${this.ns}/autobio:mergeQueue`; }
49
184
  get pinsStateId() { return `${this.ns}/autobio:pins`; }
185
+ get resolutionsStateId() { return `${this.ns}/autobio:resolutions`; }
186
+ get locksStateId() { return `${this.ns}/autobio:locks`; }
50
187
  /** Protected ranges (pins + documents). Loaded from chronicle in initialize. */
51
188
  pins = [];
52
189
  /** Monotonically increasing counter for pin ids. Persisted as part of the pins snapshot. */
53
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;
54
209
  constructor(config = {}) {
55
210
  this.config = { ...DEFAULT_AUTOBIOGRAPHICAL_CONFIG, ...config };
56
211
  // Hierarchical is on by default; set hierarchical: false to use legacy single-level
@@ -62,6 +217,93 @@ export class AutobiographicalStrategy {
62
217
  this.config.l2BudgetTokens ??= 30000;
63
218
  this.config.l1BudgetTokens ??= 30000;
64
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
+ };
65
307
  }
66
308
  async initialize(ctx) {
67
309
  // Bind to the chronicle store + namespace for persistent strategy state.
@@ -118,6 +360,24 @@ export class AutobiographicalStrategy {
118
360
  });
119
361
  }
120
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
+ }
121
381
  }
122
382
  /**
123
383
  * Load summaries, counter, and pending merges from chronicle into the
@@ -151,6 +411,26 @@ export class AutobiographicalStrategy {
151
411
  this.pins = [];
152
412
  this.pinIdCounter = 0;
153
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
+ }
154
434
  }
155
435
  /** Persist the current pins + counter as a single snapshot. */
156
436
  persistPins() {
@@ -159,6 +439,24 @@ export class AutobiographicalStrategy {
159
439
  counter: this.pinIdCounter,
160
440
  });
161
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
+ }
162
460
  // ============================================================================
163
461
  // Pins / documents (protected ranges)
164
462
  // ============================================================================
@@ -356,6 +654,137 @@ export class AutobiographicalStrategy {
356
654
  this.store?.setStateJson(this.mergeQueueStateId, this.mergeQueue);
357
655
  return merge;
358
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
+ }
359
788
  checkReadiness() {
360
789
  if (this.pendingCompression) {
361
790
  return {
@@ -501,8 +930,40 @@ export class AutobiographicalStrategy {
501
930
  }
502
931
  }
503
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
+ }
504
962
  select(store, log, budget) {
505
963
  this.rebuildChunks(store);
964
+ if (this.config.adaptiveResolution) {
965
+ return this.selectAdaptive(store, budget);
966
+ }
506
967
  return this.config.hierarchical
507
968
  ? this.selectHierarchical(store, budget)
508
969
  : this.selectLegacy(store, log, budget);
@@ -823,45 +1284,183 @@ export class AutobiographicalStrategy {
823
1284
  if (!ctx.membrane) {
824
1285
  throw new Error('No membrane instance for compression');
825
1286
  }
826
- const chunkMessageIds = new Set(chunk.messages.map(m => m.id));
827
- const { shownL3, shownL2, shownL1 } = this.getAntiRedundantSummaries(chunkMessageIds);
828
1287
  const targetTokens = this.config.summaryTargetTokens ?? 2000;
829
- const chunkContent = this.formatChunkForCompression(chunk);
830
- // 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 summaries — narrativized as CM-asks / agent-recalls
1292
+ // pairs, in source order. The unmerged frontier of the
1293
+ // summary forest: any summary that has not yet been merged
1294
+ // into a higher level. After merges run, the L_{k+1} replaces
1295
+ // its L_k children — using the children plus their parent
1296
+ // doubles the prompt size unboundedly.
1297
+ // 2. Head — raw messages before the chunk that aren't already
1298
+ // represented by a prior summary.
1299
+ // 3. Marker — in-band signal that a memory is about to form.
1300
+ // 4. Chunk — raw messages being compressed, as the agent
1301
+ // experienced them.
1302
+ // 5. Instruction — doc-aware if the chunk is part of a bodyGroup.
1303
+ //
1304
+ // There is intentionally NO tail_after_chunk: that would leak
1305
+ // future information into the model's KV state and corrupt the
1306
+ // as-of framing of memory formation.
831
1307
  const llmMessages = [];
832
- // Prior summaries as agent's own recollections (L3 → L2 → L1 gradient)
833
- const allPriorSummaries = [...shownL3, ...shownL2, ...shownL1];
834
- for (const s of allPriorSummaries) {
1308
+ // ---- 1. Prior recall pairs ----
1309
+ // Filter to the unmerged frontier: any summary whose `mergedInto`
1310
+ // is unset. After merge, the children's mergedInto points at the
1311
+ // parent and the parent stands alone with that source range. The
1312
+ // original "ALL L1s regardless of merge state" rule was a fidelity
1313
+ // optimization that scales catastrophically: a 4000-message import
1314
+ // converged to ~500 L1s that never aged out, blowing the 200k
1315
+ // window around chunk 118.
1316
+ const priorSummaries = this.summaries
1317
+ .filter((s) => !s.mergedInto)
1318
+ .sort((a, b) => a.sourceRange.first.localeCompare(b.sourceRange.first));
1319
+ // Token-budget cap on recall pairs as defense-in-depth. With merged
1320
+ // exclusion the set is bounded by `mergeThreshold^depth`, but a
1321
+ // conversation with thousands of unmerged top-level summaries can
1322
+ // still overflow. Newest-first selection so proximate context is
1323
+ // preferred; the kept set is re-sorted chronologically below. We
1324
+ // always keep at least one summary even if it's larger than the
1325
+ // budget — otherwise a single oversized summary would leave the
1326
+ // model with no compressed context at all.
1327
+ const recallBudget = this.config.compressionRecallBudgetTokens ?? 150_000;
1328
+ const keptSummaries = [];
1329
+ let recallTokens = 0;
1330
+ for (let i = priorSummaries.length - 1; i >= 0; i--) {
1331
+ const s = priorSummaries[i];
1332
+ const approxTokens = Math.ceil(s.content.length / 4);
1333
+ if (recallTokens + approxTokens > recallBudget && keptSummaries.length > 0)
1334
+ break;
1335
+ keptSummaries.push(s);
1336
+ recallTokens += approxTokens;
1337
+ }
1338
+ keptSummaries.reverse();
1339
+ if (keptSummaries.length < priorSummaries.length) {
1340
+ const dropped = priorSummaries.length - keptSummaries.length;
1341
+ console.warn(`autobio: recall-pair budget capped (${keptSummaries.length}/${priorSummaries.length} summaries kept, ` +
1342
+ `~${recallTokens} tokens, budget ${recallBudget}; ${dropped} oldest dropped this compression).`);
1343
+ logCompressionCall({
1344
+ event: 'recall-budget-capped',
1345
+ kept: keptSummaries.length,
1346
+ total: priorSummaries.length,
1347
+ tokens: recallTokens,
1348
+ budgetTokens: recallBudget,
1349
+ });
1350
+ }
1351
+ for (const s of keptSummaries) {
1352
+ llmMessages.push({
1353
+ participant: 'Context Manager',
1354
+ content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
1355
+ });
835
1356
  llmMessages.push({
836
- participant: this.config.summaryParticipant ?? 'Claude',
1357
+ participant: agentParticipant,
837
1358
  content: [{ type: 'text', text: s.content }],
838
1359
  });
839
1360
  }
840
- // Context Manager instruction with chunk content
841
- const instruction = this.getCompressionInstruction(chunk, targetTokens);
1361
+ // ---- 2. Head window (raw, ALWAYS present) ----
1362
+ //
1363
+ // The head is the foundational identity anchor: the actual opening
1364
+ // of the chronicle (the user's first message, the agent's first
1365
+ // reply, the system context if any). It establishes WHO is speaking
1366
+ // to WHOM. Without it, when the chunk content is heavily first-person
1367
+ // from someone other than the agent (e.g., a user-shared document),
1368
+ // the agent loses its first-person grounding and drifts into the
1369
+ // content author's voice.
1370
+ //
1371
+ // The head is the configured head window — not "everything before
1372
+ // the chunk." For doc-heavy chronicles, "everything before" would
1373
+ // be hundreds of thousands of tokens; the recall pairs already
1374
+ // represent that intermediate content. The head is just the
1375
+ // permanent prefix that the original instance always saw.
1376
+ //
1377
+ // Head messages are excluded from compression by `getCompressibleMessages`
1378
+ // (they're outside the chunking range), so they won't appear in
1379
+ // any L1's sourceIds — no overlap with the recall pairs above.
1380
+ const allMessages = ctx.messageStore.getAll();
1381
+ const headStartIdx = this.getHeadWindowStartIndex(ctx.messageStore);
1382
+ const headEndIdx = this.getHeadWindowEnd(ctx.messageStore);
1383
+ for (let i = headStartIdx; i < headEndIdx && i < allMessages.length; i++) {
1384
+ const m = allMessages[i];
1385
+ llmMessages.push({ participant: m.participant, content: m.content });
1386
+ }
1387
+ // Any raw messages between the head and the chunk that aren't yet
1388
+ // represented by any summary — usually empty in adaptive-resolution
1389
+ // mode, since chunking proceeds contiguously and summaries cover
1390
+ // everything up to the chunk being processed. Uses the full
1391
+ // priorSummaries set (not the budget-capped keptSummaries) because
1392
+ // the dedup question is "is this raw message covered by *any* live
1393
+ // summary?" — a budget-dropped summary doesn't make the underlying
1394
+ // raw messages reappear.
1395
+ const chunkFirstId = chunk.messages[0]?.id;
1396
+ if (chunkFirstId) {
1397
+ const priorSummaryMessageIds = new Set();
1398
+ for (const s of priorSummaries) {
1399
+ for (const id of s.sourceIds)
1400
+ priorSummaryMessageIds.add(id);
1401
+ }
1402
+ const chunkStartIdx = allMessages.findIndex((m) => m.id === chunkFirstId);
1403
+ for (let i = headEndIdx; i < chunkStartIdx && i < allMessages.length; i++) {
1404
+ const m = allMessages[i];
1405
+ if (priorSummaryMessageIds.has(m.id))
1406
+ continue;
1407
+ llmMessages.push({ participant: m.participant, content: m.content });
1408
+ }
1409
+ }
1410
+ // ---- 3. In-band marker ----
842
1411
  llmMessages.push({
843
1412
  participant: 'Context Manager',
844
- content: [{
845
- type: 'text',
846
- text: `[Context Manager] We are ready to form a long-term memory. Here is the conversation to remember:\n\n${chunkContent}\n\n${instruction}`,
847
- }],
1413
+ content: [{ type: 'text', text: COMPRESSION_MARKER }],
1414
+ });
1415
+ // ---- 4. Chunk messages raw ----
1416
+ for (const m of chunk.messages) {
1417
+ llmMessages.push({ participant: m.participant, content: m.content });
1418
+ }
1419
+ // ---- 5. Instruction (reading-mode aware) ----
1420
+ //
1421
+ // When the chunk is a portion of a substantially larger sharded message
1422
+ // (≥ 2× chunk size), use the reading-mode instruction. It avoids the
1423
+ // "form a memory of what this contained" framing — which, for content
1424
+ // heavily first-person from someone other than the agent (a user-shared
1425
+ // doc), leads the model to adopt the content author's voice. Instead,
1426
+ // it asks what reading was like and what was learned, forcing the
1427
+ // model to reflect from its own vantage point in agent-first-person.
1428
+ const docContext = this.detectDocContext(chunk, ctx);
1429
+ const instructionText = docContext
1430
+ ? this.getReadingChunkInstruction(chunk, docContext.totalTokens, targetTokens)
1431
+ : this.getCompressionInstruction(chunk, targetTokens);
1432
+ llmMessages.push({
1433
+ participant: 'Context Manager',
1434
+ content: [{ type: 'text', text: instructionText }],
848
1435
  });
849
1436
  // Collapse consecutive same-participant messages for API compliance
850
1437
  const collapsed = this.collapseConsecutiveMessages(llmMessages);
1438
+ // NO system prompt. The agent's identity is established by the head
1439
+ // (the actual conversation opening — user message + agent reply that
1440
+ // grounded the original instance). A system prompt would (a) add a
1441
+ // synthetic header the original instance never saw, disturbing KV
1442
+ // consistency between the summarizer and the original instance, and
1443
+ // (b) provide an alternative identity source that competes with the
1444
+ // structural one carried by the conversation itself. Anchoring
1445
+ // identity by the chronicle's actual head is more honest.
851
1446
  const request = {
852
1447
  messages: collapsed.map(m => ({ participant: m.participant, content: m.content })),
853
- system: 'You are forming autobiographical memories of a conversation.',
854
1448
  config: {
855
1449
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
856
1450
  maxTokens: Math.round(targetTokens * 1.5),
857
1451
  },
858
1452
  };
1453
+ const callStart = Date.now();
1454
+ let logResponse;
1455
+ let logError;
1456
+ let logSummaryId;
859
1457
  try {
860
1458
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
861
1459
  const summaryText = response.content
862
1460
  .filter((b) => b.type === 'text')
863
1461
  .map(b => b.text)
864
1462
  .join('\n');
1463
+ logResponse = summaryText;
865
1464
  const messageIds = chunk.messages.map(m => m.id);
866
1465
  const entry = {
867
1466
  id: `L1-${this.nextSummaryIdCounter()}`,
@@ -881,12 +1480,44 @@ export class AutobiographicalStrategy {
881
1480
  chunk.compressed = true;
882
1481
  chunk.summaryId = entry.id;
883
1482
  this._compressionCount++;
1483
+ logSummaryId = entry.id;
884
1484
  this.checkMergeThreshold();
885
1485
  }
886
1486
  catch (error) {
887
1487
  console.error('Failed to compress chunk (hierarchical):', error);
1488
+ logError = error instanceof Error ? error.message : String(error);
888
1489
  throw error;
889
1490
  }
1491
+ finally {
1492
+ logCompressionCall({
1493
+ operation: 'compress_l1',
1494
+ system: null,
1495
+ messages: collapsed.map((m) => ({
1496
+ participant: m.participant,
1497
+ // Flatten content for logging — store text only; binary content
1498
+ // would bloat the log and isn't typical in compression input.
1499
+ text: m.content
1500
+ .filter((b) => b.type === 'text')
1501
+ .map((b) => b.text)
1502
+ .join(''),
1503
+ })),
1504
+ metadata: {
1505
+ chunk_message_ids: chunk.messages.map((m) => m.id),
1506
+ chunk_size: chunk.messages.length,
1507
+ prior_summary_count: priorSummaries.length,
1508
+ prior_summary_count_kept: keptSummaries.length,
1509
+ prior_summary_tokens: recallTokens,
1510
+ has_doc_context: docContext !== null,
1511
+ doc_context: docContext,
1512
+ target_tokens: targetTokens,
1513
+ model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
1514
+ latency_ms: Date.now() - callStart,
1515
+ summary_id: logSummaryId,
1516
+ },
1517
+ response: logResponse,
1518
+ error: logError,
1519
+ });
1520
+ }
890
1521
  }
891
1522
  /**
892
1523
  * Check if unmerged summary counts exceed the merge threshold.
@@ -898,6 +1529,10 @@ export class AutobiographicalStrategy {
898
1529
  * summaries when the queue eventually drains.
899
1530
  */
900
1531
  checkMergeThreshold() {
1532
+ if (this.config.speculativeProduction) {
1533
+ this.checkMergeThresholdRecursive();
1534
+ return;
1535
+ }
901
1536
  const threshold = this.config.mergeThreshold ?? 6;
902
1537
  // IDs that are already part of a queued merge — exclude them from
903
1538
  // eligibility so we don't re-enqueue.
@@ -927,6 +1562,53 @@ export class AutobiographicalStrategy {
927
1562
  });
928
1563
  }
929
1564
  }
1565
+ /**
1566
+ * Bottom-up speculative pre-producer (design doc §3.5 / §7.2).
1567
+ *
1568
+ * Recursive variant of `checkMergeThreshold` for the unbounded L_n
1569
+ * design. Walks every level present in the archive; for any level k
1570
+ * with ≥ N orphans (no parent), enqueues an L_{k+1} merge. After that
1571
+ * L_{k+1} is produced and `executeMerge` calls this again, the recursion
1572
+ * naturally cascades: 6 L1s → 1 L2; 6 L2s → 1 L3; 6 L3s → 1 L4; ...
1573
+ *
1574
+ * Only fires when `config.speculativeProduction` is true. Default true
1575
+ * for adaptiveResolution=true, false otherwise. The non-speculative path
1576
+ * (above) preserves the original L1→L2→L3 behavior for non-adaptive
1577
+ * deployments.
1578
+ */
1579
+ checkMergeThresholdRecursive() {
1580
+ const threshold = this.config.mergeThreshold ?? 6;
1581
+ // Build per-level sets of source-ids already enqueued for merging,
1582
+ // so we don't re-enqueue them while a merge is pending.
1583
+ const queuedSources = new Map();
1584
+ for (const m of this.mergeQueue) {
1585
+ // m.level is the TARGET level; the sources are at level (m.level - 1).
1586
+ const sourceLevel = m.level - 1;
1587
+ if (!queuedSources.has(sourceLevel))
1588
+ queuedSources.set(sourceLevel, new Set());
1589
+ for (const id of m.sourceIds)
1590
+ queuedSources.get(sourceLevel).add(id);
1591
+ }
1592
+ // Walk every level present in the archive. Iterate from low to high
1593
+ // so when an L_{k+1} merge is enqueued and immediately produced, this
1594
+ // same check sees the new L_{k+1} on the next call and can roll up.
1595
+ let maxLevel = 0;
1596
+ for (const s of this.summaries) {
1597
+ if (s.level > maxLevel)
1598
+ maxLevel = s.level;
1599
+ }
1600
+ for (let level = 1; level <= maxLevel; level++) {
1601
+ const queued = queuedSources.get(level) ?? new Set();
1602
+ const unmerged = this.summaries.filter(s => s.level === level && !getSummaryParentId(s) && !queued.has(s.id));
1603
+ if (unmerged.length >= threshold) {
1604
+ const toMerge = unmerged.slice(0, threshold);
1605
+ this.enqueueMerge({
1606
+ level: level + 1,
1607
+ sourceIds: toMerge.map(s => s.id),
1608
+ });
1609
+ }
1610
+ }
1611
+ }
930
1612
  /**
931
1613
  * Merge N summaries at one level into a single summary at the next level.
932
1614
  * Uses self-voice consolidation prompt.
@@ -952,51 +1634,217 @@ export class AutobiographicalStrategy {
952
1634
  }
953
1635
  const targetTokens = this.config.summaryTargetTokens ?? 2000;
954
1636
  const participant = this.config.summaryParticipant ?? 'Claude';
955
- // Build message array
1637
+ // Build the merge prompt with one-level-deeper target expansion +
1638
+ // prefix of older context:
1639
+ //
1640
+ // 1. PREFIX — head messages + prior L1 recall pairs for content
1641
+ // that comes chronologically BEFORE the merge range. "Fill
1642
+ // lower orbitals first" per the spec: regardless of how
1643
+ // compressed the live view is, the summarizer always gets L1
1644
+ // fidelity for prior content. Older L2/L3 markers exist for
1645
+ // live-view compactness, not for the summarizer.
1646
+ //
1647
+ // 2. TARGET — the sources expanded ONE LEVEL DEEPER than they
1648
+ // themselves are. For L2 merge (sources at L1): expand to
1649
+ // raw L0 messages — the model sees the actual conversation
1650
+ // that the 6 L1s consolidate. For L3 merge (sources at L2):
1651
+ // expand to the L1s under each L2 (36 L1s as recall pairs).
1652
+ // For L_n merge (sources at L_{n-1}): expand to L_{n-2}.
1653
+ // This gives the model substantively more content to ground
1654
+ // the consolidation in than just the 6 surface summaries.
1655
+ //
1656
+ // 3. INSTRUCTION — "consolidate N memories preserving the
1657
+ // through-line, in first person".
1658
+ //
1659
+ // No tail-after-merge: same as-of principle as L1 compression. The
1660
+ // consolidation is being formed at the moment the last source was
1661
+ // ready, so nothing after that is visible.
956
1662
  const llmMessages = [];
957
- // Higher-level context (anti-redundant)
958
- if (targetLevel === 2) {
959
- // For L2 merge: show L3 summaries as context
960
- const shownL3 = this.summaries.filter(s => s.level === 3 && !s.mergedInto);
961
- for (const s of shownL3) {
962
- llmMessages.push({
963
- participant,
964
- content: [{ type: 'text', text: s.content }],
965
- });
1663
+ // Build lookup maps
1664
+ const summariesById = new Map();
1665
+ for (const s of this.summaries)
1666
+ summariesById.set(s.id, s);
1667
+ const allMessages = ctx.messageStore.getAll();
1668
+ const messageById = new Map();
1669
+ for (const m of allMessages)
1670
+ messageById.set(m.id, m);
1671
+ // Compute every leaf message id covered by this merge's lineage —
1672
+ // these are part of the TARGET and must not also appear in the
1673
+ // PREFIX as head content.
1674
+ const sourceLeafIds = new Set();
1675
+ const collectLeaves = (s) => {
1676
+ if (s.sourceLevel === 0) {
1677
+ for (const id of s.sourceIds)
1678
+ sourceLeafIds.add(id);
1679
+ }
1680
+ else {
1681
+ for (const childId of s.sourceIds) {
1682
+ const child = summariesById.get(childId);
1683
+ if (child)
1684
+ collectLeaves(child);
1685
+ }
966
1686
  }
1687
+ };
1688
+ for (const src of sources)
1689
+ collectLeaves(src);
1690
+ // Find the start of the merge range in the message store.
1691
+ const mergeFirstMsgId = sources[0].sourceRange.first;
1692
+ const mergeStartIdx = allMessages.findIndex((m) => m.id === mergeFirstMsgId);
1693
+ // ---- 1a. HEAD WINDOW (raw, ALWAYS present) ----
1694
+ //
1695
+ // The head window is the foundational identity anchor — the actual
1696
+ // opening of the chronicle. It establishes who is speaking to whom.
1697
+ // Without it, when the merge target's content is heavily first-person
1698
+ // from someone other than the agent, the agent loses its first-person
1699
+ // grounding and drifts into the content author's voice.
1700
+ const headStartIdx = this.getHeadWindowStartIndex(ctx.messageStore);
1701
+ const headEndIdx = this.getHeadWindowEnd(ctx.messageStore);
1702
+ for (let i = headStartIdx; i < headEndIdx && i < allMessages.length; i++) {
1703
+ const m = allMessages[i];
1704
+ llmMessages.push({ participant: m.participant, content: m.content });
1705
+ }
1706
+ // ---- 1b. PRIOR L1 RECALL PAIRS (chronologically before merge range) ----
1707
+ // L1s whose entire source range is before the merge range and that
1708
+ // aren't part of the merge tree. Sort by source position.
1709
+ const priorL1s = this.summaries
1710
+ .filter((s) => s.level === 1)
1711
+ .filter((s) => {
1712
+ // Exclude any L1 that's an ancestor of our merge target
1713
+ for (const lid of s.sourceIds)
1714
+ if (sourceLeafIds.has(lid))
1715
+ return false;
1716
+ // Include only if it starts strictly before the merge range
1717
+ const firstIdx = allMessages.findIndex((m) => m.id === s.sourceRange.first);
1718
+ return firstIdx >= 0 && (mergeStartIdx < 0 || firstIdx < mergeStartIdx);
1719
+ })
1720
+ .sort((a, b) => {
1721
+ const ai = allMessages.findIndex((m) => m.id === a.sourceRange.first);
1722
+ const bi = allMessages.findIndex((m) => m.id === b.sourceRange.first);
1723
+ return ai - bi;
1724
+ });
1725
+ const priorL1MessageIds = new Set();
1726
+ for (const s of priorL1s) {
1727
+ for (const id of s.sourceIds)
1728
+ priorL1MessageIds.add(id);
967
1729
  }
968
- // For L3 merge: no higher context exists
969
- // The source summaries as agent's own memories
970
- for (const source of sources) {
1730
+ for (const s of priorL1s) {
1731
+ llmMessages.push({
1732
+ participant: 'Context Manager',
1733
+ content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
1734
+ });
971
1735
  llmMessages.push({
972
1736
  participant,
973
- content: [{ type: 'text', text: source.content }],
1737
+ content: [{ type: 'text', text: s.content }],
974
1738
  });
975
1739
  }
976
- // Consolidation instruction
977
- const mergeInstruction = this.getMergeInstruction(targetLevel, sources, targetTokens);
1740
+ // Raw middle: any messages between the head window and the merge
1741
+ // range that aren't covered by a prior L1 or the merge tree.
1742
+ // Usually empty (chunking is contiguous).
1743
+ if (mergeStartIdx >= 0) {
1744
+ for (let i = headEndIdx; i < mergeStartIdx; i++) {
1745
+ const m = allMessages[i];
1746
+ if (priorL1MessageIds.has(m.id))
1747
+ continue;
1748
+ if (sourceLeafIds.has(m.id))
1749
+ continue;
1750
+ llmMessages.push({ participant: m.participant, content: m.content });
1751
+ }
1752
+ }
1753
+ // ---- 2. TARGET: expand sources one level deeper ----
1754
+ // For L2 (sources at L1, sourceLevel=0): expand to raw L0 messages.
1755
+ // For L3+ (sources at L_{n-1}, sourceLevel=n-2): expand to L_{n-2}
1756
+ // summaries as recall pairs.
1757
+ for (const src of sources) {
1758
+ if (src.sourceLevel === 0) {
1759
+ // Source is an L1; its sourceIds are raw message ids. Emit them raw.
1760
+ for (const messageId of src.sourceIds) {
1761
+ const m = messageById.get(messageId);
1762
+ if (m) {
1763
+ llmMessages.push({ participant: m.participant, content: m.content });
1764
+ }
1765
+ }
1766
+ }
1767
+ else {
1768
+ // Source is L2+; its sourceIds point to summaries one level
1769
+ // below. Emit each as a recall pair.
1770
+ for (const childId of src.sourceIds) {
1771
+ const child = summariesById.get(childId);
1772
+ if (!child)
1773
+ continue;
1774
+ llmMessages.push({
1775
+ participant: 'Context Manager',
1776
+ content: [{ type: 'text', text: `[CM] Recall memory ${child.id}.` }],
1777
+ });
1778
+ llmMessages.push({
1779
+ participant,
1780
+ content: [{ type: 'text', text: child.content }],
1781
+ });
1782
+ }
1783
+ }
1784
+ }
1785
+ // ---- 3. INSTRUCTION ----
1786
+ // sourceLevelShown is the level of content the model actually sees
1787
+ // (one level below the sources themselves).
1788
+ const sourceLevelShown = sources[0].sourceLevel === 0 ? 0 : sources[0].level - 1;
1789
+ // Reading-mode detection: when ALL the merge's leaf messages are
1790
+ // shards of the same bodyGroup, we know the agent was reading a
1791
+ // substantially larger message rather than conversing. The
1792
+ // reading-mode merge instruction asks what reading the stretch was
1793
+ // like instead of asking for an impersonal consolidation, which
1794
+ // forces the agent's vantage point and prevents drift into the
1795
+ // content author's voice. Same principle as the L1 case.
1796
+ let mergeReadingContext = null;
1797
+ if (sourceLeafIds.size > 0) {
1798
+ const leafBodyGroupIds = new Set();
1799
+ for (const leafId of sourceLeafIds) {
1800
+ const m = messageById.get(leafId);
1801
+ leafBodyGroupIds.add(m?.bodyGroupId);
1802
+ }
1803
+ if (leafBodyGroupIds.size === 1) {
1804
+ const groupId = [...leafBodyGroupIds][0];
1805
+ if (groupId) {
1806
+ let totalTokens = 0;
1807
+ for (const m of allMessages) {
1808
+ if (m.bodyGroupId === groupId) {
1809
+ totalTokens += ctx.messageStore.estimateTokens(m);
1810
+ }
1811
+ }
1812
+ mergeReadingContext = { totalTokens };
1813
+ }
1814
+ }
1815
+ }
1816
+ const mergeInstructionText = mergeReadingContext
1817
+ ? this.getReadingMergeInstruction(targetLevel, sources, mergeReadingContext.totalTokens, targetTokens)
1818
+ : this.getMergeInstruction(targetLevel, sources, targetTokens);
978
1819
  llmMessages.push({
979
1820
  participant: 'Context Manager',
980
1821
  content: [{
981
1822
  type: 'text',
982
- text: `[Context Manager] ${mergeInstruction}`,
1823
+ text: mergeInstructionText,
983
1824
  }],
984
1825
  });
985
1826
  const collapsed = this.collapseConsecutiveMessages(llmMessages);
1827
+ // NO system prompt — identity is established by the head window
1828
+ // (present at the start of llmMessages above) and by the prior
1829
+ // recall pairs. Same rationale as compressChunkHierarchical.
986
1830
  const request = {
987
1831
  messages: collapsed.map(m => ({ participant: m.participant, content: m.content })),
988
- system: 'You are forming autobiographical memories of a conversation.',
989
1832
  config: {
990
1833
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
991
1834
  maxTokens: Math.round(targetTokens * 1.5),
992
1835
  },
993
1836
  };
1837
+ const callStart = Date.now();
1838
+ let logResponse;
1839
+ let logError;
1840
+ let logNewSummaryId;
994
1841
  try {
995
1842
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
996
1843
  const mergedText = response.content
997
1844
  .filter((b) => b.type === 'text')
998
1845
  .map(b => b.text)
999
1846
  .join('\n');
1847
+ logResponse = mergedText;
1000
1848
  // Compute source range from constituent summaries
1001
1849
  const sourceRange = {
1002
1850
  first: sources[0].sourceRange.first,
@@ -1013,6 +1861,7 @@ export class AutobiographicalStrategy {
1013
1861
  sourceRange,
1014
1862
  created: Date.now(),
1015
1863
  };
1864
+ logNewSummaryId = newEntry.id;
1016
1865
  // Append the new merged entry first, then mark sources. Persist each
1017
1866
  // mergedInto edit individually so chronicle reflects the same shape as
1018
1867
  // the in-memory mirror. (If the process crashes mid-loop, restart sees
@@ -1027,9 +1876,569 @@ export class AutobiographicalStrategy {
1027
1876
  }
1028
1877
  catch (error) {
1029
1878
  console.error(`Failed to merge summaries into L${targetLevel}:`, error);
1879
+ logError = error instanceof Error ? error.message : String(error);
1030
1880
  throw error;
1031
1881
  }
1882
+ finally {
1883
+ logCompressionCall({
1884
+ operation: `merge_l${targetLevel}`,
1885
+ system: null,
1886
+ messages: collapsed.map((m) => ({
1887
+ participant: m.participant,
1888
+ text: m.content
1889
+ .filter((b) => b.type === 'text')
1890
+ .map((b) => b.text)
1891
+ .join(''),
1892
+ })),
1893
+ metadata: {
1894
+ target_level: targetLevel,
1895
+ source_ids: sourceIds,
1896
+ source_level: sources[0]?.level ?? null,
1897
+ source_level_shown: sourceLevelShown,
1898
+ target_tokens: targetTokens,
1899
+ model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
1900
+ latency_ms: Date.now() - callStart,
1901
+ summary_id: logNewSummaryId,
1902
+ },
1903
+ response: logResponse,
1904
+ error: logError,
1905
+ });
1906
+ }
1907
+ }
1908
+ // ============================================================================
1909
+ // Adaptive resolution (picker-driven) path
1910
+ // ============================================================================
1911
+ /**
1912
+ * Select context entries using the adaptive-resolution picker.
1913
+ *
1914
+ * Builds per-message PickerChunks from compressible messages, runs the
1915
+ * configured FoldingStrategy under token-budget pressure, and emits the
1916
+ * resulting per-message resolutions as ContextEntry[]. Adjacent messages
1917
+ * sharing the same L_k ancestor emit the recall pair once.
1918
+ *
1919
+ * See `docs/adaptive-resolution-design.md` §3, §5.
1920
+ */
1921
+ selectAdaptive(store, budget) {
1922
+ const entries = [];
1923
+ const maxTokens = budget.maxTokens - budget.reserveForResponse;
1924
+ const messages = store.getAll();
1925
+ const msgCap = this.config.maxMessageTokens;
1926
+ // ----- 1. Build head/tail sets and emit head entries -----
1927
+ const headStart = this.getHeadWindowStartIndex(store);
1928
+ const headEnd = this.getHeadWindowEnd(store);
1929
+ const recentStart = this.getRecentWindowStart(store);
1930
+ const headMessageIds = new Set();
1931
+ const tailMessageIds = new Set();
1932
+ let headTokens = 0;
1933
+ let tailTokens = 0;
1934
+ let totalTokens = 0;
1935
+ // Emit head entries verbatim
1936
+ for (let i = headStart; i < headEnd && i < messages.length; i++) {
1937
+ const msg = messages[i];
1938
+ const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1939
+ const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
1940
+ if (totalTokens + tokens > maxTokens)
1941
+ break;
1942
+ entries.push({
1943
+ index: entries.length,
1944
+ sourceMessageId: msg.id,
1945
+ sourceRelation: 'copy',
1946
+ participant: msg.participant,
1947
+ content,
1948
+ });
1949
+ totalTokens += tokens;
1950
+ headMessageIds.add(msg.id);
1951
+ headTokens += tokens;
1952
+ }
1953
+ if (entries.length > 0) {
1954
+ entries[entries.length - 1].cacheMarker = true;
1955
+ }
1956
+ // Compute tail message IDs (will be emitted at end)
1957
+ const effectiveRecentStart = Math.max(recentStart, headEnd);
1958
+ for (let i = effectiveRecentStart; i < messages.length; i++) {
1959
+ const msg = messages[i];
1960
+ const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
1961
+ tailMessageIds.add(msg.id);
1962
+ tailTokens += tokens;
1963
+ }
1964
+ // ----- 2. Build PickerChunks for messages in the middle -----
1965
+ // For each compressible (non-head, non-tail) message we create one
1966
+ // PickerChunk. Its l1Id is determined by the existing chunks that
1967
+ // group messages into L1 summaries.
1968
+ const chunksByMessageId = new Map();
1969
+ for (const ch of this.chunks) {
1970
+ for (const m of ch.messages) {
1971
+ chunksByMessageId.set(m.id, ch);
1972
+ }
1973
+ }
1974
+ // Pinned-position set so the picker doesn't fold messages the user
1975
+ // explicitly marked as keep-raw. Built once and reused.
1976
+ const pinnedSet = this.pinnedPositions(messages);
1977
+ // O(1) summary lookup for findAncestorAt — avoids O(summaries) find()
1978
+ // calls during emission.
1979
+ const summariesById = new Map();
1980
+ for (const s of this.summaries)
1981
+ summariesById.set(s.id, s);
1982
+ const pickerChunks = [];
1983
+ for (let i = headEnd; i < effectiveRecentStart && i < messages.length; i++) {
1984
+ const msg = messages[i];
1985
+ const ch = chunksByMessageId.get(msg.id);
1986
+ const tokens = msgCap > 0
1987
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
1988
+ : store.estimateTokens(msg);
1989
+ pickerChunks.push({
1990
+ id: msg.id,
1991
+ sequence: i,
1992
+ rawTokens: tokens,
1993
+ currentResolution: this.resolutions.get(msg.id) ?? 0,
1994
+ lockedByAgent: this.locked.has(msg.id),
1995
+ pinned: pinnedSet.has(i),
1996
+ l1Id: ch?.summaryId,
1997
+ });
1998
+ }
1999
+ // Also include head and tail in PickerChunks (so token accounting matches)
2000
+ // — but mark them as in-head/in-tail so the picker won't fold them.
2001
+ for (let i = headStart; i < headEnd && i < messages.length; i++) {
2002
+ const msg = messages[i];
2003
+ const tokens = msgCap > 0
2004
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
2005
+ : store.estimateTokens(msg);
2006
+ pickerChunks.push({
2007
+ id: msg.id,
2008
+ sequence: i,
2009
+ rawTokens: tokens,
2010
+ currentResolution: 0,
2011
+ lockedByAgent: this.locked.has(msg.id),
2012
+ pinned: true, // treat head as pinned for picker purposes
2013
+ l1Id: undefined,
2014
+ });
2015
+ }
2016
+ for (let i = effectiveRecentStart; i < messages.length; i++) {
2017
+ const msg = messages[i];
2018
+ const tokens = msgCap > 0
2019
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
2020
+ : store.estimateTokens(msg);
2021
+ pickerChunks.push({
2022
+ id: msg.id,
2023
+ sequence: i,
2024
+ rawTokens: tokens,
2025
+ currentResolution: 0,
2026
+ lockedByAgent: this.locked.has(msg.id),
2027
+ pinned: true, // treat tail as pinned for picker purposes
2028
+ l1Id: undefined,
2029
+ });
2030
+ }
2031
+ // ----- 3. Build summaries map and recall-pair tokens -----
2032
+ const summariesMap = new Map();
2033
+ const recallPairTokens = new Map();
2034
+ for (const s of this.summaries) {
2035
+ summariesMap.set(s.id, s);
2036
+ // recall pair = the summary's text wrapped as a Q&A pair. Approximate
2037
+ // as s.tokens + small overhead for the "What do you remember?" label.
2038
+ recallPairTokens.set(s.id, s.tokens + 20);
2039
+ }
2040
+ // ----- 4. Run the picker -----
2041
+ const totalBudget = maxTokens - totalTokens; // tokens left after head
2042
+ const slack = this.config.compressionSlackRatio ?? 0.1;
2043
+ const foldingBudget = {
2044
+ totalBudget,
2045
+ targetBudget: totalBudget * (1 - slack),
2046
+ slack,
2047
+ };
2048
+ const headSetForPicker = new Set(headMessageIds);
2049
+ const tailSetForPicker = new Set(tailMessageIds);
2050
+ const picker = this.getAdaptivePicker();
2051
+ const result = picker.run({
2052
+ chunks: pickerChunks,
2053
+ summaries: summariesMap,
2054
+ recallPairTokens,
2055
+ headChunkIds: headSetForPicker,
2056
+ tailChunkIds: tailSetForPicker,
2057
+ headTokens,
2058
+ tailTokens,
2059
+ }, foldingBudget);
2060
+ // Commit the new resolutions back to strategy state for next compile.
2061
+ // Persist to chronicle only if anything actually changed — avoids
2062
+ // unnecessary state-slot writes on no-op compiles (which is the common
2063
+ // case in steady state with slack).
2064
+ let resolutionsChanged = false;
2065
+ let deepestLevel = 0;
2066
+ for (const [id, level] of result.finalResolutions) {
2067
+ if (headMessageIds.has(id) || tailMessageIds.has(id))
2068
+ continue;
2069
+ if (this.locked.has(id))
2070
+ continue;
2071
+ const prev = this.resolutions.get(id) ?? 0;
2072
+ if (prev !== level) {
2073
+ this.resolutions.set(id, level);
2074
+ resolutionsChanged = true;
2075
+ }
2076
+ if (level > deepestLevel)
2077
+ deepestLevel = level;
2078
+ }
2079
+ if (resolutionsChanged) {
2080
+ this.persistResolutions();
2081
+ }
2082
+ // Wire produce ops into the strategy's own production queues so that
2083
+ // requested-but-not-yet-existing summaries actually get built. The
2084
+ // speculative pre-producer covers most cases ambiently, but when it is
2085
+ // disabled (`speculativeProduction: false`) or hasn't reached the level
2086
+ // the picker just asked for, the request would otherwise be dropped and
2087
+ // the picker would re-emit it on every subsequent compile. Handling it
2088
+ // here makes the produce path observable and convergent.
2089
+ //
2090
+ // The actual compression/merge work runs asynchronously via the next
2091
+ // `tick()` invocation (or the speculative drain kicked from
2092
+ // `onNewMessage`). This call only enqueues; it does not await.
2093
+ if (result.produced.length > 0) {
2094
+ this.handleProducedOps(result.produced);
2095
+ }
2096
+ // Hard-fail check: if the picker exhausted itself but the final render
2097
+ // would still exceed the HARD budget (not just the soft target), surface
2098
+ // an OverBudgetError to the host rather than silently dropping entries.
2099
+ // The strategy has done all it can; the application has to decide what
2100
+ // to do next (raise budget, switch model, drop windows, etc.).
2101
+ if (result.exhausted && result.finalTokens > totalBudget) {
2102
+ throw new OverBudgetError({
2103
+ budget: totalBudget,
2104
+ actual: result.finalTokens,
2105
+ diagnostics: {
2106
+ headTokens,
2107
+ tailTokens,
2108
+ middleTokens: Math.max(0, result.finalTokens - headTokens - tailTokens),
2109
+ middleChunkCount: pickerChunks.length - headMessageIds.size - tailMessageIds.size,
2110
+ deepestLevel,
2111
+ },
2112
+ });
2113
+ }
2114
+ // ----- 5. Emit middle entries in source order -----
2115
+ // Walk middle messages. Handle two cases:
2116
+ // - bodyGroupId set: collect all consecutive shards from the same group,
2117
+ // emit ONE combined entry with concatenated content (raw shards + inline
2118
+ // summary text for folded shards). This preserves KV — the model sees
2119
+ // one continuous user message instead of N turns.
2120
+ // - bodyGroupId absent: emit normally (raw L0 message, or Q+A summary pair).
2121
+ const emittedAncestors = new Set();
2122
+ const summaryLabel = this.config.summaryContextLabel ?? 'What do you remember from earlier?';
2123
+ const summaryParticipant = this.config.summaryParticipant ?? 'Claude';
2124
+ let i = headEnd;
2125
+ while (i < effectiveRecentStart && i < messages.length) {
2126
+ const msg = messages[i];
2127
+ if (msg.bodyGroupId) {
2128
+ // Collect the full run of consecutive shards sharing this bodyGroupId.
2129
+ const groupId = msg.bodyGroupId;
2130
+ const groupStart = i;
2131
+ while (i < effectiveRecentStart &&
2132
+ i < messages.length &&
2133
+ messages[i].bodyGroupId === groupId) {
2134
+ i++;
2135
+ }
2136
+ const groupMessages = messages.slice(groupStart, i);
2137
+ // Sort by shardIndex to ensure byte-faithful ordering.
2138
+ const sortedShards = [...groupMessages].sort((a, b) => (a.shardIndex ?? 0) - (b.shardIndex ?? 0));
2139
+ let currentRun = null;
2140
+ const participant = sortedShards[0].participant;
2141
+ const flushRun = () => {
2142
+ if (!currentRun)
2143
+ return true;
2144
+ if (currentRun.kind === 'raw') {
2145
+ const text = currentRun.parts.join('');
2146
+ const content = [{ type: 'text', text }];
2147
+ // Deliberately do NOT apply maxMessageTokens here: the picker
2148
+ // is the authority on how much of the doc renders raw vs.
2149
+ // summarized. Truncating the composite would silently lose
2150
+ // doc content that the picker explicitly chose to keep raw.
2151
+ // (`maxMessageTokens` is for per-message caps on chat / tool
2152
+ // results, not for sharded bodyGroup composites.)
2153
+ const tokens = this.estimateTokens(content);
2154
+ if (totalTokens + tokens > maxTokens) {
2155
+ currentRun = null;
2156
+ return false;
2157
+ }
2158
+ entries.push({
2159
+ index: entries.length,
2160
+ sourceMessageId: undefined,
2161
+ sourceRelation: 'copy',
2162
+ participant,
2163
+ content,
2164
+ });
2165
+ totalTokens += tokens;
2166
+ }
2167
+ else {
2168
+ // summary run — emit Q+A pair, dedup at the strategy level
2169
+ const ancestor = currentRun.ancestor;
2170
+ if (!emittedAncestors.has(ancestor.id)) {
2171
+ emittedAncestors.add(ancestor.id);
2172
+ const questionEntry = {
2173
+ index: entries.length,
2174
+ participant: 'Context Manager',
2175
+ content: [{ type: 'text', text: summaryLabel }],
2176
+ sourceRelation: 'derived',
2177
+ };
2178
+ const answerContent = [{ type: 'text', text: ancestor.content }];
2179
+ const answerEntry = {
2180
+ index: entries.length + 1,
2181
+ participant: summaryParticipant,
2182
+ content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
2183
+ sourceRelation: 'derived',
2184
+ };
2185
+ const pairTokens = this.estimateTokens(questionEntry.content) + this.estimateTokens(answerEntry.content);
2186
+ if (totalTokens + pairTokens > maxTokens) {
2187
+ currentRun = null;
2188
+ return false;
2189
+ }
2190
+ entries.push(questionEntry);
2191
+ entries.push(answerEntry);
2192
+ totalTokens += pairTokens;
2193
+ }
2194
+ }
2195
+ currentRun = null;
2196
+ return true;
2197
+ };
2198
+ let budgetExhausted = false;
2199
+ for (const shard of sortedShards) {
2200
+ const resolution = result.finalResolutions.get(shard.id) ?? 0;
2201
+ if (resolution === 0) {
2202
+ if (currentRun?.kind !== 'raw') {
2203
+ if (!flushRun()) {
2204
+ budgetExhausted = true;
2205
+ break;
2206
+ }
2207
+ currentRun = { kind: 'raw', parts: [] };
2208
+ }
2209
+ for (const block of shard.content) {
2210
+ if (block.type === 'text')
2211
+ currentRun.parts.push(block.text);
2212
+ }
2213
+ }
2214
+ else {
2215
+ const ancestor = this.findAncestorAt(shard.id, resolution, chunksByMessageId, summariesById);
2216
+ if (!ancestor) {
2217
+ // Fall back to raw
2218
+ if (currentRun?.kind !== 'raw') {
2219
+ if (!flushRun()) {
2220
+ budgetExhausted = true;
2221
+ break;
2222
+ }
2223
+ currentRun = { kind: 'raw', parts: [] };
2224
+ }
2225
+ for (const block of shard.content) {
2226
+ if (block.type === 'text')
2227
+ currentRun.parts.push(block.text);
2228
+ }
2229
+ continue;
2230
+ }
2231
+ // If we're already in a summary run for the SAME ancestor, this
2232
+ // shard is covered — skip silently.
2233
+ if (currentRun?.kind === 'summary' && currentRun.ancestor.id === ancestor.id) {
2234
+ continue;
2235
+ }
2236
+ if (!flushRun()) {
2237
+ budgetExhausted = true;
2238
+ break;
2239
+ }
2240
+ currentRun = { kind: 'summary', ancestor };
2241
+ }
2242
+ }
2243
+ if (!budgetExhausted) {
2244
+ if (!flushRun())
2245
+ budgetExhausted = true;
2246
+ }
2247
+ if (budgetExhausted)
2248
+ break;
2249
+ continue;
2250
+ }
2251
+ // Non-shard path: existing behavior.
2252
+ const resolution = result.finalResolutions.get(msg.id) ?? 0;
2253
+ if (resolution === 0) {
2254
+ const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
2255
+ const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
2256
+ if (totalTokens + tokens > maxTokens)
2257
+ break;
2258
+ entries.push({
2259
+ index: entries.length,
2260
+ sourceMessageId: msg.id,
2261
+ sourceRelation: 'copy',
2262
+ participant: msg.participant,
2263
+ content,
2264
+ });
2265
+ totalTokens += tokens;
2266
+ i++;
2267
+ }
2268
+ else {
2269
+ const ancestor = this.findAncestorAt(msg.id, resolution, chunksByMessageId, summariesById);
2270
+ if (!ancestor) {
2271
+ const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
2272
+ const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
2273
+ if (totalTokens + tokens > maxTokens)
2274
+ break;
2275
+ entries.push({
2276
+ index: entries.length,
2277
+ sourceMessageId: msg.id,
2278
+ sourceRelation: 'copy',
2279
+ participant: msg.participant,
2280
+ content,
2281
+ });
2282
+ totalTokens += tokens;
2283
+ i++;
2284
+ continue;
2285
+ }
2286
+ if (emittedAncestors.has(ancestor.id)) {
2287
+ i++;
2288
+ continue;
2289
+ }
2290
+ emittedAncestors.add(ancestor.id);
2291
+ const questionEntry = {
2292
+ index: entries.length,
2293
+ participant: 'Context Manager',
2294
+ content: [{ type: 'text', text: summaryLabel }],
2295
+ sourceRelation: 'derived',
2296
+ };
2297
+ const answerContent = [{ type: 'text', text: ancestor.content }];
2298
+ const answerEntry = {
2299
+ index: entries.length + 1,
2300
+ participant: summaryParticipant,
2301
+ content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
2302
+ sourceRelation: 'derived',
2303
+ };
2304
+ const pairTokens = this.estimateTokens(questionEntry.content) + this.estimateTokens(answerEntry.content);
2305
+ if (totalTokens + pairTokens > maxTokens)
2306
+ break;
2307
+ entries.push(questionEntry);
2308
+ entries.push(answerEntry);
2309
+ totalTokens += pairTokens;
2310
+ i++;
2311
+ }
2312
+ }
2313
+ // ----- 6. Emit tail entries newest-first eviction (matches existing behavior) -----
2314
+ this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
2315
+ // ----- 7. Post-process: merge consecutive raw entries from the same bodyGroup -----
2316
+ // Both head and tail emission paths emit shards as separate ContextEntries.
2317
+ // The middle path already merges consecutive same-bodyGroup raw shards into
2318
+ // one composite entry, but head/tail don't. This pass closes that gap so
2319
+ // a sharded message renders as ONE API message regardless of which region
2320
+ // it falls into (preserves KV cache through region transitions).
2321
+ const merged = this.mergeAdjacentBodyGroupRaw(entries, store);
2322
+ this.trimOrphanedToolUse(merged);
2323
+ return merged;
1032
2324
  }
2325
+ /**
2326
+ * Walk an entries array; for every run of consecutive entries that
2327
+ * (a) have sourceRelation: 'copy' (raw, not a synthesized recall pair)
2328
+ * (b) have sourceMessageId pointing to messages in the same bodyGroup
2329
+ * merge them into one composite entry whose body is the byte-faithful
2330
+ * concatenation of their text content. Other entries pass through.
2331
+ *
2332
+ * Reindexes the returned array.
2333
+ */
2334
+ mergeAdjacentBodyGroupRaw(entries, store) {
2335
+ if (entries.length === 0)
2336
+ return entries;
2337
+ // Look up bodyGroupId by sourceMessageId via the message store.
2338
+ const groupOf = (sourceMessageId) => {
2339
+ if (!sourceMessageId)
2340
+ return undefined;
2341
+ const m = store.get(sourceMessageId);
2342
+ return m?.bodyGroupId;
2343
+ };
2344
+ const out = [];
2345
+ let i = 0;
2346
+ while (i < entries.length) {
2347
+ const entry = entries[i];
2348
+ const groupId = entry.sourceRelation === 'copy' ? groupOf(entry.sourceMessageId) : undefined;
2349
+ if (!groupId) {
2350
+ out.push({ ...entry, index: out.length });
2351
+ i++;
2352
+ continue;
2353
+ }
2354
+ // Collect run of consecutive raw entries with same bodyGroupId.
2355
+ const run = [entry];
2356
+ let j = i + 1;
2357
+ while (j < entries.length &&
2358
+ entries[j].sourceRelation === 'copy' &&
2359
+ groupOf(entries[j].sourceMessageId) === groupId) {
2360
+ run.push(entries[j]);
2361
+ j++;
2362
+ }
2363
+ if (run.length === 1) {
2364
+ out.push({ ...entry, index: out.length });
2365
+ i++;
2366
+ continue;
2367
+ }
2368
+ // Sort the run by the underlying shardIndex to ensure byte-faithful
2369
+ // ordering. (Head/tail emission keeps chronological order, but defending
2370
+ // against reorderings is cheap.)
2371
+ const sortedRun = [...run].sort((a, b) => {
2372
+ const ma = a.sourceMessageId ? store.get(a.sourceMessageId) : null;
2373
+ const mb = b.sourceMessageId ? store.get(b.sourceMessageId) : null;
2374
+ return (ma?.shardIndex ?? 0) - (mb?.shardIndex ?? 0);
2375
+ });
2376
+ // Build merged text content. Non-text blocks (rare in shards) are
2377
+ // preserved on the first shard's entry only.
2378
+ const mergedTextParts = [];
2379
+ const nonTextBlocks = [];
2380
+ for (const r of sortedRun) {
2381
+ for (const block of r.content) {
2382
+ if (block.type === 'text')
2383
+ mergedTextParts.push(block.text);
2384
+ else
2385
+ nonTextBlocks.push(block);
2386
+ }
2387
+ }
2388
+ const mergedContent = [
2389
+ ...nonTextBlocks,
2390
+ { type: 'text', text: mergedTextParts.join('') },
2391
+ ];
2392
+ out.push({
2393
+ index: out.length,
2394
+ sourceMessageId: sortedRun[0].sourceMessageId,
2395
+ sourceRelation: 'copy',
2396
+ participant: sortedRun[0].participant,
2397
+ content: mergedContent,
2398
+ });
2399
+ i = j;
2400
+ }
2401
+ return out;
2402
+ }
2403
+ /** Get (lazily constructing) the configured picker instance. */
2404
+ getAdaptivePicker() {
2405
+ if (this._adaptivePicker)
2406
+ return this._adaptivePicker;
2407
+ const strategy = this.config.foldingStrategy === 'oldest-first'
2408
+ ? new OldestFirstStrategy()
2409
+ : new FlatProfileStrategy();
2410
+ this._adaptivePicker = new Picker(strategy);
2411
+ return this._adaptivePicker;
2412
+ }
2413
+ /**
2414
+ * Walk the summary tree to find the L_k ancestor of a message.
2415
+ * Returns null if no ancestor exists at that level (e.g., L_k not yet produced).
2416
+ *
2417
+ * Takes a pre-built summariesById map to avoid O(summaries) lookups per
2418
+ * call — for a chronicle with thousands of summaries and hundreds of
2419
+ * middle messages, the O(n) `find` would dominate compile latency.
2420
+ */
2421
+ findAncestorAt(messageId, level, chunksByMessageId, summariesById) {
2422
+ if (level <= 0)
2423
+ return null;
2424
+ const chunk = chunksByMessageId.get(messageId);
2425
+ if (!chunk?.summaryId)
2426
+ return null;
2427
+ const lookup = (id) => summariesById ? summariesById.get(id) : this.summaries.find((s) => s.id === id);
2428
+ let current = lookup(chunk.summaryId);
2429
+ while (current && current.level < level) {
2430
+ const parentId = getSummaryParentId(current);
2431
+ if (!parentId)
2432
+ return null;
2433
+ current = lookup(parentId);
2434
+ }
2435
+ if (!current || current.level !== level)
2436
+ return null;
2437
+ return current;
2438
+ }
2439
+ // ============================================================================
2440
+ // Hierarchical (threshold-driven) path
2441
+ // ============================================================================
1033
2442
  /**
1034
2443
  * Select context entries using hierarchical compression with budget carryover.
1035
2444
  * Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
@@ -1290,18 +2699,97 @@ export class AutobiographicalStrategy {
1290
2699
  // Overridable hooks (for subclass customization)
1291
2700
  // ============================================================================
1292
2701
  /**
1293
- * Build the compression instruction for an L1 chunk.
1294
- * Override in subclasses for phase-aware prompts.
2702
+ * Build the compression instruction for an L1 chunk in the hierarchical
2703
+ * path. Override in subclasses for domain-specific prompts (e.g.,
2704
+ * phase-aware prompts in KnowledgeStrategy).
2705
+ *
2706
+ * Default returns the KV-preserving first-person instruction matching
2707
+ * the hermes-autobio spec. The doc/reading-mode variant is exposed via
2708
+ * {@link getReadingChunkInstruction}.
1295
2709
  */
1296
2710
  getCompressionInstruction(chunk, targetTokens) {
1297
- 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.`;
2711
+ return formatInstruction(targetTokens);
2712
+ }
2713
+ /**
2714
+ * Build the compression instruction for an L1 chunk that is part of a
2715
+ * substantially larger sharded message (reading mode). Override in
2716
+ * subclasses if domain logic needs to vary the reading-mode prompt.
2717
+ *
2718
+ * Default returns the reading-mode instruction that asks the model to
2719
+ * reflect on what reading was like rather than form a memory "of what
2720
+ * the chunk contained", which prevents voice drift into the content
2721
+ * author's perspective.
2722
+ */
2723
+ getReadingChunkInstruction(chunk, totalTokens, targetTokens) {
2724
+ return formatReadingChunkInstruction(totalTokens, targetTokens);
2725
+ }
2726
+ /**
2727
+ * If the chunk is part of a substantially larger sharded message (total
2728
+ * bodyGroup tokens ≥ 2× the chunk's own tokens), return reading-context
2729
+ * metadata for the reading instruction. The 2× threshold means the
2730
+ * chunk represents a portion of something significantly larger — the
2731
+ * agent is reading, not conversing.
2732
+ *
2733
+ * Returns null when the chunk is a whole message (no bodyGroup), or
2734
+ * when bodyGroup total is < 2× chunk size (degenerate case — chunk
2735
+ * effectively IS the whole message). In those cases the standard
2736
+ * (non-reading) instruction is appropriate.
2737
+ */
2738
+ detectDocContext(chunk, ctx) {
2739
+ if (chunk.messages.length === 0)
2740
+ return null;
2741
+ const firstGroupId = chunk.messages[0].bodyGroupId;
2742
+ if (!firstGroupId)
2743
+ return null;
2744
+ // All messages in the chunk must share the same bodyGroupId
2745
+ for (const m of chunk.messages) {
2746
+ if (m.bodyGroupId !== firstGroupId)
2747
+ return null;
2748
+ }
2749
+ // Total tokens of the original message (sum of all shards in the bodyGroup).
2750
+ const allMessages = ctx.messageStore.getAll();
2751
+ let totalTokens = 0;
2752
+ for (const m of allMessages) {
2753
+ if (m.bodyGroupId === firstGroupId) {
2754
+ totalTokens += ctx.messageStore.estimateTokens(m);
2755
+ }
2756
+ }
2757
+ // Tokens in this chunk specifically.
2758
+ let chunkTokens = 0;
2759
+ for (const m of chunk.messages) {
2760
+ chunkTokens += ctx.messageStore.estimateTokens(m);
2761
+ }
2762
+ // Reading-mode threshold: the original message must be substantially
2763
+ // larger than this chunk. 2× means the chunk is at most half of the
2764
+ // whole — clearly a portion of something bigger.
2765
+ if (chunkTokens === 0 || totalTokens < 2 * chunkTokens)
2766
+ return null;
2767
+ return {
2768
+ totalTokens,
2769
+ chunkTokens,
2770
+ };
1298
2771
  }
1299
2772
  /**
1300
2773
  * Build the merge instruction for combining summaries into a higher level.
1301
2774
  * Override in subclasses for domain-specific merge prompts.
2775
+ *
2776
+ * Default returns the KV-preserving merge instruction. The reading-mode
2777
+ * variant (used when all leaves share a bodyGroup of substantial size)
2778
+ * is exposed via {@link getReadingMergeInstruction}.
1302
2779
  */
1303
2780
  getMergeInstruction(targetLevel, sources, targetTokens) {
1304
- 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.`;
2781
+ const sourceLevelShown = sources.length > 0 ? Math.max(0, sources[0].level - 1) : 0;
2782
+ return formatMergeInstruction(targetLevel, sourceLevelShown, targetTokens);
2783
+ }
2784
+ /**
2785
+ * Build the reading-mode merge instruction. Used when all leaf messages
2786
+ * underlying the merge share a bodyGroup of substantial size — the agent
2787
+ * has been reading a doc rather than conversing. Override in subclasses
2788
+ * if domain logic needs to vary the reading-mode merge prompt.
2789
+ */
2790
+ getReadingMergeInstruction(targetLevel, sources, totalTokens, targetTokens) {
2791
+ const sourceLevelShown = sources.length > 0 ? Math.max(0, sources[0].level - 1) : 0;
2792
+ return formatReadingMergeInstruction(targetLevel, sourceLevelShown, totalTokens, targetTokens);
1305
2793
  }
1306
2794
  /**
1307
2795
  * Select L1 summaries within a budget. Returns selected summaries and tokens used.