@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,3 +1,4 @@
1
+ import type { JsStore } from '@animalabs/chronicle';
1
2
  import type { Membrane, NormalizedRequest, ContentBlock, CompleteOptions } from '@animalabs/membrane';
2
3
  import { NativeFormatter } from '@animalabs/membrane';
3
4
  import type {
@@ -13,8 +14,182 @@ import type {
13
14
  AutobiographicalConfig,
14
15
  SummaryLevel,
15
16
  SummaryEntry,
17
+ ProtectedRange,
18
+ SearchQuery,
19
+ SearchResult,
16
20
  } from '../types/index.js';
17
21
  import { DEFAULT_AUTOBIOGRAPHICAL_CONFIG } from '../types/index.js';
22
+ import { getSummaryParentId } from '../types/strategy.js';
23
+ import { appendFileSync, mkdirSync } from 'node:fs';
24
+ import { dirname } from 'node:path';
25
+ import { Picker, OverBudgetError, type PickerChunk } from '../adaptive/picker.js';
26
+ import { FlatProfileStrategy } from '../adaptive/strategies/flat-profile.js';
27
+ import { OldestFirstStrategy } from '../adaptive/strategies/oldest-first.js';
28
+ import type {
29
+ FoldingStrategy,
30
+ FoldingBudget,
31
+ FoldOp,
32
+ ChunkId,
33
+ SummaryId,
34
+ } from '../adaptive/folding-strategy.js';
35
+ import { chunkMessage, DEFAULT_CHUNKER_OPTIONS } from '../adaptive/chunker.js';
36
+ import type { MessageId } from '../types/message.js';
37
+ import type { IngressChunkResult } from '../types/strategy.js';
38
+
39
+ /**
40
+ * Append a JSONL entry describing one compression LLM call to the path
41
+ * given by `CONTEXT_MANAGER_COMPRESSION_LOG`. No-op if the env var isn't
42
+ * set. Called at every L1 and merge LLM-call site so we can audit the
43
+ * exact prompts and responses post-hoc — no reconstruction, no
44
+ * assumption about whether the strategy code matches what produced
45
+ * historical summaries.
46
+ *
47
+ * Failures to write are logged to stderr but don't propagate — logging
48
+ * is non-essential observability and should never break compression.
49
+ */
50
+ function logCompressionCall(entry: Record<string, unknown>): void {
51
+ const logPath = process.env.CONTEXT_MANAGER_COMPRESSION_LOG;
52
+ if (!logPath) return;
53
+ try {
54
+ mkdirSync(dirname(logPath), { recursive: true });
55
+ const line = JSON.stringify({ ...entry, timestamp: Date.now() }) + '\n';
56
+ appendFileSync(logPath, line);
57
+ } catch (err) {
58
+ console.warn('compression log write failed:', err);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * In-band marker shown to the summarizer just before the chunk it's
64
+ * about to memorize. Primes attention without disrupting KV state —
65
+ * the agent has seen this exact wording before every prior compression
66
+ * in its history, so the model treats memory formation as a recurring
67
+ * narrated event rather than a fresh instruction each time.
68
+ *
69
+ * Wording matches `hermes-autobio/plugins/autobio/compression.py`
70
+ * `_MARKER` so the in-band primer is consistent across the codebase.
71
+ */
72
+ const COMPRESSION_MARKER =
73
+ 'System: You will soon form a new memory, get ready. ' +
74
+ 'The messages that follow are the slice of recent experience you are ' +
75
+ 'about to compress. After them, write the memory in your own voice.';
76
+
77
+ /** Standard compression instruction for chat/general chunks. */
78
+ function formatInstruction(targetTokens: number): string {
79
+ return (
80
+ 'Write the memory of events since the most recent memory system ' +
81
+ 'notification. Speak in the first person from your own perspective. ' +
82
+ 'Preserve concrete details — file paths, exact values, decisions, ' +
83
+ `unresolved questions, the user\'s active asks. Target ~${targetTokens} ` +
84
+ 'tokens. Output only the memory body — no preamble, no section headers ' +
85
+ 'unless they help preservation, no meta-commentary about summarizing.'
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Compression instruction for chunks that are part of a substantially larger
91
+ * message (≥ 2× the chunk's own token size).
92
+ *
93
+ * Avoids forcing a "document" or "message" frame — just describes the
94
+ * experience: the agent has been reading a substantial piece of text, of
95
+ * which the slice is a portion. Asks what reading was like and what was
96
+ * learned. This naturally elicits first-person agent voice ("I read…", "I
97
+ * learned…", "I noticed…") and preserves concrete content via the
98
+ * "specific claims, names, dates" guidance.
99
+ *
100
+ * Rationale: when chunks are shards of a much larger user-shared message,
101
+ * the chunk content is heavily first-person from someone other than the
102
+ * agent. Asking "form a memory of what this contained" lets the model
103
+ * adopt the dominant voice of the chunk content. Asking "what was reading
104
+ * this like, what did you learn" forces the model to reflect from its own
105
+ * vantage point — only the agent can describe what reading something was
106
+ * like from its own perspective.
107
+ */
108
+ function formatReadingChunkInstruction(
109
+ totalTokens: number,
110
+ targetTokens: number,
111
+ ): string {
112
+ return (
113
+ `You have just been reading a substantial body of text — approximately ` +
114
+ `${totalTokens} tokens total in this piece, of which what you just read ` +
115
+ `is a portion. Earlier portions are in your memory above as your prior ` +
116
+ `reflections; what comes after, you have not yet read.\n\n` +
117
+ `Reflect on this reading: what was it like? What did you learn? What ` +
118
+ `stood out? Be substantive — name the specific claims, frameworks, ` +
119
+ `people, dates, and phrases that struck you. What is now in your ` +
120
+ `understanding that wasn't before you read this portion?\n\n` +
121
+ `Speak in your own voice as the one reading. Target ~${targetTokens} ` +
122
+ `tokens. Output only the body of your reflection — no preamble.`
123
+ );
124
+ }
125
+
126
+ /**
127
+ * Merge instruction for L2/L3+ consolidation (conversation/general case).
128
+ *
129
+ * The model has just been shown content ONE LEVEL DEEPER than its
130
+ * sources: raw messages for an L2 merge (sources are L1s), L1 memories
131
+ * for an L3 merge (sources are L2s), etc. The instruction describes
132
+ * the content the model just saw and asks for a consolidation at
133
+ * `targetLevel`.
134
+ */
135
+ function formatMergeInstruction(
136
+ targetLevel: number,
137
+ sourceLevelShown: number,
138
+ targetTokens: number,
139
+ ): string {
140
+ const seenDescription =
141
+ sourceLevelShown === 0
142
+ ? 'the slices of recent experience above (raw conversation)'
143
+ : `the L${sourceLevelShown} memories above`;
144
+ return (
145
+ `You have just reviewed ${seenDescription}, in chronological order. ` +
146
+ `They cover the stretch of experience you are about to consolidate into a ` +
147
+ `single L${targetLevel} memory. Write a memory that preserves the ` +
148
+ `through-line: what happened, what was decided, what remains open, what ` +
149
+ `concrete details future you will want to reach for. Speak in the first ` +
150
+ `person. Target ~${targetTokens} tokens. Output only the memory body — ` +
151
+ `no preamble, no meta-commentary about summarizing.`
152
+ );
153
+ }
154
+
155
+ /**
156
+ * Reading-mode merge instruction. Used when the merge's leaf messages
157
+ * are all part of a substantially-larger sharded message — i.e., the
158
+ * agent has been reading a doc/long-message rather than conversing.
159
+ *
160
+ * Analogous to formatReadingChunkInstruction: avoids forcing a
161
+ * "document" or "message" frame, asks what reading the stretch was
162
+ * like and what was understood. Forces the agent's vantage point —
163
+ * only the reader can describe what reading was like — and so prevents
164
+ * the drift into the content author's voice that happens when the
165
+ * instruction asks for an impersonal summary.
166
+ */
167
+ function formatReadingMergeInstruction(
168
+ targetLevel: number,
169
+ sourceLevelShown: number,
170
+ totalTokens: number,
171
+ targetTokens: number,
172
+ ): string {
173
+ const seenDescription =
174
+ sourceLevelShown === 0
175
+ ? 'the portions of text you read above (raw passages from a larger piece)'
176
+ : `your earlier L${sourceLevelShown} reflections above on portions you read`;
177
+ return (
178
+ `You have just re-experienced ${seenDescription}, in chronological order. ` +
179
+ `They cover a contiguous stretch of a substantial body of text you have ` +
180
+ `been reading — approximately ${totalTokens} tokens in total across all ` +
181
+ `of it. The portions above cover the stretch you are now consolidating ` +
182
+ `at L${targetLevel}.\n\n` +
183
+ `Reflect across the stretch: what was it like, reading these portions ` +
184
+ `together? What did you come to understand that you couldn't have from ` +
185
+ `any single portion alone? What recurring patterns, frameworks, or ` +
186
+ `concerns emerged? Be substantive — name the specific claims, people, ` +
187
+ `dates, and phrases that defined this stretch of your reading.\n\n` +
188
+ `Speak in your own voice as the one who read these portions. Target ` +
189
+ `~${targetTokens} tokens. Output only the body of your consolidation — ` +
190
+ `no preamble.`
191
+ );
192
+ }
18
193
 
19
194
  /**
20
195
  * Surrogate-safe string slice. Avoids cutting between a UTF-16 surrogate pair
@@ -55,6 +230,27 @@ export interface Chunk {
55
230
  phaseType?: string;
56
231
  }
57
232
 
233
+ /**
234
+ * Point-in-time snapshot of compression progress, returned from
235
+ * `AutobiographicalStrategy.getProgressSnapshot()`. External observers
236
+ * (warmup scripts, dashboards) use this to track convergence without
237
+ * reaching into the strategy's protected fields.
238
+ */
239
+ export interface AutobiographicalProgressSnapshot {
240
+ /** All chunks the strategy is tracking, compressed or not. */
241
+ totalChunks: number;
242
+ /** Chunks that already have an L1 summary. */
243
+ chunksCompressed: number;
244
+ /** Chunks queued for L1 compression. */
245
+ l1QueueLength: number;
246
+ /** Pending L1→L2 and L2→L3 merges. */
247
+ mergeQueueLength: number;
248
+ /** Stored summary counts per level (1 = raw L1, 2 = L1→L2 merge, 3 = L2→L3 merge). */
249
+ summaryCounts: { l1: number; l2: number; l3: number };
250
+ /** True if a compression or merge LLM call is currently in flight. */
251
+ pending: boolean;
252
+ }
253
+
58
254
  /**
59
255
  * Autobiographical chunking strategy.
60
256
  * Compresses old conversation chunks into summaries in the model's own words.
@@ -86,6 +282,43 @@ export class AutobiographicalStrategy implements ResettableStrategy {
86
282
  /** Cached result of getHeadWindowStartIndex to avoid repeated linear scans. */
87
283
  private _cachedHeadStartIndex: { id: string | null; msgCount: number; result: number } | null = null;
88
284
 
285
+ /** Chronicle store for persistent state. Set in `initialize()`. */
286
+ protected store: JsStore | null = null;
287
+ /** Namespace for state-id scoping. Set in `initialize()`. */
288
+ protected ns: string = '';
289
+ protected get summariesStateId(): string { return `${this.ns}/autobio:summaries`; }
290
+ protected get counterStateId(): string { return `${this.ns}/autobio:counter`; }
291
+ protected get mergeQueueStateId(): string { return `${this.ns}/autobio:mergeQueue`; }
292
+ protected get pinsStateId(): string { return `${this.ns}/autobio:pins`; }
293
+ protected get resolutionsStateId(): string { return `${this.ns}/autobio:resolutions`; }
294
+ protected get locksStateId(): string { return `${this.ns}/autobio:locks`; }
295
+
296
+ /** Protected ranges (pins + documents). Loaded from chronicle in initialize. */
297
+ protected pins: ProtectedRange[] = [];
298
+ /** Monotonically increasing counter for pin ids. Persisted as part of the pins snapshot. */
299
+ protected pinIdCounter = 0;
300
+
301
+ /**
302
+ * Per-message resolution state for the adaptive-resolution picker.
303
+ * - Key: MessageId
304
+ * - Value: currentResolution (0 = render raw, k>0 = render L_k recall)
305
+ *
306
+ * Maintained only when `config.adaptiveResolution` is true. Loaded from
307
+ * the chronicle on `initialize()` and persisted via the resolutions state
308
+ * slot so resolutions survive process restart and follow branches.
309
+ */
310
+ protected resolutions: Map<MessageId, number> = new Map();
311
+
312
+ /**
313
+ * Per-message lock state for the adaptive-resolution picker. Set via the
314
+ * programmatic `lockChunk(id)` API on the strategy. Locked messages are
315
+ * skipped by the picker. Persisted via the locks state slot.
316
+ */
317
+ protected locked: Set<MessageId> = new Set();
318
+
319
+ /** Lazy picker instance, built from config.foldingStrategy. */
320
+ private _adaptivePicker: Picker | null = null;
321
+
89
322
  constructor(config: Partial<AutobiographicalConfig> = {}) {
90
323
  this.config = { ...DEFAULT_AUTOBIOGRAPHICAL_CONFIG, ...config };
91
324
  // Hierarchical is on by default; set hierarchical: false to use legacy single-level
@@ -97,11 +330,109 @@ export class AutobiographicalStrategy implements ResettableStrategy {
97
330
  this.config.l2BudgetTokens ??= 30000;
98
331
  this.config.l1BudgetTokens ??= 30000;
99
332
  }
333
+ // Adaptive-resolution defaults
334
+ if (this.config.adaptiveResolution) {
335
+ this.config.foldingStrategy ??= 'flat-profile';
336
+ this.config.compressionSlackRatio ??= 0.1;
337
+ this.config.speculativeProduction ??= true;
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Lock a message so the adaptive picker won't change its resolution.
343
+ * No-op when adaptiveResolution is false. Set-only programmatic API per
344
+ * the design (no agent-facing tool in V1). Persisted to chronicle.
345
+ */
346
+ lockChunk(id: MessageId): void {
347
+ if (this.locked.has(id)) return;
348
+ this.locked.add(id);
349
+ this.persistLocks();
350
+ }
351
+
352
+ /**
353
+ * Unlock a message so the adaptive picker may again change its resolution.
354
+ * No-op when adaptiveResolution is false. Persisted to chronicle.
355
+ */
356
+ unlockChunk(id: MessageId): void {
357
+ if (!this.locked.has(id)) return;
358
+ this.locked.delete(id);
359
+ this.persistLocks();
360
+ }
361
+
362
+ /**
363
+ * Ingestion-time chunking hook.
364
+ *
365
+ * Active only when `config.adaptiveResolution` is true. Inspects the
366
+ * incoming message's text content; if its approximate token count
367
+ * exceeds the chunker's threshold, splits it into shards with a stable
368
+ * shared `bodyGroupId`. The framework then stores each shard as its own
369
+ * StoredMessage, and the render path concatenates them back into one
370
+ * API message at compile time (preserving KV cache structure).
371
+ *
372
+ * Multi-block content: text blocks are concatenated for chunking, then
373
+ * the resulting shards are emitted as text blocks. Non-text blocks
374
+ * (images, tool results) are passed through unchanged on the first
375
+ * shard only — they don't get split.
376
+ *
377
+ * See `docs/adaptive-resolution-design.md` §3.6.
378
+ */
379
+ chunkIngressMessage(participant: string, content: ContentBlock[]): IngressChunkResult | null {
380
+ if (!this.config.adaptiveResolution) return null;
381
+
382
+ // Separate text and non-text blocks.
383
+ const textParts: string[] = [];
384
+ const nonTextBlocks: ContentBlock[] = [];
385
+ for (const block of content) {
386
+ if (block.type === 'text') {
387
+ textParts.push(block.text);
388
+ } else {
389
+ nonTextBlocks.push(block);
390
+ }
391
+ }
392
+ if (textParts.length === 0) return null;
393
+ const combined = textParts.join('');
394
+
395
+ // Threshold and shard size derive from the strategy's existing
396
+ // targetChunkTokens setting: a message over 2x targetChunkTokens
397
+ // gets sharded into pieces of ~targetChunkTokens each. This keeps
398
+ // doc shards the same size as chat-message chunks for L1 production
399
+ // consistency.
400
+ const target = this.config.targetChunkTokens ?? DEFAULT_CHUNKER_OPTIONS.chunkSize;
401
+ const chunkerOpts = {
402
+ chunkThreshold: target * 2,
403
+ chunkSize: target,
404
+ charsPerToken: DEFAULT_CHUNKER_OPTIONS.charsPerToken,
405
+ };
406
+
407
+ const sharded = chunkMessage(combined, chunkerOpts);
408
+ if (!sharded.wasSharded) return null;
409
+
410
+ // Build IngressChunkResult. Non-text blocks (if any) go on shard 0
411
+ // so the agent doesn't lose attachments. They're outside the chunker's
412
+ // concern but should still be available on the first shard.
413
+ const shards = sharded.shards.map((s) => ({
414
+ content: ([{ type: 'text', text: s.content }] as ContentBlock[]).concat(
415
+ s.index === 0 ? nonTextBlocks : []
416
+ ),
417
+ shardIndex: s.index,
418
+ }));
419
+
420
+ return {
421
+ bodyGroupId: sharded.bodyGroupId,
422
+ shards,
423
+ };
100
424
  }
101
425
 
102
426
  async initialize(ctx: StrategyContext): Promise<void> {
427
+ // Bind to the chronicle store + namespace for persistent strategy state.
428
+ this.store = ctx.store;
429
+ this.ns = ctx.namespace;
430
+ this.registerStates();
431
+ this.loadPersistedState();
432
+
103
433
  // Restore headWindowStartId from last topic transition message
104
434
  const messages = ctx.messageStore.getAll();
435
+ this.headWindowStartId = null;
105
436
  for (let i = messages.length - 1; i >= 0; i--) {
106
437
  if (this.isTopicTransitionMessage(messages[i])) {
107
438
  this.headWindowStartId = messages[i].id;
@@ -111,6 +442,480 @@ export class AutobiographicalStrategy implements ResettableStrategy {
111
442
  this.rebuildChunks(ctx.messageStore);
112
443
  }
113
444
 
445
+ /**
446
+ * Register the three Chronicle state slots this strategy uses.
447
+ * Idempotent — chronicle throws if a state is already registered, which we
448
+ * swallow (the existing slot is what we want).
449
+ */
450
+ protected registerStates(): void {
451
+ if (!this.store) return;
452
+ try {
453
+ this.store.registerState({
454
+ id: this.summariesStateId,
455
+ strategy: 'append_log',
456
+ deltaSnapshotEvery: 50,
457
+ fullSnapshotEvery: 10,
458
+ });
459
+ } catch { /* already registered */ }
460
+ try {
461
+ this.store.registerState({
462
+ id: this.counterStateId,
463
+ strategy: 'snapshot',
464
+ });
465
+ } catch { /* already registered */ }
466
+ try {
467
+ this.store.registerState({
468
+ id: this.mergeQueueStateId,
469
+ strategy: 'snapshot',
470
+ });
471
+ } catch { /* already registered */ }
472
+ try {
473
+ this.store.registerState({
474
+ id: this.pinsStateId,
475
+ strategy: 'snapshot',
476
+ });
477
+ } catch { /* already registered */ }
478
+ // Adaptive-resolution state slots — only registered when the flag is on
479
+ // so chronicles without the flag don't accumulate unused slots.
480
+ if (this.config.adaptiveResolution) {
481
+ try {
482
+ this.store.registerState({
483
+ id: this.resolutionsStateId,
484
+ strategy: 'snapshot',
485
+ });
486
+ } catch { /* already registered */ }
487
+ try {
488
+ this.store.registerState({
489
+ id: this.locksStateId,
490
+ strategy: 'snapshot',
491
+ });
492
+ } catch { /* already registered */ }
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Load summaries, counter, and pending merges from chronicle into the
498
+ * in-memory mirrors. Called on every (re)initialize so branch switches
499
+ * pick up the new branch's state.
500
+ */
501
+ protected loadPersistedState(): void {
502
+ if (!this.store) {
503
+ this.summaries = [];
504
+ this.summaryIdCounter = 0;
505
+ this.mergeQueue = [];
506
+ this.pins = [];
507
+ this.pinIdCounter = 0;
508
+ return;
509
+ }
510
+ const summaries = this.store.getStateJson(this.summariesStateId);
511
+ this.summaries = Array.isArray(summaries) ? (summaries as SummaryEntry[]) : [];
512
+
513
+ const counter = this.store.getStateJson(this.counterStateId);
514
+ this.summaryIdCounter = typeof counter === 'number' ? counter : 0;
515
+
516
+ const queue = this.store.getStateJson(this.mergeQueueStateId);
517
+ this.mergeQueue = Array.isArray(queue)
518
+ ? (queue as Array<{ level: SummaryLevel; sourceIds: string[] }>)
519
+ : [];
520
+
521
+ const pinsState = this.store.getStateJson(this.pinsStateId);
522
+ if (pinsState && typeof pinsState === 'object' && Array.isArray((pinsState as { pins?: unknown }).pins)) {
523
+ const ps = pinsState as { pins: ProtectedRange[]; counter?: number };
524
+ this.pins = ps.pins;
525
+ this.pinIdCounter = typeof ps.counter === 'number' ? ps.counter : ps.pins.length;
526
+ } else {
527
+ this.pins = [];
528
+ this.pinIdCounter = 0;
529
+ }
530
+
531
+ // Adaptive-resolution state — only present when flag was/is on
532
+ if (this.config.adaptiveResolution) {
533
+ const resState = this.store.getStateJson(this.resolutionsStateId);
534
+ this.resolutions = new Map();
535
+ if (resState && typeof resState === 'object') {
536
+ for (const [k, v] of Object.entries(resState as Record<string, unknown>)) {
537
+ if (typeof v === 'number' && v > 0) {
538
+ this.resolutions.set(k, v);
539
+ }
540
+ }
541
+ }
542
+ const lockState = this.store.getStateJson(this.locksStateId);
543
+ this.locked = new Set();
544
+ if (Array.isArray(lockState)) {
545
+ for (const id of lockState) {
546
+ if (typeof id === 'string') this.locked.add(id);
547
+ }
548
+ }
549
+ }
550
+ }
551
+
552
+ /** Persist the current pins + counter as a single snapshot. */
553
+ protected persistPins(): void {
554
+ this.store?.setStateJson(this.pinsStateId, {
555
+ pins: this.pins,
556
+ counter: this.pinIdCounter,
557
+ });
558
+ }
559
+
560
+ /** Persist the current resolutions snapshot. Only stores non-zero entries
561
+ * to keep the slot compact. */
562
+ protected persistResolutions(): void {
563
+ if (!this.store) return;
564
+ const out: Record<string, number> = {};
565
+ for (const [id, level] of this.resolutions) {
566
+ if (level > 0) out[id] = level;
567
+ }
568
+ this.store.setStateJson(this.resolutionsStateId, out);
569
+ }
570
+
571
+ /** Persist the current locked-id snapshot. */
572
+ protected persistLocks(): void {
573
+ if (!this.store) return;
574
+ this.store.setStateJson(this.locksStateId, Array.from(this.locked));
575
+ }
576
+
577
+ // ============================================================================
578
+ // Pins / documents (protected ranges)
579
+ // ============================================================================
580
+
581
+ /**
582
+ * Pin a range of messages so they aren't compressed and render raw at
583
+ * their original position. Returns the pin id.
584
+ */
585
+ pinRange(firstMessageId: string, lastMessageId: string, opts?: { name?: string }): string {
586
+ const id = `pin-${this.pinIdCounter++}`;
587
+ this.pins.push({
588
+ id,
589
+ firstMessageId,
590
+ lastMessageId,
591
+ kind: 'pin',
592
+ name: opts?.name,
593
+ created: Date.now(),
594
+ });
595
+ this.persistPins();
596
+ return id;
597
+ }
598
+
599
+ /**
600
+ * Mark a single message as a "document" — semantically a body of
601
+ * information the agent wants to retain in full. Functionally a
602
+ * single-message pin with `kind: 'document'`.
603
+ */
604
+ markDocument(messageId: string, opts?: { name?: string }): string {
605
+ const id = `pin-${this.pinIdCounter++}`;
606
+ this.pins.push({
607
+ id,
608
+ firstMessageId: messageId,
609
+ lastMessageId: messageId,
610
+ kind: 'document',
611
+ name: opts?.name,
612
+ created: Date.now(),
613
+ });
614
+ this.persistPins();
615
+ return id;
616
+ }
617
+
618
+ /** Remove a pin or document mark by id. Returns true if removed. */
619
+ unpin(pinId: string): boolean {
620
+ const before = this.pins.length;
621
+ this.pins = this.pins.filter(p => p.id !== pinId);
622
+ if (this.pins.length < before) {
623
+ this.persistPins();
624
+ return true;
625
+ }
626
+ return false;
627
+ }
628
+
629
+ /** Read-only list of all current pins. */
630
+ listPins(): ReadonlyArray<ProtectedRange> {
631
+ return this.pins;
632
+ }
633
+
634
+ // ============================================================================
635
+ // Search (gap #7)
636
+ // ============================================================================
637
+
638
+ /**
639
+ * Look up a single summary by id. Returns null if not found.
640
+ */
641
+ getSummary(id: string): SummaryEntry | null {
642
+ return this.summaries.find(s => s.id === id) ?? null;
643
+ }
644
+
645
+ /**
646
+ * Search summaries by substring or regex over their content.
647
+ *
648
+ * Result ordering: matches by descending hit count, then by descending
649
+ * `created` timestamp (newest first within the same hit count).
650
+ *
651
+ * Default behavior: only "live" (unmerged) summaries are searched. Set
652
+ * `includeMerged: true` to also include summaries that have been folded
653
+ * into a higher level.
654
+ */
655
+ searchSummaries(query: SearchQuery): SearchResult[] {
656
+ const limit = query.limit ?? 50;
657
+ const includeMerged = query.includeMerged ?? false;
658
+
659
+ // Build the matcher
660
+ let matcher: ((content: string) => number) | null = null;
661
+ if (query.regex) {
662
+ const flags = query.regex.flags.includes('g') ? query.regex.flags : query.regex.flags + 'g';
663
+ const re = new RegExp(query.regex.source, flags);
664
+ matcher = (content: string) => {
665
+ const matches = content.match(re);
666
+ return matches ? matches.length : 0;
667
+ };
668
+ } else if (query.text) {
669
+ const needle = query.text.toLowerCase();
670
+ matcher = (content: string) => {
671
+ const hay = content.toLowerCase();
672
+ let count = 0;
673
+ let idx = 0;
674
+ while ((idx = hay.indexOf(needle, idx)) !== -1) {
675
+ count++;
676
+ idx += needle.length || 1;
677
+ }
678
+ return count;
679
+ };
680
+ } else {
681
+ // No pattern: every summary "matches" once
682
+ matcher = () => 1;
683
+ }
684
+
685
+ const levelsFilter = query.levels && query.levels.length > 0 ? new Set(query.levels) : null;
686
+
687
+ const results: SearchResult[] = [];
688
+ for (const s of this.summaries) {
689
+ if (!includeMerged && s.mergedInto) continue;
690
+ if (levelsFilter && !levelsFilter.has(s.level)) continue;
691
+ const matches = matcher(s.content);
692
+ if (matches > 0) {
693
+ results.push({ summary: s, matches });
694
+ }
695
+ }
696
+
697
+ results.sort((a, b) => {
698
+ if (b.matches !== a.matches) return b.matches - a.matches;
699
+ return b.summary.created - a.summary.created;
700
+ });
701
+
702
+ return results.slice(0, limit);
703
+ }
704
+
705
+ /**
706
+ * Whether a given message position is inside any protected range.
707
+ * Uses a position map (computed by caller) so callers can avoid
708
+ * repeated per-message lookups in tight loops.
709
+ */
710
+ protected isPositionPinned(position: number, pinPositions: Set<number>): boolean {
711
+ return pinPositions.has(position);
712
+ }
713
+
714
+ /**
715
+ * Build a set of message-store positions covered by any pin. O(N pins · K range).
716
+ * Returns positions for which the message exists; orphan pins (deleted
717
+ * messages) are silently skipped.
718
+ */
719
+ protected pinnedPositions(messages: StoredMessage[]): Set<number> {
720
+ if (this.pins.length === 0) return new Set();
721
+ const positionOf = new Map<string, number>();
722
+ for (let i = 0; i < messages.length; i++) {
723
+ positionOf.set(messages[i].id, i);
724
+ }
725
+ const out = new Set<number>();
726
+ for (const pin of this.pins) {
727
+ const first = positionOf.get(pin.firstMessageId);
728
+ const last = positionOf.get(pin.lastMessageId);
729
+ if (first === undefined || last === undefined) continue;
730
+ const lo = Math.min(first, last);
731
+ const hi = Math.max(first, last);
732
+ for (let i = lo; i <= hi; i++) out.add(i);
733
+ }
734
+ return out;
735
+ }
736
+
737
+ /**
738
+ * Append a summary to the in-memory list and to the chronicle AppendLog.
739
+ * Single point so subclasses inherit persistence.
740
+ */
741
+ protected pushSummary(entry: SummaryEntry): void {
742
+ this.summaries.push(entry);
743
+ this.store?.appendToStateJson(this.summariesStateId, entry);
744
+ }
745
+
746
+ /**
747
+ * Mark a summary as merged into a higher-level summary, updating the
748
+ * chronicle copy at the same index. Index is the position in `this.summaries`.
749
+ */
750
+ protected setMergedInto(entry: SummaryEntry, mergedIntoId: string): void {
751
+ entry.mergedInto = mergedIntoId;
752
+ if (!this.store) return;
753
+ const index = this.summaries.indexOf(entry);
754
+ if (index < 0) return;
755
+ this.store.editStateItem(
756
+ this.summariesStateId,
757
+ index,
758
+ Buffer.from(JSON.stringify(entry)),
759
+ );
760
+ }
761
+
762
+ /**
763
+ * Allocate the next summary-id counter value and persist the new counter.
764
+ */
765
+ protected nextSummaryIdCounter(): number {
766
+ const value = this.summaryIdCounter++;
767
+ this.store?.setStateJson(this.counterStateId, this.summaryIdCounter);
768
+ return value;
769
+ }
770
+
771
+ /**
772
+ * Push to the merge queue and persist the new queue snapshot.
773
+ */
774
+ protected enqueueMerge(merge: { level: SummaryLevel; sourceIds: string[] }): void {
775
+ this.mergeQueue.push(merge);
776
+ this.store?.setStateJson(this.mergeQueueStateId, this.mergeQueue);
777
+ }
778
+
779
+ /**
780
+ * Pop from the merge queue and persist the new queue snapshot.
781
+ */
782
+ protected dequeueMerge(): { level: SummaryLevel; sourceIds: string[] } | undefined {
783
+ const merge = this.mergeQueue.shift();
784
+ this.store?.setStateJson(this.mergeQueueStateId, this.mergeQueue);
785
+ return merge;
786
+ }
787
+
788
+ /**
789
+ * Translate produce ops emitted by the picker into concrete work items on
790
+ * the strategy's own queues. Two cases:
791
+ *
792
+ * - `level === 1`: the picker has asked for L1 coverage on a raw chunk.
793
+ * In the autobio chunker model, each chunk maps to exactly one L1, so
794
+ * we locate the chunk whose messages fall in `op.range` and ensure
795
+ * it is queued for L1 compression. If the chunker hasn't realized the
796
+ * message yet, we skip silently — the next `rebuildChunks` will pick
797
+ * it up.
798
+ *
799
+ * - `level >= 2`: the picker has asked for an L_n covering a contiguous
800
+ * range. We gather unmerged L_{n-1} summaries whose source ranges
801
+ * fall within that range (de-duplicated against entries already in
802
+ * `mergeQueue`) and enqueue a single merge over them. The merge fires
803
+ * on the next `tick()`.
804
+ *
805
+ * The handler is conservative: it never enqueues a singleton or empty
806
+ * merge, and it never re-enqueues an id that's already pending. That
807
+ * keeps the next-compile picker loop convergent even when the same
808
+ * produce op gets re-emitted before the work completes.
809
+ */
810
+ protected handleProducedOps(ops: readonly FoldOp[]): void {
811
+ for (const op of ops) {
812
+ if (op.kind !== 'produce') continue;
813
+ if (op.level === 1) {
814
+ this.enqueueL1ForRange(op.range.firstChunkId, op.range.lastChunkId);
815
+ } else if (op.level >= 2) {
816
+ this.enqueueMergeForRange(
817
+ op.level,
818
+ op.range.firstChunkId,
819
+ op.range.lastChunkId,
820
+ );
821
+ }
822
+ }
823
+ }
824
+
825
+ /**
826
+ * Ensure that chunks whose message range overlaps [firstMsgId..lastMsgId]
827
+ * are queued for L1 compression. No-op if the matching chunk is already
828
+ * compressed or already in the queue.
829
+ */
830
+ protected enqueueL1ForRange(firstMsgId: MessageId, lastMsgId: MessageId): void {
831
+ const messageIdToChunk = new Map<MessageId, Chunk>();
832
+ for (const ch of this.chunks) {
833
+ for (const m of ch.messages) messageIdToChunk.set(m.id, ch);
834
+ }
835
+ const candidates = new Set<Chunk>();
836
+ const first = messageIdToChunk.get(firstMsgId);
837
+ const last = messageIdToChunk.get(lastMsgId);
838
+ if (first) candidates.add(first);
839
+ if (last) candidates.add(last);
840
+ // Also catch chunks fully spanned by the range (rare, but supports the
841
+ // case where the picker requests an L1 that should logically cover
842
+ // multiple chunks worth of messages — we err on the side of producing
843
+ // L1s for every spanned chunk).
844
+ if (first && last && first.index !== last.index) {
845
+ const [lo, hi] = first.index < last.index
846
+ ? [first.index, last.index]
847
+ : [last.index, first.index];
848
+ for (let i = lo; i <= hi; i++) {
849
+ const ch = this.chunks[i];
850
+ if (ch) candidates.add(ch);
851
+ }
852
+ }
853
+ for (const chunk of candidates) {
854
+ if (chunk.compressed) continue;
855
+ if (this.compressionQueue.includes(chunk.index)) continue;
856
+ this.compressionQueue.push(chunk.index);
857
+ }
858
+ }
859
+
860
+ /**
861
+ * Enqueue an L_{targetLevel} merge over unmerged L_{targetLevel-1}
862
+ * summaries whose source ranges fall within [firstMsgId..lastMsgId].
863
+ * No-op if fewer than 2 viable sources are available (a singleton merge
864
+ * would just rename a summary without consolidating).
865
+ */
866
+ protected enqueueMergeForRange(
867
+ targetLevel: number,
868
+ firstMsgId: MessageId,
869
+ lastMsgId: MessageId,
870
+ ): void {
871
+ const sourceLevel = targetLevel - 1;
872
+
873
+ // IDs already enqueued at this target level.
874
+ const queuedAtLevel = new Set<string>();
875
+ for (const m of this.mergeQueue) {
876
+ if (m.level === targetLevel) {
877
+ for (const id of m.sourceIds) queuedAtLevel.add(id);
878
+ }
879
+ }
880
+
881
+ // Sequence index per message id, for "within range" tests. Use the
882
+ // current chunk store as the ordering source.
883
+ const messageOrder = new Map<MessageId, number>();
884
+ let seq = 0;
885
+ for (const ch of this.chunks) {
886
+ for (const m of ch.messages) {
887
+ messageOrder.set(m.id, seq++);
888
+ }
889
+ }
890
+ const firstSeq = messageOrder.get(firstMsgId);
891
+ const lastSeq = messageOrder.get(lastMsgId);
892
+
893
+ const inRange = (msgId: MessageId): boolean => {
894
+ if (firstSeq === undefined || lastSeq === undefined) return true;
895
+ const s = messageOrder.get(msgId);
896
+ if (s === undefined) return false;
897
+ const [lo, hi] = firstSeq <= lastSeq ? [firstSeq, lastSeq] : [lastSeq, firstSeq];
898
+ return s >= lo && s <= hi;
899
+ };
900
+
901
+ const sources: SummaryEntry[] = [];
902
+ for (const s of this.summaries) {
903
+ if (s.level !== sourceLevel) continue;
904
+ if (getSummaryParentId(s)) continue;
905
+ if (queuedAtLevel.has(s.id)) continue;
906
+ if (!inRange(s.sourceRange.first) && !inRange(s.sourceRange.last)) continue;
907
+ sources.push(s);
908
+ }
909
+ if (sources.length < 2) return;
910
+
911
+ const N = this.config.mergeThreshold ?? 6;
912
+ const toMerge = sources.slice(0, N);
913
+ this.enqueueMerge({
914
+ level: targetLevel as SummaryLevel,
915
+ sourceIds: toMerge.map((s) => s.id),
916
+ });
917
+ }
918
+
114
919
  checkReadiness(): ReadinessState {
115
920
  if (this.pendingCompression) {
116
921
  return {
@@ -141,16 +946,75 @@ export class AutobiographicalStrategy implements ResettableStrategy {
141
946
  async onNewMessage(message: StoredMessage, ctx: StrategyContext): Promise<void> {
142
947
  this.rebuildChunks(ctx.messageStore);
143
948
 
144
- // Auto-tick: fire compression in the background so it runs without
145
- // the framework explicitly calling tick(). compile() will await
146
- // pendingCompression via checkReadiness().
147
- if (this.config.autoTickOnNewMessage && this.compressionQueue.length > 0 && !this.pendingCompression) {
148
- this.tick(ctx).catch((err) =>
149
- console.error('AutobiographicalStrategy: auto-tick error:', err)
150
- );
949
+ // Auto-tick: fire speculative compression in the background. After
950
+ // each tick completes, if the queue still has work AND we're under
951
+ // the speculation cap AND preflight allows, schedule another tick.
952
+ // This drains the queue ahead of need rather than one-chunk-per-
953
+ // user-turn (reactive). Combined with ContextManager.compile not
954
+ // awaiting pendingCompression, the agent's response and background
955
+ // compression run truly in parallel.
956
+ if (this.config.autoTickOnNewMessage && !this.pendingCompression) {
957
+ this.driveSpeculativeDrain(ctx);
151
958
  }
152
959
  }
153
960
 
961
+ /**
962
+ * Background-drain loop: keeps calling tick() while there's queued work,
963
+ * subject to the speculation cap and preflight hook. Recurses via
964
+ * `queueMicrotask` so one chunk's compression doesn't block the
965
+ * scheduling of the next.
966
+ *
967
+ * Stops if a tick fails to make progress (queue size unchanged) — guards
968
+ * against runaway recursion when tick is a no-op (e.g. no membrane
969
+ * configured, or a subclass override that doesn't process the queue).
970
+ */
971
+ protected driveSpeculativeDrain(ctx: StrategyContext): void {
972
+ if (this.pendingCompression) return;
973
+ if (this.compressionQueue.length === 0 && this.mergeQueue.length === 0) return;
974
+ if (this.isAtSpeculativeCap()) return;
975
+ if (!this.shouldCompressPreflight()) return;
976
+
977
+ const beforeChunks = this.compressionQueue.length;
978
+ const beforeMerges = this.mergeQueue.length;
979
+
980
+ this.tick(ctx)
981
+ .then(() => {
982
+ const afterChunks = this.compressionQueue.length;
983
+ const afterMerges = this.mergeQueue.length;
984
+ // Made progress if either queue shrank.
985
+ const progressed = afterChunks < beforeChunks || afterMerges < beforeMerges;
986
+ if (!progressed) return;
987
+ // Recurse to drain more. queueMicrotask defers until the current
988
+ // task is done, letting other code (the agent's stream consumer)
989
+ // interleave.
990
+ queueMicrotask(() => this.driveSpeculativeDrain(ctx));
991
+ })
992
+ .catch((err) => {
993
+ console.error('AutobiographicalStrategy: speculative-drain error:', err);
994
+ });
995
+ }
996
+
997
+ /**
998
+ * Whether the strategy's pending+queued L1 budget has reached the cap
999
+ * configured by `maxSpeculativeL1s`. If no cap is set, always false.
1000
+ */
1001
+ protected isAtSpeculativeCap(): boolean {
1002
+ const cap = this.config.maxSpeculativeL1s;
1003
+ if (cap === undefined || cap < 0) return false;
1004
+ const unmergedL1s = this.summaries.filter(s => s.level === 1 && !s.mergedInto).length;
1005
+ return unmergedL1s + this.compressionQueue.length > cap;
1006
+ }
1007
+
1008
+ /**
1009
+ * Preflight hook for whether speculative compression should fire on
1010
+ * `onNewMessage`. Returns true by default (current eager behavior).
1011
+ * Subclasses can override for predictive scheduling — e.g. only fire
1012
+ * when the live tail token count is approaching some threshold.
1013
+ */
1014
+ protected shouldCompressPreflight(): boolean {
1015
+ return true;
1016
+ }
1017
+
154
1018
  async tick(ctx: StrategyContext): Promise<void> {
155
1019
  if (this.pendingCompression) return;
156
1020
 
@@ -179,18 +1043,58 @@ export class AutobiographicalStrategy implements ResettableStrategy {
179
1043
  }
180
1044
 
181
1045
  // Priority 2: Execute pending merges (hierarchical only)
1046
+ //
1047
+ // Peek at the head rather than dequeueing eagerly: dequeueMerge persists
1048
+ // the shorter queue *before* the LLM call leaves the building, so a
1049
+ // transient failure (429, network drop, timeout, executeMerge throw)
1050
+ // would silently lose the merge from disk and the sources would sit at
1051
+ // level N-1 with no mergedInto pointers forever. Commit the removal
1052
+ // only after the merge succeeds; on failure, the queue keeps its entry
1053
+ // and the next tick() retries it.
182
1054
  if (this.config.hierarchical && this.mergeQueue.length > 0) {
183
- const merge = this.mergeQueue.shift()!;
1055
+ const merge = this.mergeQueue[0]!;
184
1056
  this.pendingCompression = this.executeMerge(merge.level, merge.sourceIds, ctx);
185
1057
 
186
1058
  try {
187
1059
  await this.pendingCompression;
1060
+ // Success: drop from head and persist the shorter queue. We
1061
+ // re-check that head is still our merge in case some future code
1062
+ // path mutates the queue mid-await (today no other site does,
1063
+ // but the assertion makes that invariant explicit).
1064
+ if (this.mergeQueue[0] === merge) {
1065
+ this.dequeueMerge();
1066
+ }
188
1067
  } finally {
189
1068
  this.pendingCompression = null;
190
1069
  }
191
1070
  }
192
1071
  }
193
1072
 
1073
+ /**
1074
+ * Snapshot of compression progress. Intended for external observers
1075
+ * (warmup scripts, dashboards) that need to track convergence without
1076
+ * reaching into protected fields. Values are point-in-time copies; mutating
1077
+ * them does not affect strategy state.
1078
+ */
1079
+ getProgressSnapshot(): AutobiographicalProgressSnapshot {
1080
+ let chunksCompressed = 0;
1081
+ for (const c of this.chunks) if (c.compressed) chunksCompressed++;
1082
+ let l1 = 0, l2 = 0, l3 = 0;
1083
+ for (const s of this.summaries) {
1084
+ if (s.level === 1) l1++;
1085
+ else if (s.level === 2) l2++;
1086
+ else if (s.level === 3) l3++;
1087
+ }
1088
+ return {
1089
+ totalChunks: this.chunks.length,
1090
+ chunksCompressed,
1091
+ l1QueueLength: this.compressionQueue.length,
1092
+ mergeQueueLength: this.mergeQueue.length,
1093
+ summaryCounts: { l1, l2, l3 },
1094
+ pending: this.pendingCompression !== null,
1095
+ };
1096
+ }
1097
+
194
1098
  select(
195
1099
  store: MessageStoreView,
196
1100
  log: ContextLogView,
@@ -198,6 +1102,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
198
1102
  ): ContextEntry[] {
199
1103
  this.rebuildChunks(store);
200
1104
 
1105
+ if (this.config.adaptiveResolution) {
1106
+ return this.selectAdaptive(store, budget);
1107
+ }
201
1108
  return this.config.hierarchical
202
1109
  ? this.selectHierarchical(store, budget)
203
1110
  : this.selectLegacy(store, log, budget);
@@ -221,6 +1128,56 @@ export class AutobiographicalStrategy implements ResettableStrategy {
221
1128
  };
222
1129
  }
223
1130
 
1131
+ /**
1132
+ * Richer per-render stats: requires a message store view to compute the
1133
+ * head + tail (recent window) sizes. Returns counts AND token sums per
1134
+ * summary level, so observers can see "how much of the agent's context
1135
+ * is in raw tail vs folded into L1/L2/L3."
1136
+ *
1137
+ * Useful for TUI / dashboards. The token sums use the strategy's own
1138
+ * token estimates (which match what `select()` uses for budget math).
1139
+ */
1140
+ getRenderStats(store: MessageStoreView): {
1141
+ head: { messages: number; tokens: number };
1142
+ tail: { messages: number; tokens: number };
1143
+ summaries: {
1144
+ l1: { count: number; tokens: number };
1145
+ l2: { count: number; tokens: number };
1146
+ l3: { count: number; tokens: number };
1147
+ };
1148
+ pending: { chunks: number; merges: number };
1149
+ } {
1150
+ const messages = store.getAll();
1151
+ const headStart = this.getHeadWindowStartIndex(store);
1152
+ const headEnd = this.getHeadWindowEnd(store);
1153
+ const recentStart = this.getRecentWindowStart(store);
1154
+
1155
+ const sumTokens = (slice: StoredMessage[]): number =>
1156
+ slice.reduce((acc, m) => acc + store.estimateTokens(m), 0);
1157
+
1158
+ const headMsgs = messages.slice(headStart, headEnd);
1159
+ const tailMsgs = messages.slice(recentStart);
1160
+
1161
+ const live = (level: SummaryLevel) =>
1162
+ this.summaries.filter(s => s.level === level && !s.mergedInto);
1163
+ const sumLevelTokens = (level: SummaryLevel): number =>
1164
+ live(level).reduce((acc, s) => acc + s.tokens, 0);
1165
+
1166
+ return {
1167
+ head: { messages: headMsgs.length, tokens: sumTokens(headMsgs) },
1168
+ tail: { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) },
1169
+ summaries: {
1170
+ l1: { count: live(1).length, tokens: sumLevelTokens(1) },
1171
+ l2: { count: live(2).length, tokens: sumLevelTokens(2) },
1172
+ l3: { count: live(3).length, tokens: sumLevelTokens(3) },
1173
+ },
1174
+ pending: {
1175
+ chunks: this.chunks.filter(c => !c.compressed).length,
1176
+ merges: this.mergeQueue.length,
1177
+ },
1178
+ };
1179
+ }
1180
+
224
1181
  // ============================================================================
225
1182
  // Legacy (single-level) path
226
1183
  // ============================================================================
@@ -243,7 +1200,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
243
1200
  const msg = messages[i];
244
1201
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
245
1202
  const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
246
- if (totalTokens + tokens > maxTokens) break;
1203
+ if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
247
1204
 
248
1205
  entries.push({
249
1206
  index: entries.length,
@@ -291,7 +1248,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
291
1248
  const pairTokens = this.estimateTokens(questionEntry.content) +
292
1249
  this.estimateTokens(answerEntry.content);
293
1250
 
294
- if (totalTokens + pairTokens > maxTokens) break;
1251
+ if (this.isOverBudget(totalTokens + pairTokens, maxTokens)) break;
295
1252
 
296
1253
  entries.push(questionEntry);
297
1254
  entries.push(answerEntry);
@@ -301,7 +1258,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
301
1258
  for (const msg of chunk.messages) {
302
1259
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
303
1260
  const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
304
- if (totalTokens + tokens > maxTokens) break;
1261
+ if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
305
1262
 
306
1263
  entries.push({
307
1264
  index: entries.length,
@@ -324,7 +1281,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
324
1281
  const msg = messages[i];
325
1282
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
326
1283
  const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
327
- if (totalTokens + tokens > maxTokens) break;
1284
+ if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
328
1285
 
329
1286
  entries.push({
330
1287
  index: entries.length,
@@ -341,6 +1298,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
341
1298
  this.emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokens);
342
1299
 
343
1300
  this.trimOrphanedToolUse(entries);
1301
+ this.pruneToolEntries(entries);
344
1302
  return entries;
345
1303
  }
346
1304
 
@@ -373,7 +1331,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
373
1331
  const tokens = msgCap > 0
374
1332
  ? Math.min(store.estimateTokens(msg), msgCap + 50)
375
1333
  : store.estimateTokens(msg);
376
- if (totalTokensBefore + acceptedTokens + tokens > maxTokens) break;
1334
+ if (this.isOverBudget(totalTokensBefore + acceptedTokens + tokens, maxTokens)) break;
377
1335
  accepted.push(i);
378
1336
  acceptedTokens += tokens;
379
1337
  }
@@ -429,7 +1387,6 @@ export class AutobiographicalStrategy implements ResettableStrategy {
429
1387
  config: {
430
1388
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
431
1389
  maxTokens: 2000,
432
- temperature: 0,
433
1390
  },
434
1391
  };
435
1392
 
@@ -545,57 +1502,152 @@ export class AutobiographicalStrategy implements ResettableStrategy {
545
1502
  throw new Error('No membrane instance for compression');
546
1503
  }
547
1504
 
548
- const chunkMessageIds = new Set(chunk.messages.map(m => m.id));
549
- const { shownL3, shownL2, shownL1 } = this.getAntiRedundantSummaries(chunkMessageIds);
550
-
551
1505
  const targetTokens = this.config.summaryTargetTokens ?? 2000;
552
- const chunkContent = this.formatChunkForCompression(chunk);
553
-
554
- // Build message array: prior summaries as assistant, then instruction
1506
+ const agentParticipant = this.config.summaryParticipant ?? 'Claude';
1507
+
1508
+ // Build the KV-preserving prompt per hermes-autobio spec:
1509
+ //
1510
+ // 1. Prior L1 summaries — narrativized as CM-asks / agent-recalls
1511
+ // pairs, in source order. ALL existing L1s at L1 fidelity
1512
+ // regardless of merge state ("fill lower orbitals first").
1513
+ // 2. Head — raw messages before the chunk that aren't already
1514
+ // represented by a prior L1.
1515
+ // 3. Marker — in-band signal that a memory is about to form.
1516
+ // 4. Chunk — raw messages being compressed, as the agent
1517
+ // experienced them.
1518
+ // 5. Instruction — doc-aware if the chunk is part of a bodyGroup.
1519
+ //
1520
+ // There is intentionally NO tail_after_chunk: that would leak
1521
+ // future information into the model's KV state and corrupt the
1522
+ // as-of framing of memory formation.
555
1523
  const llmMessages: Array<{ participant: string; content: ContentBlock[] }> = [];
556
1524
 
557
- // Prior summaries as agent's own recollections (L3 → L2 → L1 gradient)
558
- const allPriorSummaries = [...shownL3, ...shownL2, ...shownL1];
559
- for (const s of allPriorSummaries) {
1525
+ // ---- 1. Prior L1 recall pairs ----
1526
+ // Sort by source position so the agent walks its prior memories
1527
+ // chronologically, matching how it would have formed them.
1528
+ const priorL1s = this.summaries
1529
+ .filter((s) => s.level === 1)
1530
+ .sort((a, b) => a.sourceRange.first.localeCompare(b.sourceRange.first));
1531
+ for (const s of priorL1s) {
1532
+ llmMessages.push({
1533
+ participant: 'Context Manager',
1534
+ content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
1535
+ });
560
1536
  llmMessages.push({
561
- participant: this.config.summaryParticipant ?? 'Claude',
1537
+ participant: agentParticipant,
562
1538
  content: [{ type: 'text', text: s.content }],
563
1539
  });
564
1540
  }
565
1541
 
566
- // Context Manager instruction with chunk content
567
- const instruction = this.getCompressionInstruction(chunk, targetTokens);
1542
+ // ---- 2. Head window (raw, ALWAYS present) ----
1543
+ //
1544
+ // The head is the foundational identity anchor: the actual opening
1545
+ // of the chronicle (the user's first message, the agent's first
1546
+ // reply, the system context if any). It establishes WHO is speaking
1547
+ // to WHOM. Without it, when the chunk content is heavily first-person
1548
+ // from someone other than the agent (e.g., a user-shared document),
1549
+ // the agent loses its first-person grounding and drifts into the
1550
+ // content author's voice.
1551
+ //
1552
+ // The head is the configured head window — not "everything before
1553
+ // the chunk." For doc-heavy chronicles, "everything before" would
1554
+ // be hundreds of thousands of tokens; the recall pairs already
1555
+ // represent that intermediate content. The head is just the
1556
+ // permanent prefix that the original instance always saw.
1557
+ //
1558
+ // Head messages are excluded from compression by `getCompressibleMessages`
1559
+ // (they're outside the chunking range), so they won't appear in
1560
+ // any L1's sourceIds — no overlap with the recall pairs above.
1561
+ const allMessages = ctx.messageStore.getAll();
1562
+ const headStartIdx = this.getHeadWindowStartIndex(ctx.messageStore);
1563
+ const headEndIdx = this.getHeadWindowEnd(ctx.messageStore);
1564
+ for (let i = headStartIdx; i < headEndIdx && i < allMessages.length; i++) {
1565
+ const m = allMessages[i];
1566
+ llmMessages.push({ participant: m.participant, content: m.content });
1567
+ }
1568
+
1569
+ // Any raw messages between the head and the chunk that aren't yet
1570
+ // represented by an L1 — usually empty in adaptive-resolution mode,
1571
+ // since chunking proceeds contiguously and L1s exist for everything
1572
+ // up to the chunk being processed.
1573
+ const chunkFirstId = chunk.messages[0]?.id;
1574
+ if (chunkFirstId) {
1575
+ const priorL1MessageIds = new Set<string>();
1576
+ for (const s of priorL1s) {
1577
+ for (const id of s.sourceIds) priorL1MessageIds.add(id);
1578
+ }
1579
+ const chunkStartIdx = allMessages.findIndex((m) => m.id === chunkFirstId);
1580
+ for (let i = headEndIdx; i < chunkStartIdx && i < allMessages.length; i++) {
1581
+ const m = allMessages[i];
1582
+ if (priorL1MessageIds.has(m.id)) continue;
1583
+ llmMessages.push({ participant: m.participant, content: m.content });
1584
+ }
1585
+ }
1586
+
1587
+ // ---- 3. In-band marker ----
568
1588
  llmMessages.push({
569
1589
  participant: 'Context Manager',
570
- content: [{
571
- type: 'text',
572
- text: `[Context Manager] We are ready to form a long-term memory. Here is the conversation to remember:\n\n${chunkContent}\n\n${instruction}`,
573
- }],
1590
+ content: [{ type: 'text', text: COMPRESSION_MARKER }],
1591
+ });
1592
+
1593
+ // ---- 4. Chunk messages raw ----
1594
+ for (const m of chunk.messages) {
1595
+ llmMessages.push({ participant: m.participant, content: m.content });
1596
+ }
1597
+
1598
+ // ---- 5. Instruction (reading-mode aware) ----
1599
+ //
1600
+ // When the chunk is a portion of a substantially larger sharded message
1601
+ // (≥ 2× chunk size), use the reading-mode instruction. It avoids the
1602
+ // "form a memory of what this contained" framing — which, for content
1603
+ // heavily first-person from someone other than the agent (a user-shared
1604
+ // doc), leads the model to adopt the content author's voice. Instead,
1605
+ // it asks what reading was like and what was learned, forcing the
1606
+ // model to reflect from its own vantage point in agent-first-person.
1607
+ const docContext = this.detectDocContext(chunk, ctx);
1608
+ const instructionText = docContext
1609
+ ? this.getReadingChunkInstruction(chunk, docContext.totalTokens, targetTokens)
1610
+ : this.getCompressionInstruction(chunk, targetTokens);
1611
+ llmMessages.push({
1612
+ participant: 'Context Manager',
1613
+ content: [{ type: 'text', text: instructionText }],
574
1614
  });
575
1615
 
576
1616
  // Collapse consecutive same-participant messages for API compliance
577
1617
  const collapsed = this.collapseConsecutiveMessages(llmMessages);
578
1618
 
1619
+ // NO system prompt. The agent's identity is established by the head
1620
+ // (the actual conversation opening — user message + agent reply that
1621
+ // grounded the original instance). A system prompt would (a) add a
1622
+ // synthetic header the original instance never saw, disturbing KV
1623
+ // consistency between the summarizer and the original instance, and
1624
+ // (b) provide an alternative identity source that competes with the
1625
+ // structural one carried by the conversation itself. Anchoring
1626
+ // identity by the chronicle's actual head is more honest.
579
1627
  const request: NormalizedRequest = {
580
1628
  messages: collapsed.map(m => ({ participant: m.participant, content: m.content })),
581
- system: 'You are forming autobiographical memories of a conversation.',
582
1629
  config: {
583
1630
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
584
1631
  maxTokens: Math.round(targetTokens * 1.5),
585
- temperature: 0,
586
1632
  },
587
1633
  };
588
1634
 
1635
+ const callStart = Date.now();
1636
+ let logResponse: string | undefined;
1637
+ let logError: string | undefined;
1638
+ let logSummaryId: string | undefined;
1639
+
589
1640
  try {
590
1641
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
591
1642
  const summaryText = response.content
592
1643
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
593
1644
  .map(b => b.text)
594
1645
  .join('\n');
1646
+ logResponse = summaryText;
595
1647
 
596
1648
  const messageIds = chunk.messages.map(m => m.id);
597
1649
  const entry: SummaryEntry = {
598
- id: `L1-${this.summaryIdCounter++}`,
1650
+ id: `L1-${this.nextSummaryIdCounter()}`,
599
1651
  level: 1,
600
1652
  content: summaryText,
601
1653
  tokens: Math.ceil(summaryText.length / 4),
@@ -609,157 +1661,1033 @@ export class AutobiographicalStrategy implements ResettableStrategy {
609
1661
  phaseType: chunk.phaseType,
610
1662
  };
611
1663
 
612
- this.summaries.push(entry);
1664
+ this.pushSummary(entry);
613
1665
  chunk.compressed = true;
614
1666
  chunk.summaryId = entry.id;
615
1667
  this._compressionCount++;
1668
+ logSummaryId = entry.id;
616
1669
 
617
1670
  this.checkMergeThreshold();
618
1671
  } catch (error) {
619
1672
  console.error('Failed to compress chunk (hierarchical):', error);
1673
+ logError = error instanceof Error ? error.message : String(error);
620
1674
  throw error;
1675
+ } finally {
1676
+ logCompressionCall({
1677
+ operation: 'compress_l1',
1678
+ system: null,
1679
+ messages: collapsed.map((m) => ({
1680
+ participant: m.participant,
1681
+ // Flatten content for logging — store text only; binary content
1682
+ // would bloat the log and isn't typical in compression input.
1683
+ text: m.content
1684
+ .filter((b: ContentBlock) => b.type === 'text')
1685
+ .map((b: any) => b.text)
1686
+ .join(''),
1687
+ })),
1688
+ metadata: {
1689
+ chunk_message_ids: chunk.messages.map((m) => m.id),
1690
+ chunk_size: chunk.messages.length,
1691
+ prior_l1_count: priorL1s.length,
1692
+ has_doc_context: docContext !== null,
1693
+ doc_context: docContext,
1694
+ target_tokens: targetTokens,
1695
+ model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
1696
+ latency_ms: Date.now() - callStart,
1697
+ summary_id: logSummaryId,
1698
+ },
1699
+ response: logResponse,
1700
+ error: logError,
1701
+ });
621
1702
  }
622
1703
  }
623
1704
 
624
1705
  /**
625
1706
  * Check if unmerged summary counts exceed the merge threshold.
626
1707
  * Enqueues merge operations if so.
1708
+ *
1709
+ * Skips L1s/L2s that are already in a pending merge — without this guard,
1710
+ * each new summary above threshold re-enqueues a merge for the same
1711
+ * already-eligible siblings, producing N near-identical higher-level
1712
+ * summaries when the queue eventually drains.
627
1713
  */
628
1714
  protected checkMergeThreshold(): void {
1715
+ if (this.config.speculativeProduction) {
1716
+ this.checkMergeThresholdRecursive();
1717
+ return;
1718
+ }
1719
+
629
1720
  const threshold = this.config.mergeThreshold ?? 6;
630
1721
 
1722
+ // IDs that are already part of a queued merge — exclude them from
1723
+ // eligibility so we don't re-enqueue.
1724
+ const queuedL1 = new Set<string>();
1725
+ const queuedL2 = new Set<string>();
1726
+ for (const m of this.mergeQueue) {
1727
+ const set = m.level === 2 ? queuedL1 : queuedL2;
1728
+ for (const id of m.sourceIds) set.add(id);
1729
+ }
1730
+
631
1731
  // Check L1 → L2
632
- const unmergedL1 = this.summaries.filter(s => s.level === 1 && !s.mergedInto);
1732
+ const unmergedL1 = this.summaries.filter(
1733
+ s => s.level === 1 && !s.mergedInto && !queuedL1.has(s.id),
1734
+ );
633
1735
  if (unmergedL1.length >= threshold) {
634
1736
  const toMerge = unmergedL1.slice(0, threshold);
635
- this.mergeQueue.push({
1737
+ this.enqueueMerge({
636
1738
  level: 2,
637
1739
  sourceIds: toMerge.map(s => s.id),
638
1740
  });
639
1741
  }
640
1742
 
641
1743
  // Check L2 → L3
642
- const unmergedL2 = this.summaries.filter(s => s.level === 2 && !s.mergedInto);
1744
+ const unmergedL2 = this.summaries.filter(
1745
+ s => s.level === 2 && !s.mergedInto && !queuedL2.has(s.id),
1746
+ );
643
1747
  if (unmergedL2.length >= threshold) {
644
1748
  const toMerge = unmergedL2.slice(0, threshold);
645
- this.mergeQueue.push({
1749
+ this.enqueueMerge({
646
1750
  level: 3,
647
1751
  sourceIds: toMerge.map(s => s.id),
648
1752
  });
649
1753
  }
650
- }
651
-
652
- /**
653
- * Merge N summaries at one level into a single summary at the next level.
654
- * Uses self-voice consolidation prompt.
655
- */
656
- protected async executeMerge(
657
- targetLevel: SummaryLevel,
658
- sourceIds: string[],
659
- ctx: StrategyContext
660
- ): Promise<void> {
661
- if (!ctx.membrane) {
662
- throw new Error('No membrane instance for merge');
1754
+ }
1755
+
1756
+ /**
1757
+ * Bottom-up speculative pre-producer (design doc §3.5 / §7.2).
1758
+ *
1759
+ * Recursive variant of `checkMergeThreshold` for the unbounded L_n
1760
+ * design. Walks every level present in the archive; for any level k
1761
+ * with ≥ N orphans (no parent), enqueues an L_{k+1} merge. After that
1762
+ * L_{k+1} is produced and `executeMerge` calls this again, the recursion
1763
+ * naturally cascades: 6 L1s → 1 L2; 6 L2s → 1 L3; 6 L3s → 1 L4; ...
1764
+ *
1765
+ * Only fires when `config.speculativeProduction` is true. Default true
1766
+ * for adaptiveResolution=true, false otherwise. The non-speculative path
1767
+ * (above) preserves the original L1→L2→L3 behavior for non-adaptive
1768
+ * deployments.
1769
+ */
1770
+ protected checkMergeThresholdRecursive(): void {
1771
+ const threshold = this.config.mergeThreshold ?? 6;
1772
+
1773
+ // Build per-level sets of source-ids already enqueued for merging,
1774
+ // so we don't re-enqueue them while a merge is pending.
1775
+ const queuedSources = new Map<number, Set<string>>();
1776
+ for (const m of this.mergeQueue) {
1777
+ // m.level is the TARGET level; the sources are at level (m.level - 1).
1778
+ const sourceLevel = m.level - 1;
1779
+ if (!queuedSources.has(sourceLevel)) queuedSources.set(sourceLevel, new Set());
1780
+ for (const id of m.sourceIds) queuedSources.get(sourceLevel)!.add(id);
1781
+ }
1782
+
1783
+ // Walk every level present in the archive. Iterate from low to high
1784
+ // so when an L_{k+1} merge is enqueued and immediately produced, this
1785
+ // same check sees the new L_{k+1} on the next call and can roll up.
1786
+ let maxLevel = 0;
1787
+ for (const s of this.summaries) {
1788
+ if (s.level > maxLevel) maxLevel = s.level;
1789
+ }
1790
+ for (let level = 1; level <= maxLevel; level++) {
1791
+ const queued = queuedSources.get(level) ?? new Set();
1792
+ const unmerged = this.summaries.filter(
1793
+ s => s.level === level && !getSummaryParentId(s) && !queued.has(s.id),
1794
+ );
1795
+ if (unmerged.length >= threshold) {
1796
+ const toMerge = unmerged.slice(0, threshold);
1797
+ this.enqueueMerge({
1798
+ level: level + 1,
1799
+ sourceIds: toMerge.map(s => s.id),
1800
+ });
1801
+ }
1802
+ }
1803
+ }
1804
+
1805
+ /**
1806
+ * Merge N summaries at one level into a single summary at the next level.
1807
+ * Uses self-voice consolidation prompt.
1808
+ */
1809
+ protected async executeMerge(
1810
+ targetLevel: SummaryLevel,
1811
+ sourceIds: string[],
1812
+ ctx: StrategyContext
1813
+ ): Promise<void> {
1814
+ if (!ctx.membrane) {
1815
+ throw new Error('No membrane instance for merge');
1816
+ }
1817
+
1818
+ const sources = sourceIds
1819
+ .map(id => this.summaries.find(s => s.id === id))
1820
+ .filter((s): s is SummaryEntry => s != null);
1821
+
1822
+ if (sources.length !== sourceIds.length) {
1823
+ console.warn('executeMerge: some source summaries not found, skipping');
1824
+ return;
1825
+ }
1826
+
1827
+ // Defensive: if every source is already mergedInto something, this is a
1828
+ // stale queue entry (could happen if multiple merges for the same
1829
+ // sourceIds were enqueued before the dedup fix in checkMergeThreshold).
1830
+ // Skip rather than produce a redundant near-identical higher-level entry.
1831
+ if (sources.every(s => s.mergedInto)) {
1832
+ console.warn(
1833
+ `executeMerge: all sources already merged into ${sources[0].mergedInto}, skipping (stale queue entry)`,
1834
+ );
1835
+ return;
1836
+ }
1837
+
1838
+ const targetTokens = this.config.summaryTargetTokens ?? 2000;
1839
+ const participant = this.config.summaryParticipant ?? 'Claude';
1840
+
1841
+ // Build the merge prompt with one-level-deeper target expansion +
1842
+ // prefix of older context:
1843
+ //
1844
+ // 1. PREFIX — head messages + prior L1 recall pairs for content
1845
+ // that comes chronologically BEFORE the merge range. "Fill
1846
+ // lower orbitals first" per the spec: regardless of how
1847
+ // compressed the live view is, the summarizer always gets L1
1848
+ // fidelity for prior content. Older L2/L3 markers exist for
1849
+ // live-view compactness, not for the summarizer.
1850
+ //
1851
+ // 2. TARGET — the sources expanded ONE LEVEL DEEPER than they
1852
+ // themselves are. For L2 merge (sources at L1): expand to
1853
+ // raw L0 messages — the model sees the actual conversation
1854
+ // that the 6 L1s consolidate. For L3 merge (sources at L2):
1855
+ // expand to the L1s under each L2 (36 L1s as recall pairs).
1856
+ // For L_n merge (sources at L_{n-1}): expand to L_{n-2}.
1857
+ // This gives the model substantively more content to ground
1858
+ // the consolidation in than just the 6 surface summaries.
1859
+ //
1860
+ // 3. INSTRUCTION — "consolidate N memories preserving the
1861
+ // through-line, in first person".
1862
+ //
1863
+ // No tail-after-merge: same as-of principle as L1 compression. The
1864
+ // consolidation is being formed at the moment the last source was
1865
+ // ready, so nothing after that is visible.
1866
+ const llmMessages: Array<{ participant: string; content: ContentBlock[] }> = [];
1867
+
1868
+ // Build lookup maps
1869
+ const summariesById = new Map<string, SummaryEntry>();
1870
+ for (const s of this.summaries) summariesById.set(s.id, s);
1871
+ const allMessages = ctx.messageStore.getAll();
1872
+ const messageById = new Map<MessageId, typeof allMessages[number]>();
1873
+ for (const m of allMessages) messageById.set(m.id, m);
1874
+
1875
+ // Compute every leaf message id covered by this merge's lineage —
1876
+ // these are part of the TARGET and must not also appear in the
1877
+ // PREFIX as head content.
1878
+ const sourceLeafIds = new Set<MessageId>();
1879
+ const collectLeaves = (s: SummaryEntry): void => {
1880
+ if (s.sourceLevel === 0) {
1881
+ for (const id of s.sourceIds) sourceLeafIds.add(id);
1882
+ } else {
1883
+ for (const childId of s.sourceIds) {
1884
+ const child = summariesById.get(childId);
1885
+ if (child) collectLeaves(child);
1886
+ }
1887
+ }
1888
+ };
1889
+ for (const src of sources) collectLeaves(src);
1890
+
1891
+ // Find the start of the merge range in the message store.
1892
+ const mergeFirstMsgId = sources[0].sourceRange.first;
1893
+ const mergeStartIdx = allMessages.findIndex((m) => m.id === mergeFirstMsgId);
1894
+
1895
+ // ---- 1a. HEAD WINDOW (raw, ALWAYS present) ----
1896
+ //
1897
+ // The head window is the foundational identity anchor — the actual
1898
+ // opening of the chronicle. It establishes who is speaking to whom.
1899
+ // Without it, when the merge target's content is heavily first-person
1900
+ // from someone other than the agent, the agent loses its first-person
1901
+ // grounding and drifts into the content author's voice.
1902
+ const headStartIdx = this.getHeadWindowStartIndex(ctx.messageStore);
1903
+ const headEndIdx = this.getHeadWindowEnd(ctx.messageStore);
1904
+ for (let i = headStartIdx; i < headEndIdx && i < allMessages.length; i++) {
1905
+ const m = allMessages[i];
1906
+ llmMessages.push({ participant: m.participant, content: m.content });
1907
+ }
1908
+
1909
+ // ---- 1b. PRIOR L1 RECALL PAIRS (chronologically before merge range) ----
1910
+ // L1s whose entire source range is before the merge range and that
1911
+ // aren't part of the merge tree. Sort by source position.
1912
+ const priorL1s = this.summaries
1913
+ .filter((s) => s.level === 1)
1914
+ .filter((s) => {
1915
+ // Exclude any L1 that's an ancestor of our merge target
1916
+ for (const lid of s.sourceIds) if (sourceLeafIds.has(lid)) return false;
1917
+ // Include only if it starts strictly before the merge range
1918
+ const firstIdx = allMessages.findIndex((m) => m.id === s.sourceRange.first);
1919
+ return firstIdx >= 0 && (mergeStartIdx < 0 || firstIdx < mergeStartIdx);
1920
+ })
1921
+ .sort((a, b) => {
1922
+ const ai = allMessages.findIndex((m) => m.id === a.sourceRange.first);
1923
+ const bi = allMessages.findIndex((m) => m.id === b.sourceRange.first);
1924
+ return ai - bi;
1925
+ });
1926
+
1927
+ const priorL1MessageIds = new Set<MessageId>();
1928
+ for (const s of priorL1s) {
1929
+ for (const id of s.sourceIds) priorL1MessageIds.add(id);
1930
+ }
1931
+
1932
+ for (const s of priorL1s) {
1933
+ llmMessages.push({
1934
+ participant: 'Context Manager',
1935
+ content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
1936
+ });
1937
+ llmMessages.push({
1938
+ participant,
1939
+ content: [{ type: 'text', text: s.content }],
1940
+ });
1941
+ }
1942
+
1943
+ // Raw middle: any messages between the head window and the merge
1944
+ // range that aren't covered by a prior L1 or the merge tree.
1945
+ // Usually empty (chunking is contiguous).
1946
+ if (mergeStartIdx >= 0) {
1947
+ for (let i = headEndIdx; i < mergeStartIdx; i++) {
1948
+ const m = allMessages[i];
1949
+ if (priorL1MessageIds.has(m.id)) continue;
1950
+ if (sourceLeafIds.has(m.id)) continue;
1951
+ llmMessages.push({ participant: m.participant, content: m.content });
1952
+ }
1953
+ }
1954
+
1955
+ // ---- 2. TARGET: expand sources one level deeper ----
1956
+ // For L2 (sources at L1, sourceLevel=0): expand to raw L0 messages.
1957
+ // For L3+ (sources at L_{n-1}, sourceLevel=n-2): expand to L_{n-2}
1958
+ // summaries as recall pairs.
1959
+ for (const src of sources) {
1960
+ if (src.sourceLevel === 0) {
1961
+ // Source is an L1; its sourceIds are raw message ids. Emit them raw.
1962
+ for (const messageId of src.sourceIds) {
1963
+ const m = messageById.get(messageId);
1964
+ if (m) {
1965
+ llmMessages.push({ participant: m.participant, content: m.content });
1966
+ }
1967
+ }
1968
+ } else {
1969
+ // Source is L2+; its sourceIds point to summaries one level
1970
+ // below. Emit each as a recall pair.
1971
+ for (const childId of src.sourceIds) {
1972
+ const child = summariesById.get(childId);
1973
+ if (!child) continue;
1974
+ llmMessages.push({
1975
+ participant: 'Context Manager',
1976
+ content: [{ type: 'text', text: `[CM] Recall memory ${child.id}.` }],
1977
+ });
1978
+ llmMessages.push({
1979
+ participant,
1980
+ content: [{ type: 'text', text: child.content }],
1981
+ });
1982
+ }
1983
+ }
1984
+ }
1985
+
1986
+ // ---- 3. INSTRUCTION ----
1987
+ // sourceLevelShown is the level of content the model actually sees
1988
+ // (one level below the sources themselves).
1989
+ const sourceLevelShown =
1990
+ sources[0].sourceLevel === 0 ? 0 : sources[0].level - 1;
1991
+
1992
+ // Reading-mode detection: when ALL the merge's leaf messages are
1993
+ // shards of the same bodyGroup, we know the agent was reading a
1994
+ // substantially larger message rather than conversing. The
1995
+ // reading-mode merge instruction asks what reading the stretch was
1996
+ // like instead of asking for an impersonal consolidation, which
1997
+ // forces the agent's vantage point and prevents drift into the
1998
+ // content author's voice. Same principle as the L1 case.
1999
+ let mergeReadingContext: { totalTokens: number } | null = null;
2000
+ if (sourceLeafIds.size > 0) {
2001
+ const leafBodyGroupIds = new Set<string | undefined>();
2002
+ for (const leafId of sourceLeafIds) {
2003
+ const m = messageById.get(leafId);
2004
+ leafBodyGroupIds.add(m?.bodyGroupId);
2005
+ }
2006
+ if (leafBodyGroupIds.size === 1) {
2007
+ const groupId = [...leafBodyGroupIds][0];
2008
+ if (groupId) {
2009
+ let totalTokens = 0;
2010
+ for (const m of allMessages) {
2011
+ if (m.bodyGroupId === groupId) {
2012
+ totalTokens += ctx.messageStore.estimateTokens(m);
2013
+ }
2014
+ }
2015
+ mergeReadingContext = { totalTokens };
2016
+ }
2017
+ }
2018
+ }
2019
+ const mergeInstructionText = mergeReadingContext
2020
+ ? this.getReadingMergeInstruction(
2021
+ targetLevel,
2022
+ sources,
2023
+ mergeReadingContext.totalTokens,
2024
+ targetTokens,
2025
+ )
2026
+ : this.getMergeInstruction(targetLevel, sources, targetTokens);
2027
+ llmMessages.push({
2028
+ participant: 'Context Manager',
2029
+ content: [{
2030
+ type: 'text',
2031
+ text: mergeInstructionText,
2032
+ }],
2033
+ });
2034
+
2035
+ const collapsed = this.collapseConsecutiveMessages(llmMessages);
2036
+
2037
+ // NO system prompt — identity is established by the head window
2038
+ // (present at the start of llmMessages above) and by the prior
2039
+ // recall pairs. Same rationale as compressChunkHierarchical.
2040
+ const request: NormalizedRequest = {
2041
+ messages: collapsed.map(m => ({ participant: m.participant, content: m.content })),
2042
+ config: {
2043
+ model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
2044
+ maxTokens: Math.round(targetTokens * 1.5),
2045
+ },
2046
+ };
2047
+
2048
+ const callStart = Date.now();
2049
+ let logResponse: string | undefined;
2050
+ let logError: string | undefined;
2051
+ let logNewSummaryId: string | undefined;
2052
+
2053
+ try {
2054
+ const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
2055
+ const mergedText = response.content
2056
+ .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
2057
+ .map(b => b.text)
2058
+ .join('\n');
2059
+ logResponse = mergedText;
2060
+
2061
+ // Compute source range from constituent summaries
2062
+ const sourceRange = {
2063
+ first: sources[0].sourceRange.first,
2064
+ last: sources[sources.length - 1].sourceRange.last,
2065
+ };
2066
+
2067
+ const sourceLevel = (targetLevel - 1) as 0 | 1 | 2;
2068
+ const newEntry: SummaryEntry = {
2069
+ id: `L${targetLevel}-${this.nextSummaryIdCounter()}`,
2070
+ level: targetLevel,
2071
+ content: mergedText,
2072
+ tokens: Math.ceil(mergedText.length / 4),
2073
+ sourceLevel,
2074
+ sourceIds,
2075
+ sourceRange,
2076
+ created: Date.now(),
2077
+ };
2078
+ logNewSummaryId = newEntry.id;
2079
+
2080
+ // Append the new merged entry first, then mark sources. Persist each
2081
+ // mergedInto edit individually so chronicle reflects the same shape as
2082
+ // the in-memory mirror. (If the process crashes mid-loop, restart sees
2083
+ // the new entry plus a partial set of marked sources; un-marked sources
2084
+ // would re-trigger a merge — accept the rare duplicate over data loss.)
2085
+ this.pushSummary(newEntry);
2086
+
2087
+ for (const source of sources) {
2088
+ this.setMergedInto(source, newEntry.id);
2089
+ }
2090
+
2091
+ // Check if this merge triggers a further merge
2092
+ this.checkMergeThreshold();
2093
+ } catch (error) {
2094
+ console.error(`Failed to merge summaries into L${targetLevel}:`, error);
2095
+ logError = error instanceof Error ? error.message : String(error);
2096
+ throw error;
2097
+ } finally {
2098
+ logCompressionCall({
2099
+ operation: `merge_l${targetLevel}`,
2100
+ system: null,
2101
+ messages: collapsed.map((m) => ({
2102
+ participant: m.participant,
2103
+ text: m.content
2104
+ .filter((b: ContentBlock) => b.type === 'text')
2105
+ .map((b: any) => b.text)
2106
+ .join(''),
2107
+ })),
2108
+ metadata: {
2109
+ target_level: targetLevel,
2110
+ source_ids: sourceIds,
2111
+ source_level: sources[0]?.level ?? null,
2112
+ source_level_shown: sourceLevelShown,
2113
+ target_tokens: targetTokens,
2114
+ model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
2115
+ latency_ms: Date.now() - callStart,
2116
+ summary_id: logNewSummaryId,
2117
+ },
2118
+ response: logResponse,
2119
+ error: logError,
2120
+ });
2121
+ }
2122
+ }
2123
+
2124
+ // ============================================================================
2125
+ // Adaptive resolution (picker-driven) path
2126
+ // ============================================================================
2127
+
2128
+ /**
2129
+ * Select context entries using the adaptive-resolution picker.
2130
+ *
2131
+ * Builds per-message PickerChunks from compressible messages, runs the
2132
+ * configured FoldingStrategy under token-budget pressure, and emits the
2133
+ * resulting per-message resolutions as ContextEntry[]. Adjacent messages
2134
+ * sharing the same L_k ancestor emit the recall pair once.
2135
+ *
2136
+ * See `docs/adaptive-resolution-design.md` §3, §5.
2137
+ */
2138
+ protected selectAdaptive(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
2139
+ const entries: ContextEntry[] = [];
2140
+ const maxTokens = budget.maxTokens - budget.reserveForResponse;
2141
+ const messages = store.getAll();
2142
+ const msgCap = this.config.maxMessageTokens;
2143
+
2144
+ // ----- 1. Build head/tail sets and emit head entries -----
2145
+ const headStart = this.getHeadWindowStartIndex(store);
2146
+ const headEnd = this.getHeadWindowEnd(store);
2147
+ const recentStart = this.getRecentWindowStart(store);
2148
+
2149
+ const headMessageIds = new Set<MessageId>();
2150
+ const tailMessageIds = new Set<MessageId>();
2151
+ let headTokens = 0;
2152
+ let tailTokens = 0;
2153
+
2154
+ let totalTokens = 0;
2155
+
2156
+ // Emit head entries verbatim
2157
+ for (let i = headStart; i < headEnd && i < messages.length; i++) {
2158
+ const msg = messages[i];
2159
+ const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
2160
+ const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
2161
+ if (totalTokens + tokens > maxTokens) break;
2162
+ entries.push({
2163
+ index: entries.length,
2164
+ sourceMessageId: msg.id,
2165
+ sourceRelation: 'copy',
2166
+ participant: msg.participant,
2167
+ content,
2168
+ });
2169
+ totalTokens += tokens;
2170
+ headMessageIds.add(msg.id);
2171
+ headTokens += tokens;
2172
+ }
2173
+ if (entries.length > 0) {
2174
+ entries[entries.length - 1].cacheMarker = true;
2175
+ }
2176
+
2177
+ // Compute tail message IDs (will be emitted at end)
2178
+ const effectiveRecentStart = Math.max(recentStart, headEnd);
2179
+ for (let i = effectiveRecentStart; i < messages.length; i++) {
2180
+ const msg = messages[i];
2181
+ const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
2182
+ tailMessageIds.add(msg.id);
2183
+ tailTokens += tokens;
2184
+ }
2185
+
2186
+ // ----- 2. Build PickerChunks for messages in the middle -----
2187
+ // For each compressible (non-head, non-tail) message we create one
2188
+ // PickerChunk. Its l1Id is determined by the existing chunks that
2189
+ // group messages into L1 summaries.
2190
+ const chunksByMessageId = new Map<MessageId, Chunk>();
2191
+ for (const ch of this.chunks) {
2192
+ for (const m of ch.messages) {
2193
+ chunksByMessageId.set(m.id, ch);
2194
+ }
2195
+ }
2196
+
2197
+ // Pinned-position set so the picker doesn't fold messages the user
2198
+ // explicitly marked as keep-raw. Built once and reused.
2199
+ const pinnedSet = this.pinnedPositions(messages);
2200
+
2201
+ // O(1) summary lookup for findAncestorAt — avoids O(summaries) find()
2202
+ // calls during emission.
2203
+ const summariesById = new Map<string, SummaryEntry>();
2204
+ for (const s of this.summaries) summariesById.set(s.id, s);
2205
+
2206
+ const pickerChunks: PickerChunk[] = [];
2207
+ for (let i = headEnd; i < effectiveRecentStart && i < messages.length; i++) {
2208
+ const msg = messages[i];
2209
+ const ch = chunksByMessageId.get(msg.id);
2210
+ const tokens = msgCap > 0
2211
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
2212
+ : store.estimateTokens(msg);
2213
+ pickerChunks.push({
2214
+ id: msg.id,
2215
+ sequence: i,
2216
+ rawTokens: tokens,
2217
+ currentResolution: this.resolutions.get(msg.id) ?? 0,
2218
+ lockedByAgent: this.locked.has(msg.id),
2219
+ pinned: pinnedSet.has(i),
2220
+ l1Id: ch?.summaryId,
2221
+ });
2222
+ }
2223
+
2224
+ // Also include head and tail in PickerChunks (so token accounting matches)
2225
+ // — but mark them as in-head/in-tail so the picker won't fold them.
2226
+ for (let i = headStart; i < headEnd && i < messages.length; i++) {
2227
+ const msg = messages[i];
2228
+ const tokens = msgCap > 0
2229
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
2230
+ : store.estimateTokens(msg);
2231
+ pickerChunks.push({
2232
+ id: msg.id,
2233
+ sequence: i,
2234
+ rawTokens: tokens,
2235
+ currentResolution: 0,
2236
+ lockedByAgent: this.locked.has(msg.id),
2237
+ pinned: true, // treat head as pinned for picker purposes
2238
+ l1Id: undefined,
2239
+ });
2240
+ }
2241
+ for (let i = effectiveRecentStart; i < messages.length; i++) {
2242
+ const msg = messages[i];
2243
+ const tokens = msgCap > 0
2244
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
2245
+ : store.estimateTokens(msg);
2246
+ pickerChunks.push({
2247
+ id: msg.id,
2248
+ sequence: i,
2249
+ rawTokens: tokens,
2250
+ currentResolution: 0,
2251
+ lockedByAgent: this.locked.has(msg.id),
2252
+ pinned: true, // treat tail as pinned for picker purposes
2253
+ l1Id: undefined,
2254
+ });
663
2255
  }
664
2256
 
665
- const sources = sourceIds
666
- .map(id => this.summaries.find(s => s.id === id))
667
- .filter((s): s is SummaryEntry => s != null);
668
-
669
- if (sources.length !== sourceIds.length) {
670
- console.warn('executeMerge: some source summaries not found, skipping');
671
- return;
2257
+ // ----- 3. Build summaries map and recall-pair tokens -----
2258
+ const summariesMap = new Map<SummaryId, SummaryEntry>();
2259
+ const recallPairTokens = new Map<SummaryId, number>();
2260
+ for (const s of this.summaries) {
2261
+ summariesMap.set(s.id, s);
2262
+ // recall pair = the summary's text wrapped as a Q&A pair. Approximate
2263
+ // as s.tokens + small overhead for the "What do you remember?" label.
2264
+ recallPairTokens.set(s.id, s.tokens + 20);
672
2265
  }
673
2266
 
674
- const targetTokens = this.config.summaryTargetTokens ?? 2000;
675
- const participant = this.config.summaryParticipant ?? 'Claude';
2267
+ // ----- 4. Run the picker -----
2268
+ const totalBudget = maxTokens - totalTokens; // tokens left after head
2269
+ const slack = this.config.compressionSlackRatio ?? 0.1;
2270
+ const foldingBudget: FoldingBudget = {
2271
+ totalBudget,
2272
+ targetBudget: totalBudget * (1 - slack),
2273
+ slack,
2274
+ };
676
2275
 
677
- // Build message array
678
- const llmMessages: Array<{ participant: string; content: ContentBlock[] }> = [];
2276
+ const headSetForPicker = new Set<ChunkId>(headMessageIds);
2277
+ const tailSetForPicker = new Set<ChunkId>(tailMessageIds);
679
2278
 
680
- // Higher-level context (anti-redundant)
681
- if (targetLevel === 2) {
682
- // For L2 merge: show L3 summaries as context
683
- const shownL3 = this.summaries.filter(s => s.level === 3 && !s.mergedInto);
684
- for (const s of shownL3) {
685
- llmMessages.push({
686
- participant,
687
- content: [{ type: 'text', text: s.content }],
688
- });
2279
+ const picker = this.getAdaptivePicker();
2280
+ const result = picker.run(
2281
+ {
2282
+ chunks: pickerChunks,
2283
+ summaries: summariesMap,
2284
+ recallPairTokens,
2285
+ headChunkIds: headSetForPicker,
2286
+ tailChunkIds: tailSetForPicker,
2287
+ headTokens,
2288
+ tailTokens,
2289
+ },
2290
+ foldingBudget
2291
+ );
2292
+
2293
+ // Commit the new resolutions back to strategy state for next compile.
2294
+ // Persist to chronicle only if anything actually changed — avoids
2295
+ // unnecessary state-slot writes on no-op compiles (which is the common
2296
+ // case in steady state with slack).
2297
+ let resolutionsChanged = false;
2298
+ let deepestLevel = 0;
2299
+ for (const [id, level] of result.finalResolutions) {
2300
+ if (headMessageIds.has(id) || tailMessageIds.has(id)) continue;
2301
+ if (this.locked.has(id)) continue;
2302
+ const prev = this.resolutions.get(id) ?? 0;
2303
+ if (prev !== level) {
2304
+ this.resolutions.set(id, level);
2305
+ resolutionsChanged = true;
689
2306
  }
2307
+ if (level > deepestLevel) deepestLevel = level;
2308
+ }
2309
+ if (resolutionsChanged) {
2310
+ this.persistResolutions();
690
2311
  }
691
- // For L3 merge: no higher context exists
692
2312
 
693
- // The source summaries as agent's own memories
694
- for (const source of sources) {
695
- llmMessages.push({
696
- participant,
697
- content: [{ type: 'text', text: source.content }],
2313
+ // Wire produce ops into the strategy's own production queues so that
2314
+ // requested-but-not-yet-existing summaries actually get built. The
2315
+ // speculative pre-producer covers most cases ambiently, but when it is
2316
+ // disabled (`speculativeProduction: false`) or hasn't reached the level
2317
+ // the picker just asked for, the request would otherwise be dropped and
2318
+ // the picker would re-emit it on every subsequent compile. Handling it
2319
+ // here makes the produce path observable and convergent.
2320
+ //
2321
+ // The actual compression/merge work runs asynchronously via the next
2322
+ // `tick()` invocation (or the speculative drain kicked from
2323
+ // `onNewMessage`). This call only enqueues; it does not await.
2324
+ if (result.produced.length > 0) {
2325
+ this.handleProducedOps(result.produced);
2326
+ }
2327
+
2328
+ // Hard-fail check: if the picker exhausted itself but the final render
2329
+ // would still exceed the HARD budget (not just the soft target), surface
2330
+ // an OverBudgetError to the host rather than silently dropping entries.
2331
+ // The strategy has done all it can; the application has to decide what
2332
+ // to do next (raise budget, switch model, drop windows, etc.).
2333
+ if (result.exhausted && result.finalTokens > totalBudget) {
2334
+ throw new OverBudgetError({
2335
+ budget: totalBudget,
2336
+ actual: result.finalTokens,
2337
+ diagnostics: {
2338
+ headTokens,
2339
+ tailTokens,
2340
+ middleTokens: Math.max(0, result.finalTokens - headTokens - tailTokens),
2341
+ middleChunkCount: pickerChunks.length - headMessageIds.size - tailMessageIds.size,
2342
+ deepestLevel,
2343
+ },
698
2344
  });
699
2345
  }
700
2346
 
701
- // Consolidation instruction
702
- const mergeInstruction = this.getMergeInstruction(targetLevel, sources, targetTokens);
703
- llmMessages.push({
704
- participant: 'Context Manager',
705
- content: [{
706
- type: 'text',
707
- text: `[Context Manager] ${mergeInstruction}`,
708
- }],
709
- });
2347
+ // ----- 5. Emit middle entries in source order -----
2348
+ // Walk middle messages. Handle two cases:
2349
+ // - bodyGroupId set: collect all consecutive shards from the same group,
2350
+ // emit ONE combined entry with concatenated content (raw shards + inline
2351
+ // summary text for folded shards). This preserves KV — the model sees
2352
+ // one continuous user message instead of N turns.
2353
+ // - bodyGroupId absent: emit normally (raw L0 message, or Q+A summary pair).
2354
+ const emittedAncestors = new Set<SummaryId>();
2355
+ const summaryLabel = this.config.summaryContextLabel ?? 'What do you remember from earlier?';
2356
+ const summaryParticipant = this.config.summaryParticipant ?? 'Claude';
2357
+
2358
+ let i = headEnd;
2359
+ while (i < effectiveRecentStart && i < messages.length) {
2360
+ const msg = messages[i];
710
2361
 
711
- const collapsed = this.collapseConsecutiveMessages(llmMessages);
2362
+ if (msg.bodyGroupId) {
2363
+ // Collect the full run of consecutive shards sharing this bodyGroupId.
2364
+ const groupId = msg.bodyGroupId;
2365
+ const groupStart = i;
2366
+ while (
2367
+ i < effectiveRecentStart &&
2368
+ i < messages.length &&
2369
+ messages[i].bodyGroupId === groupId
2370
+ ) {
2371
+ i++;
2372
+ }
2373
+ const groupMessages = messages.slice(groupStart, i);
2374
+ // Sort by shardIndex to ensure byte-faithful ordering.
2375
+ const sortedShards = [...groupMessages].sort(
2376
+ (a, b) => (a.shardIndex ?? 0) - (b.shardIndex ?? 0)
2377
+ );
712
2378
 
713
- const request: NormalizedRequest = {
714
- messages: collapsed.map(m => ({ participant: m.participant, content: m.content })),
715
- system: 'You are forming autobiographical memories of a conversation.',
716
- config: {
717
- model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
718
- maxTokens: Math.round(targetTokens * 1.5),
719
- temperature: 0,
720
- },
721
- };
2379
+ // Walk shards in order, accumulating "runs":
2380
+ // - a 'raw' run is consecutive shards at L0; flushed as ONE User
2381
+ // message with their text concatenated.
2382
+ // - a 'summary' run is consecutive shards under the same L_k
2383
+ // ancestor; flushed as a Q+A recall pair (Context Manager
2384
+ // question + summaryParticipant answer), emitted once per
2385
+ // distinct ancestor.
2386
+ // The run breaks (and the previous run flushes) when:
2387
+ // - resolution transitions raw ↔ folded
2388
+ // - the L_k ancestor changes
2389
+ type Run =
2390
+ | { kind: 'raw'; parts: string[] }
2391
+ | { kind: 'summary'; ancestor: SummaryEntry };
2392
+ let currentRun: Run | null = null;
2393
+ const participant = sortedShards[0].participant;
2394
+
2395
+ const flushRun = (): boolean => {
2396
+ if (!currentRun) return true;
2397
+ if (currentRun.kind === 'raw') {
2398
+ const text = currentRun.parts.join('');
2399
+ const content: ContentBlock[] = [{ type: 'text', text }];
2400
+ // Deliberately do NOT apply maxMessageTokens here: the picker
2401
+ // is the authority on how much of the doc renders raw vs.
2402
+ // summarized. Truncating the composite would silently lose
2403
+ // doc content that the picker explicitly chose to keep raw.
2404
+ // (`maxMessageTokens` is for per-message caps on chat / tool
2405
+ // results, not for sharded bodyGroup composites.)
2406
+ const tokens = this.estimateTokens(content);
2407
+ if (totalTokens + tokens > maxTokens) {
2408
+ currentRun = null;
2409
+ return false;
2410
+ }
2411
+ entries.push({
2412
+ index: entries.length,
2413
+ sourceMessageId: undefined,
2414
+ sourceRelation: 'copy',
2415
+ participant,
2416
+ content,
2417
+ });
2418
+ totalTokens += tokens;
2419
+ } else {
2420
+ // summary run — emit Q+A pair, dedup at the strategy level
2421
+ const ancestor = currentRun.ancestor;
2422
+ if (!emittedAncestors.has(ancestor.id)) {
2423
+ emittedAncestors.add(ancestor.id);
2424
+ const questionEntry: ContextEntry = {
2425
+ index: entries.length,
2426
+ participant: 'Context Manager',
2427
+ content: [{ type: 'text', text: summaryLabel }],
2428
+ sourceRelation: 'derived',
2429
+ };
2430
+ const answerContent: ContentBlock[] = [{ type: 'text', text: ancestor.content }];
2431
+ const answerEntry: ContextEntry = {
2432
+ index: entries.length + 1,
2433
+ participant: summaryParticipant,
2434
+ content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
2435
+ sourceRelation: 'derived',
2436
+ };
2437
+ const pairTokens = this.estimateTokens(questionEntry.content) + this.estimateTokens(answerEntry.content);
2438
+ if (totalTokens + pairTokens > maxTokens) {
2439
+ currentRun = null;
2440
+ return false;
2441
+ }
2442
+ entries.push(questionEntry);
2443
+ entries.push(answerEntry);
2444
+ totalTokens += pairTokens;
2445
+ }
2446
+ }
2447
+ currentRun = null;
2448
+ return true;
2449
+ };
722
2450
 
723
- try {
724
- const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
725
- const mergedText = response.content
726
- .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
727
- .map(b => b.text)
728
- .join('\n');
2451
+ let budgetExhausted = false;
2452
+ for (const shard of sortedShards) {
2453
+ const resolution = result.finalResolutions.get(shard.id) ?? 0;
2454
+ if (resolution === 0) {
2455
+ if (currentRun?.kind !== 'raw') {
2456
+ if (!flushRun()) { budgetExhausted = true; break; }
2457
+ currentRun = { kind: 'raw', parts: [] };
2458
+ }
2459
+ for (const block of shard.content) {
2460
+ if (block.type === 'text') (currentRun as { kind: 'raw'; parts: string[] }).parts.push(block.text);
2461
+ }
2462
+ } else {
2463
+ const ancestor = this.findAncestorAt(shard.id, resolution, chunksByMessageId, summariesById);
2464
+ if (!ancestor) {
2465
+ // Fall back to raw
2466
+ if (currentRun?.kind !== 'raw') {
2467
+ if (!flushRun()) { budgetExhausted = true; break; }
2468
+ currentRun = { kind: 'raw', parts: [] };
2469
+ }
2470
+ for (const block of shard.content) {
2471
+ if (block.type === 'text') (currentRun as { kind: 'raw'; parts: string[] }).parts.push(block.text);
2472
+ }
2473
+ continue;
2474
+ }
2475
+ // If we're already in a summary run for the SAME ancestor, this
2476
+ // shard is covered — skip silently.
2477
+ if (currentRun?.kind === 'summary' && currentRun.ancestor.id === ancestor.id) {
2478
+ continue;
2479
+ }
2480
+ if (!flushRun()) { budgetExhausted = true; break; }
2481
+ currentRun = { kind: 'summary', ancestor };
2482
+ }
2483
+ }
2484
+ if (!budgetExhausted) {
2485
+ if (!flushRun()) budgetExhausted = true;
2486
+ }
2487
+ if (budgetExhausted) break;
2488
+ continue;
2489
+ }
729
2490
 
730
- // Compute source range from constituent summaries
731
- const sourceRange = {
732
- first: sources[0].sourceRange.first,
733
- last: sources[sources.length - 1].sourceRange.last,
734
- };
2491
+ // Non-shard path: existing behavior.
2492
+ const resolution = result.finalResolutions.get(msg.id) ?? 0;
2493
+ if (resolution === 0) {
2494
+ const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
2495
+ const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
2496
+ if (totalTokens + tokens > maxTokens) break;
2497
+ entries.push({
2498
+ index: entries.length,
2499
+ sourceMessageId: msg.id,
2500
+ sourceRelation: 'copy',
2501
+ participant: msg.participant,
2502
+ content,
2503
+ });
2504
+ totalTokens += tokens;
2505
+ i++;
2506
+ } else {
2507
+ const ancestor = this.findAncestorAt(msg.id, resolution, chunksByMessageId, summariesById);
2508
+ if (!ancestor) {
2509
+ const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
2510
+ const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
2511
+ if (totalTokens + tokens > maxTokens) break;
2512
+ entries.push({
2513
+ index: entries.length,
2514
+ sourceMessageId: msg.id,
2515
+ sourceRelation: 'copy',
2516
+ participant: msg.participant,
2517
+ content,
2518
+ });
2519
+ totalTokens += tokens;
2520
+ i++;
2521
+ continue;
2522
+ }
2523
+ if (emittedAncestors.has(ancestor.id)) {
2524
+ i++;
2525
+ continue;
2526
+ }
2527
+ emittedAncestors.add(ancestor.id);
2528
+ const questionEntry: ContextEntry = {
2529
+ index: entries.length,
2530
+ participant: 'Context Manager',
2531
+ content: [{ type: 'text', text: summaryLabel }],
2532
+ sourceRelation: 'derived',
2533
+ };
2534
+ const answerContent: ContentBlock[] = [{ type: 'text', text: ancestor.content }];
2535
+ const answerEntry: ContextEntry = {
2536
+ index: entries.length + 1,
2537
+ participant: summaryParticipant,
2538
+ content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
2539
+ sourceRelation: 'derived',
2540
+ };
2541
+ const pairTokens = this.estimateTokens(questionEntry.content) + this.estimateTokens(answerEntry.content);
2542
+ if (totalTokens + pairTokens > maxTokens) break;
2543
+ entries.push(questionEntry);
2544
+ entries.push(answerEntry);
2545
+ totalTokens += pairTokens;
2546
+ i++;
2547
+ }
2548
+ }
735
2549
 
736
- const sourceLevel = (targetLevel - 1) as 0 | 1 | 2;
737
- const newEntry: SummaryEntry = {
738
- id: `L${targetLevel}-${this.summaryIdCounter++}`,
739
- level: targetLevel,
740
- content: mergedText,
741
- tokens: Math.ceil(mergedText.length / 4),
742
- sourceLevel,
743
- sourceIds,
744
- sourceRange,
745
- created: Date.now(),
746
- };
2550
+ // ----- 6. Emit tail entries newest-first eviction (matches existing behavior) -----
2551
+ this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
747
2552
 
748
- this.summaries.push(newEntry);
2553
+ // ----- 7. Post-process: merge consecutive raw entries from the same bodyGroup -----
2554
+ // Both head and tail emission paths emit shards as separate ContextEntries.
2555
+ // The middle path already merges consecutive same-bodyGroup raw shards into
2556
+ // one composite entry, but head/tail don't. This pass closes that gap so
2557
+ // a sharded message renders as ONE API message regardless of which region
2558
+ // it falls into (preserves KV cache through region transitions).
2559
+ const merged = this.mergeAdjacentBodyGroupRaw(entries, store);
749
2560
 
750
- // Mark sources as merged
751
- for (const source of sources) {
752
- source.mergedInto = newEntry.id;
2561
+ this.trimOrphanedToolUse(merged);
2562
+ return merged;
2563
+ }
2564
+
2565
+ /**
2566
+ * Walk an entries array; for every run of consecutive entries that
2567
+ * (a) have sourceRelation: 'copy' (raw, not a synthesized recall pair)
2568
+ * (b) have sourceMessageId pointing to messages in the same bodyGroup
2569
+ * merge them into one composite entry whose body is the byte-faithful
2570
+ * concatenation of their text content. Other entries pass through.
2571
+ *
2572
+ * Reindexes the returned array.
2573
+ */
2574
+ protected mergeAdjacentBodyGroupRaw(
2575
+ entries: ContextEntry[],
2576
+ store: MessageStoreView
2577
+ ): ContextEntry[] {
2578
+ if (entries.length === 0) return entries;
2579
+
2580
+ // Look up bodyGroupId by sourceMessageId via the message store.
2581
+ const groupOf = (sourceMessageId?: string): string | undefined => {
2582
+ if (!sourceMessageId) return undefined;
2583
+ const m = store.get(sourceMessageId);
2584
+ return m?.bodyGroupId;
2585
+ };
2586
+
2587
+ const out: ContextEntry[] = [];
2588
+ let i = 0;
2589
+ while (i < entries.length) {
2590
+ const entry = entries[i];
2591
+ const groupId = entry.sourceRelation === 'copy' ? groupOf(entry.sourceMessageId) : undefined;
2592
+ if (!groupId) {
2593
+ out.push({ ...entry, index: out.length });
2594
+ i++;
2595
+ continue;
753
2596
  }
2597
+ // Collect run of consecutive raw entries with same bodyGroupId.
2598
+ const run: ContextEntry[] = [entry];
2599
+ let j = i + 1;
2600
+ while (
2601
+ j < entries.length &&
2602
+ entries[j].sourceRelation === 'copy' &&
2603
+ groupOf(entries[j].sourceMessageId) === groupId
2604
+ ) {
2605
+ run.push(entries[j]);
2606
+ j++;
2607
+ }
2608
+ if (run.length === 1) {
2609
+ out.push({ ...entry, index: out.length });
2610
+ i++;
2611
+ continue;
2612
+ }
2613
+ // Sort the run by the underlying shardIndex to ensure byte-faithful
2614
+ // ordering. (Head/tail emission keeps chronological order, but defending
2615
+ // against reorderings is cheap.)
2616
+ const sortedRun = [...run].sort((a, b) => {
2617
+ const ma = a.sourceMessageId ? store.get(a.sourceMessageId) : null;
2618
+ const mb = b.sourceMessageId ? store.get(b.sourceMessageId) : null;
2619
+ return (ma?.shardIndex ?? 0) - (mb?.shardIndex ?? 0);
2620
+ });
2621
+ // Build merged text content. Non-text blocks (rare in shards) are
2622
+ // preserved on the first shard's entry only.
2623
+ const mergedTextParts: string[] = [];
2624
+ const nonTextBlocks: ContentBlock[] = [];
2625
+ for (const r of sortedRun) {
2626
+ for (const block of r.content) {
2627
+ if (block.type === 'text') mergedTextParts.push(block.text);
2628
+ else nonTextBlocks.push(block);
2629
+ }
2630
+ }
2631
+ const mergedContent: ContentBlock[] = [
2632
+ ...nonTextBlocks,
2633
+ { type: 'text', text: mergedTextParts.join('') },
2634
+ ];
2635
+ out.push({
2636
+ index: out.length,
2637
+ sourceMessageId: sortedRun[0].sourceMessageId,
2638
+ sourceRelation: 'copy',
2639
+ participant: sortedRun[0].participant,
2640
+ content: mergedContent,
2641
+ });
2642
+ i = j;
2643
+ }
2644
+ return out;
2645
+ }
754
2646
 
755
- // Check if this merge triggers a further merge
756
- this.checkMergeThreshold();
757
- } catch (error) {
758
- console.error(`Failed to merge summaries into L${targetLevel}:`, error);
759
- throw error;
2647
+ /** Get (lazily constructing) the configured picker instance. */
2648
+ protected getAdaptivePicker(): Picker {
2649
+ if (this._adaptivePicker) return this._adaptivePicker;
2650
+ const strategy: FoldingStrategy =
2651
+ this.config.foldingStrategy === 'oldest-first'
2652
+ ? new OldestFirstStrategy()
2653
+ : new FlatProfileStrategy();
2654
+ this._adaptivePicker = new Picker(strategy);
2655
+ return this._adaptivePicker;
2656
+ }
2657
+
2658
+ /**
2659
+ * Walk the summary tree to find the L_k ancestor of a message.
2660
+ * Returns null if no ancestor exists at that level (e.g., L_k not yet produced).
2661
+ *
2662
+ * Takes a pre-built summariesById map to avoid O(summaries) lookups per
2663
+ * call — for a chronicle with thousands of summaries and hundreds of
2664
+ * middle messages, the O(n) `find` would dominate compile latency.
2665
+ */
2666
+ protected findAncestorAt(
2667
+ messageId: MessageId,
2668
+ level: number,
2669
+ chunksByMessageId: ReadonlyMap<MessageId, Chunk>,
2670
+ summariesById?: ReadonlyMap<string, SummaryEntry>,
2671
+ ): SummaryEntry | null {
2672
+ if (level <= 0) return null;
2673
+ const chunk = chunksByMessageId.get(messageId);
2674
+ if (!chunk?.summaryId) return null;
2675
+ const lookup = (id: string): SummaryEntry | undefined =>
2676
+ summariesById ? summariesById.get(id) : this.summaries.find((s) => s.id === id);
2677
+ let current: SummaryEntry | undefined = lookup(chunk.summaryId);
2678
+ while (current && current.level < level) {
2679
+ const parentId = getSummaryParentId(current);
2680
+ if (!parentId) return null;
2681
+ current = lookup(parentId);
760
2682
  }
2683
+ if (!current || current.level !== level) return null;
2684
+ return current;
761
2685
  }
762
2686
 
2687
+ // ============================================================================
2688
+ // Hierarchical (threshold-driven) path
2689
+ // ============================================================================
2690
+
763
2691
  /**
764
2692
  * Select context entries using hierarchical compression with budget carryover.
765
2693
  * Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
@@ -779,7 +2707,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
779
2707
  const msg = messages[i];
780
2708
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
781
2709
  const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
782
- if (totalTokens + tokens > maxTokens) break;
2710
+ if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
783
2711
 
784
2712
  entries.push({
785
2713
  index: entries.length,
@@ -816,7 +2744,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
816
2744
  let l3Used = 0;
817
2745
  for (const s of shownL3) {
818
2746
  if (l3Used + s.tokens > l3Budget) break;
819
- if (totalTokens + totalSummaryTokens + s.tokens > maxTokens) break;
2747
+ if (this.isOverBudget(totalTokens + totalSummaryTokens + s.tokens, maxTokens)) break;
820
2748
  selectedSummaries.push(s);
821
2749
  l3Used += s.tokens;
822
2750
  totalSummaryTokens += s.tokens;
@@ -828,7 +2756,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
828
2756
  const l2Effective = l2Budget + l3Carryover;
829
2757
  for (const s of shownL2) {
830
2758
  if (l2Used + s.tokens > l2Effective) break;
831
- if (totalTokens + totalSummaryTokens + s.tokens > maxTokens) break;
2759
+ if (this.isOverBudget(totalTokens + totalSummaryTokens + s.tokens, maxTokens)) break;
832
2760
  selectedSummaries.push(s);
833
2761
  l2Used += s.tokens;
834
2762
  totalSummaryTokens += s.tokens;
@@ -844,35 +2772,179 @@ export class AutobiographicalStrategy implements ResettableStrategy {
844
2772
  selectedSummaries.push(...l1Selected);
845
2773
  totalSummaryTokens += l1Used;
846
2774
 
847
- // Emit summaries as a single Q&A pair
848
- if (selectedSummaries.length > 0) {
849
- const contextLabel = this.config.summaryContextLabel ?? 'What do you remember from earlier?';
850
- const combinedText = selectedSummaries.map(s => s.content).join('\n\n---\n\n');
2775
+ // Emit summaries + pinned messages between head and recent windows.
2776
+ //
2777
+ // Default (positionedRecallPairs=true): one Q/A pair per summary,
2778
+ // interleaved with raw pinned messages, all sorted chronologically by
2779
+ // source-range / message position. Each memory appears in its temporal
2780
+ // place rather than as a wall of unrelated recollections.
2781
+ //
2782
+ // Legacy (positionedRecallPairs=false): summaries concatenated into one
2783
+ // Q/A pair between head and tail; pinned messages still emit raw, in
2784
+ // their chronological positions, after the combined recall pair.
2785
+ const positionOf = new Map<string, number>();
2786
+ for (let i = 0; i < messages.length; i++) {
2787
+ positionOf.set(messages[i].id, i);
2788
+ }
2789
+ const pinnedPositionsSet = this.pinnedPositions(messages);
2790
+ // Pinned messages between head and recent (head/recent pinned ones
2791
+ // already emit raw via Phase 0 / Phase 4).
2792
+ const pinnedInMiddle: { msg: StoredMessage; position: number }[] = [];
2793
+ const pinnedIdsInMiddle = new Set<string>();
2794
+ for (let i = headEnd; i < recentStart; i++) {
2795
+ if (pinnedPositionsSet.has(i)) {
2796
+ pinnedInMiddle.push({ msg: messages[i], position: i });
2797
+ pinnedIdsInMiddle.add(messages[i].id);
2798
+ }
2799
+ }
2800
+
2801
+ // Uncompressed-chunk fallback: messages in the middle region whose
2802
+ // chunk hasn't been summarized yet. Without this, a message that
2803
+ // rolled out of the recent window into a queued-but-not-yet-compressed
2804
+ // chunk would vanish from rendered context — there'd be no summary to
2805
+ // emit (compression hasn't run) and Phase 4 only walks recentStart
2806
+ // onwards. Mirrors selectLegacy's "Uncompressed: emit raw" behavior
2807
+ // around line 738, but here we interleave chronologically with
2808
+ // summaries and pins via the unified items list below.
2809
+ //
2810
+ // This matters because compile() was made non-blocking in commit
2811
+ // `3e42e98` (drops the prior `await readiness.pendingWork`); without
2812
+ // this fallback, the trade was silent data-loss for messages caught
2813
+ // in the queued-but-not-yet-compressed window. Now compile()'s
2814
+ // freshness contract is: summaries may lag the very latest L1, but
2815
+ // no message ever disappears.
2816
+ const uncompressedInMiddle: { msg: StoredMessage; position: number }[] = [];
2817
+ for (const chunk of this.chunks) {
2818
+ if (chunk.compressed) continue;
2819
+ for (const msg of chunk.messages) {
2820
+ const pos = positionOf.get(msg.id);
2821
+ if (pos === undefined) continue;
2822
+ if (pos < headEnd || pos >= recentStart) continue;
2823
+ if (pinnedIdsInMiddle.has(msg.id)) continue;
2824
+ uncompressedInMiddle.push({ msg, position: pos });
2825
+ }
2826
+ }
851
2827
 
852
- const questionEntry: ContextEntry = {
853
- index: entries.length,
854
- participant: 'Context Manager',
855
- content: [{ type: 'text', text: contextLabel }],
856
- sourceRelation: 'derived',
857
- };
858
- // Synthesised summary turns must respect maxMessageTokens. With L1+L2+L3
859
- // budgets defaulting to 30k each, an unconstrained concatenation can push
860
- // a single assistant turn past 90k tokens, eating the inference budget
861
- // and starving recent messages (postmortem 2026-05-04, bug B).
862
- const answerContent: ContentBlock[] = [{ type: 'text', text: combinedText }];
863
- const answerEntry: ContextEntry = {
864
- index: entries.length + 1,
865
- participant: this.config.summaryParticipant ?? 'Claude',
866
- content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
867
- sourceRelation: 'derived',
868
- };
2828
+ // Merged list of raw messages to emit in the middle region —
2829
+ // either a pin or a message whose chunk hasn't compressed yet.
2830
+ // Both render identically (raw, at their chronological position).
2831
+ const middleRaw: { msg: StoredMessage; position: number }[] = [
2832
+ ...pinnedInMiddle,
2833
+ ...uncompressedInMiddle,
2834
+ ];
2835
+
2836
+ if (selectedSummaries.length > 0 || middleRaw.length > 0) {
2837
+ const summaryParticipant = this.config.summaryParticipant ?? 'Claude';
2838
+
2839
+ if (this.config.positionedRecallPairs !== false) {
2840
+ // Build a unified, chronologically-sorted item list.
2841
+ type Item =
2842
+ | { kind: 'summary'; position: number; summary: SummaryEntry }
2843
+ | { kind: 'pin'; position: number; msg: StoredMessage };
2844
+
2845
+ const items: Item[] = [];
2846
+ for (const s of selectedSummaries) {
2847
+ const pos = positionOf.get(s.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
2848
+ items.push({ kind: 'summary', position: pos, summary: s });
2849
+ }
2850
+ for (const p of middleRaw) {
2851
+ items.push({ kind: 'pin', position: p.position, msg: p.msg });
2852
+ }
2853
+ items.sort((a, b) => a.position - b.position);
2854
+
2855
+ for (const item of items) {
2856
+ if (item.kind === 'summary') {
2857
+ const summary = item.summary;
2858
+ const headerText = this.buildRecallHeader(summary);
2859
+ const questionEntry: ContextEntry = {
2860
+ index: entries.length,
2861
+ participant: 'Context Manager',
2862
+ content: [{ type: 'text', text: headerText }],
2863
+ sourceRelation: 'derived',
2864
+ };
2865
+ const answerContent: ContentBlock[] = [{ type: 'text', text: summary.content }];
2866
+ const answerEntry: ContextEntry = {
2867
+ index: entries.length + 1,
2868
+ participant: summaryParticipant,
2869
+ content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
2870
+ sourceRelation: 'derived',
2871
+ };
2872
+ const pairTokens =
2873
+ this.estimateTokens(questionEntry.content) +
2874
+ this.estimateTokens(answerEntry.content);
2875
+ if (this.isOverBudget(totalTokens + pairTokens, maxTokens)) break;
2876
+ entries.push(questionEntry);
2877
+ entries.push(answerEntry);
2878
+ totalTokens += pairTokens;
2879
+ } else {
2880
+ const msg = item.msg;
2881
+ const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
2882
+ const tokens = msgCap > 0
2883
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
2884
+ : store.estimateTokens(msg);
2885
+ if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
2886
+ entries.push({
2887
+ index: entries.length,
2888
+ sourceMessageId: msg.id,
2889
+ sourceRelation: 'copy',
2890
+ participant: msg.participant,
2891
+ content,
2892
+ });
2893
+ totalTokens += tokens;
2894
+ }
2895
+ }
2896
+ } else {
2897
+ // Legacy combined-pair mode for summaries; pins still emit raw at
2898
+ // their positions after the combined pair.
2899
+ if (selectedSummaries.length > 0) {
2900
+ const contextLabel = this.config.summaryContextLabel ?? 'What do you remember from earlier?';
2901
+ const combinedText = selectedSummaries.map(s => s.content).join('\n\n---\n\n');
869
2902
 
870
- const pairTokens = this.estimateTokens(questionEntry.content) +
871
- this.estimateTokens(answerEntry.content);
2903
+ const questionEntry: ContextEntry = {
2904
+ index: entries.length,
2905
+ participant: 'Context Manager',
2906
+ content: [{ type: 'text', text: contextLabel }],
2907
+ sourceRelation: 'derived',
2908
+ };
2909
+ // Synthesised summary turns must respect maxMessageTokens. With L1+L2+L3
2910
+ // budgets defaulting to 30k each, an unconstrained concatenation can push
2911
+ // a single assistant turn past 90k tokens, eating the inference budget
2912
+ // and starving recent messages (postmortem 2026-05-04, bug B).
2913
+ const answerContent: ContentBlock[] = [{ type: 'text', text: combinedText }];
2914
+ const answerEntry: ContextEntry = {
2915
+ index: entries.length + 1,
2916
+ participant: summaryParticipant,
2917
+ content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
2918
+ sourceRelation: 'derived',
2919
+ };
2920
+
2921
+ const pairTokens = this.estimateTokens(questionEntry.content) +
2922
+ this.estimateTokens(answerEntry.content);
2923
+
2924
+ entries.push(questionEntry);
2925
+ entries.push(answerEntry);
2926
+ totalTokens += pairTokens;
2927
+ }
872
2928
 
873
- entries.push(questionEntry);
874
- entries.push(answerEntry);
875
- totalTokens += pairTokens;
2929
+ // Sort by position so uncompressed-middle messages and pins both
2930
+ // appear in their chronological place after the combined recall pair.
2931
+ const middleRawSorted = [...middleRaw].sort((a, b) => a.position - b.position);
2932
+ for (const { msg } of middleRawSorted) {
2933
+ const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
2934
+ const tokens = msgCap > 0
2935
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
2936
+ : store.estimateTokens(msg);
2937
+ if (this.isOverBudget(totalTokens + tokens, maxTokens)) break;
2938
+ entries.push({
2939
+ index: entries.length,
2940
+ sourceMessageId: msg.id,
2941
+ sourceRelation: 'copy',
2942
+ participant: msg.participant,
2943
+ content,
2944
+ });
2945
+ totalTokens += tokens;
2946
+ }
2947
+ }
876
2948
  }
877
2949
 
878
2950
  // Phase 4: Recent uncompressed messages (skip head window overlap).
@@ -883,6 +2955,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
883
2955
  this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
884
2956
 
885
2957
  this.trimOrphanedToolUse(entries);
2958
+ this.pruneToolEntries(entries);
886
2959
  return entries;
887
2960
  }
888
2961
 
@@ -891,23 +2964,113 @@ export class AutobiographicalStrategy implements ResettableStrategy {
891
2964
  // ============================================================================
892
2965
 
893
2966
  /**
894
- * Build the compression instruction for an L1 chunk.
895
- * Override in subclasses for phase-aware prompts.
2967
+ * Build the compression instruction for an L1 chunk in the hierarchical
2968
+ * path. Override in subclasses for domain-specific prompts (e.g.,
2969
+ * phase-aware prompts in KnowledgeStrategy).
2970
+ *
2971
+ * Default returns the KV-preserving first-person instruction matching
2972
+ * the hermes-autobio spec. The doc/reading-mode variant is exposed via
2973
+ * {@link getReadingChunkInstruction}.
896
2974
  */
897
2975
  protected getCompressionInstruction(chunk: Chunk, targetTokens: number): string {
898
- 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.`;
2976
+ return formatInstruction(targetTokens);
2977
+ }
2978
+
2979
+ /**
2980
+ * Build the compression instruction for an L1 chunk that is part of a
2981
+ * substantially larger sharded message (reading mode). Override in
2982
+ * subclasses if domain logic needs to vary the reading-mode prompt.
2983
+ *
2984
+ * Default returns the reading-mode instruction that asks the model to
2985
+ * reflect on what reading was like rather than form a memory "of what
2986
+ * the chunk contained", which prevents voice drift into the content
2987
+ * author's perspective.
2988
+ */
2989
+ protected getReadingChunkInstruction(
2990
+ chunk: Chunk,
2991
+ totalTokens: number,
2992
+ targetTokens: number,
2993
+ ): string {
2994
+ return formatReadingChunkInstruction(totalTokens, targetTokens);
2995
+ }
2996
+
2997
+ /**
2998
+ * If the chunk is part of a substantially larger sharded message (total
2999
+ * bodyGroup tokens ≥ 2× the chunk's own tokens), return reading-context
3000
+ * metadata for the reading instruction. The 2× threshold means the
3001
+ * chunk represents a portion of something significantly larger — the
3002
+ * agent is reading, not conversing.
3003
+ *
3004
+ * Returns null when the chunk is a whole message (no bodyGroup), or
3005
+ * when bodyGroup total is < 2× chunk size (degenerate case — chunk
3006
+ * effectively IS the whole message). In those cases the standard
3007
+ * (non-reading) instruction is appropriate.
3008
+ */
3009
+ protected detectDocContext(
3010
+ chunk: Chunk,
3011
+ ctx: StrategyContext,
3012
+ ): { totalTokens: number; chunkTokens: number } | null {
3013
+ if (chunk.messages.length === 0) return null;
3014
+ const firstGroupId = chunk.messages[0].bodyGroupId;
3015
+ if (!firstGroupId) return null;
3016
+ // All messages in the chunk must share the same bodyGroupId
3017
+ for (const m of chunk.messages) {
3018
+ if (m.bodyGroupId !== firstGroupId) return null;
3019
+ }
3020
+ // Total tokens of the original message (sum of all shards in the bodyGroup).
3021
+ const allMessages = ctx.messageStore.getAll();
3022
+ let totalTokens = 0;
3023
+ for (const m of allMessages) {
3024
+ if (m.bodyGroupId === firstGroupId) {
3025
+ totalTokens += ctx.messageStore.estimateTokens(m);
3026
+ }
3027
+ }
3028
+ // Tokens in this chunk specifically.
3029
+ let chunkTokens = 0;
3030
+ for (const m of chunk.messages) {
3031
+ chunkTokens += ctx.messageStore.estimateTokens(m);
3032
+ }
3033
+ // Reading-mode threshold: the original message must be substantially
3034
+ // larger than this chunk. 2× means the chunk is at most half of the
3035
+ // whole — clearly a portion of something bigger.
3036
+ if (chunkTokens === 0 || totalTokens < 2 * chunkTokens) return null;
3037
+ return {
3038
+ totalTokens,
3039
+ chunkTokens,
3040
+ };
899
3041
  }
900
3042
 
901
3043
  /**
902
3044
  * Build the merge instruction for combining summaries into a higher level.
903
3045
  * Override in subclasses for domain-specific merge prompts.
3046
+ *
3047
+ * Default returns the KV-preserving merge instruction. The reading-mode
3048
+ * variant (used when all leaves share a bodyGroup of substantial size)
3049
+ * is exposed via {@link getReadingMergeInstruction}.
904
3050
  */
905
3051
  protected getMergeInstruction(
906
3052
  targetLevel: SummaryLevel,
907
3053
  sources: SummaryEntry[],
908
3054
  targetTokens: number
909
3055
  ): string {
910
- 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.`;
3056
+ const sourceLevelShown = sources.length > 0 ? Math.max(0, sources[0].level - 1) : 0;
3057
+ return formatMergeInstruction(targetLevel, sourceLevelShown, targetTokens);
3058
+ }
3059
+
3060
+ /**
3061
+ * Build the reading-mode merge instruction. Used when all leaf messages
3062
+ * underlying the merge share a bodyGroup of substantial size — the agent
3063
+ * has been reading a doc rather than conversing. Override in subclasses
3064
+ * if domain logic needs to vary the reading-mode merge prompt.
3065
+ */
3066
+ protected getReadingMergeInstruction(
3067
+ targetLevel: SummaryLevel,
3068
+ sources: SummaryEntry[],
3069
+ totalTokens: number,
3070
+ targetTokens: number,
3071
+ ): string {
3072
+ const sourceLevelShown = sources.length > 0 ? Math.max(0, sources[0].level - 1) : 0;
3073
+ return formatReadingMergeInstruction(targetLevel, sourceLevelShown, totalTokens, targetTokens);
911
3074
  }
912
3075
 
913
3076
  /**
@@ -923,13 +3086,60 @@ export class AutobiographicalStrategy implements ResettableStrategy {
923
3086
  let used = 0;
924
3087
  for (const s of shownL1) {
925
3088
  if (used + s.tokens > budget) break;
926
- if (used + s.tokens > maxTokens) break;
3089
+ if (this.isOverBudget(used + s.tokens, maxTokens)) break;
927
3090
  selected.push(s);
928
3091
  used += s.tokens;
929
3092
  }
930
3093
  return { selected, tokensUsed: used };
931
3094
  }
932
3095
 
3096
+ /**
3097
+ * True if `projectedTotal` exceeds `max` AND the strategy is configured
3098
+ * to enforce budget. When `enforceBudget: false`, always returns false
3099
+ * — the rendering pipeline emits the full ideal context regardless of
3100
+ * budget overage. Caller's API will reject if it exceeds the model's
3101
+ * context window; the philosophy is "surface overage, don't hide it."
3102
+ */
3103
+ protected isOverBudget(projectedTotal: number, max: number): boolean {
3104
+ if (this.config.enforceBudget === false) return false;
3105
+ return projectedTotal > max;
3106
+ }
3107
+
3108
+ /**
3109
+ * Sort selected summaries by source-range start position, so per-pair
3110
+ * recall emission appears in chronological order. Falls back to the
3111
+ * created timestamp for summaries whose source-range first message is
3112
+ * no longer in the store.
3113
+ */
3114
+ protected sortSummariesChronologically(
3115
+ summaries: SummaryEntry[],
3116
+ messages: StoredMessage[],
3117
+ ): SummaryEntry[] {
3118
+ const positionOf = new Map<string, number>();
3119
+ for (let i = 0; i < messages.length; i++) {
3120
+ positionOf.set(messages[i].id, i);
3121
+ }
3122
+ return [...summaries].sort((a, b) => {
3123
+ const posA = positionOf.get(a.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
3124
+ const posB = positionOf.get(b.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
3125
+ if (posA !== posB) return posA - posB;
3126
+ return a.created - b.created;
3127
+ });
3128
+ }
3129
+
3130
+ /**
3131
+ * Render the per-pair recall header from the configured template.
3132
+ * Substitutions: {id} {level} {first} {last}.
3133
+ */
3134
+ protected buildRecallHeader(summary: SummaryEntry): string {
3135
+ const template = this.config.recallHeaderTemplate ?? '[Recall {id}]';
3136
+ return template
3137
+ .replace(/\{id\}/g, summary.id)
3138
+ .replace(/\{level\}/g, String(summary.level))
3139
+ .replace(/\{first\}/g, summary.sourceRange.first)
3140
+ .replace(/\{last\}/g, summary.sourceRange.last);
3141
+ }
3142
+
933
3143
  // ============================================================================
934
3144
  // Head window reset / topic transition
935
3145
  // ============================================================================
@@ -996,7 +3206,6 @@ export class AutobiographicalStrategy implements ResettableStrategy {
996
3206
  config: {
997
3207
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
998
3208
  maxTokens: 1500,
999
- temperature: 0,
1000
3209
  },
1001
3210
  };
1002
3211
 
@@ -1032,16 +3241,24 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1032
3241
  // ============================================================================
1033
3242
 
1034
3243
  /**
1035
- * Get messages in the compressible zone: outside both head window and recent window.
1036
- * Returns messages from [0, headStart) [headEnd, recentStart).
3244
+ * Get messages in the compressible zone: outside both head window and
3245
+ * recent window AND not inside any pinned range. Returns messages from
3246
+ * [0, headStart) ∪ [headEnd, recentStart) minus any positions covered
3247
+ * by a pin or document mark.
1037
3248
  */
1038
3249
  protected getCompressibleMessages(store: MessageStoreView): StoredMessage[] {
1039
3250
  const messages = store.getAll();
1040
3251
  const headStart = this.getHeadWindowStartIndex(store);
1041
3252
  const headEnd = this.getHeadWindowEnd(store);
1042
3253
  const recentStart = this.getRecentWindowStart(store);
1043
- return messages.slice(0, recentStart)
1044
- .filter((_, i) => i < headStart || i >= headEnd);
3254
+ const pinned = this.pinnedPositions(messages);
3255
+ const out: StoredMessage[] = [];
3256
+ for (let i = 0; i < recentStart; i++) {
3257
+ if (i >= headStart && i < headEnd) continue;
3258
+ if (pinned.has(i)) continue;
3259
+ out.push(messages[i]);
3260
+ }
3261
+ return out;
1045
3262
  }
1046
3263
 
1047
3264
  /**
@@ -1257,6 +3474,91 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1257
3474
  }
1258
3475
  }
1259
3476
 
3477
+ /**
3478
+ * Prune tool_use / tool_result blocks in-place:
3479
+ * 1. Truncate `tool_use.input` blocks whose serialized JSON exceeds
3480
+ * `toolUseInputMaxTokens`.
3481
+ * 2. For each tool name, keep only the last N `tool_result` blocks
3482
+ * per `toolResultMaxLastN`; older ones get their `content` replaced
3483
+ * with a brief marker referencing the tool name and how many newer
3484
+ * results exist below.
3485
+ *
3486
+ * Both passes are no-ops when the corresponding config is unset/0.
3487
+ * Pruning runs AFTER selection and orphan-trimming, so it doesn't
3488
+ * affect chunk formation or the recall/pin layout.
3489
+ */
3490
+ protected pruneToolEntries(entries: ContextEntry[]): void {
3491
+ // Pass 1: build toolUseId → toolName map and apply input truncation
3492
+ const toolUseInputCap = this.config.toolUseInputMaxTokens ?? 0;
3493
+ const toolUseIdToName = new Map<string, string>();
3494
+
3495
+ for (const entry of entries) {
3496
+ for (let i = 0; i < entry.content.length; i++) {
3497
+ const block = entry.content[i];
3498
+ if (block.type !== 'tool_use') continue;
3499
+ toolUseIdToName.set(block.id, block.name);
3500
+
3501
+ if (toolUseInputCap > 0) {
3502
+ const inputJson = JSON.stringify(block.input);
3503
+ const inputTokens = Math.ceil(inputJson.length / 4);
3504
+ if (inputTokens > toolUseInputCap) {
3505
+ const keys = Object.keys(block.input).slice(0, 5);
3506
+ entry.content[i] = {
3507
+ ...block,
3508
+ input: {
3509
+ _truncated: true,
3510
+ _originalTokens: inputTokens,
3511
+ _keys: keys,
3512
+ },
3513
+ };
3514
+ }
3515
+ }
3516
+ }
3517
+ }
3518
+
3519
+ // Pass 2: collect tool_result occurrences per tool name, in order
3520
+ const occurrencesByTool = new Map<string, Array<{ entry: ContextEntry; blockIndex: number }>>();
3521
+ for (const entry of entries) {
3522
+ for (let i = 0; i < entry.content.length; i++) {
3523
+ const block = entry.content[i];
3524
+ if (block.type !== 'tool_result') continue;
3525
+ const toolName = toolUseIdToName.get(block.toolUseId);
3526
+ if (!toolName) continue;
3527
+ let arr = occurrencesByTool.get(toolName);
3528
+ if (!arr) {
3529
+ arr = [];
3530
+ occurrencesByTool.set(toolName, arr);
3531
+ }
3532
+ arr.push({ entry, blockIndex: i });
3533
+ }
3534
+ }
3535
+
3536
+ // Pass 3: apply per-tool max-last-N
3537
+ const cfg = this.config.toolResultMaxLastN;
3538
+ if (cfg === undefined) return;
3539
+
3540
+ for (const [toolName, occs] of occurrencesByTool) {
3541
+ let limit: number | undefined;
3542
+ if (typeof cfg === 'number') limit = cfg;
3543
+ else if (typeof cfg === 'object') limit = cfg[toolName];
3544
+ if (limit === undefined || limit < 0) continue;
3545
+
3546
+ const excessCount = occs.length - limit;
3547
+ if (excessCount <= 0) continue;
3548
+
3549
+ for (let i = 0; i < excessCount; i++) {
3550
+ const { entry, blockIndex } = occs[i];
3551
+ const orig = entry.content[blockIndex];
3552
+ if (orig.type !== 'tool_result') continue;
3553
+ const fresherCount = occs.length - i - 1;
3554
+ entry.content[blockIndex] = {
3555
+ ...orig,
3556
+ content: `[Result truncated — tool '${toolName}' has ${fresherCount} more recent result${fresherCount === 1 ? '' : 's'} below]`,
3557
+ };
3558
+ }
3559
+ }
3560
+ }
3561
+
1260
3562
  protected isChunkOldEnough(chunk: Chunk): boolean {
1261
3563
  return true;
1262
3564
  }