@animalabs/context-manager 0.3.0 → 0.4.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 (50) hide show
  1. package/dist/src/context-manager.d.ts +69 -2
  2. package/dist/src/context-manager.d.ts.map +1 -1
  3. package/dist/src/context-manager.js +128 -10
  4. package/dist/src/context-manager.js.map +1 -1
  5. package/dist/src/strategies/autobiographical.d.ts +205 -3
  6. package/dist/src/strategies/autobiographical.d.ts.map +1 -1
  7. package/dist/src/strategies/autobiographical.js +767 -59
  8. package/dist/src/strategies/autobiographical.js.map +1 -1
  9. package/dist/src/types/index.d.ts +2 -2
  10. package/dist/src/types/index.d.ts.map +1 -1
  11. package/dist/src/types/index.js +1 -1
  12. package/dist/src/types/index.js.map +1 -1
  13. package/dist/src/types/strategy.d.ts +208 -0
  14. package/dist/src/types/strategy.d.ts.map +1 -1
  15. package/dist/src/types/strategy.js +21 -0
  16. package/dist/src/types/strategy.js.map +1 -1
  17. package/dist/test/non-blocking-compile.test.d.ts +22 -0
  18. package/dist/test/non-blocking-compile.test.d.ts.map +1 -0
  19. package/dist/test/non-blocking-compile.test.js +114 -0
  20. package/dist/test/non-blocking-compile.test.js.map +1 -0
  21. package/dist/test/pins.test.d.ts +10 -0
  22. package/dist/test/pins.test.d.ts.map +1 -0
  23. package/dist/test/pins.test.js +164 -0
  24. package/dist/test/pins.test.js.map +1 -0
  25. package/dist/test/recall-positioning.test.d.ts +16 -0
  26. package/dist/test/recall-positioning.test.d.ts.map +1 -0
  27. package/dist/test/recall-positioning.test.js +181 -0
  28. package/dist/test/recall-positioning.test.js.map +1 -0
  29. package/dist/test/search.test.d.ts +10 -0
  30. package/dist/test/search.test.d.ts.map +1 -0
  31. package/dist/test/search.test.js +141 -0
  32. package/dist/test/search.test.js.map +1 -0
  33. package/dist/test/speculation-cap.test.d.ts +17 -0
  34. package/dist/test/speculation-cap.test.d.ts.map +1 -0
  35. package/dist/test/speculation-cap.test.js +157 -0
  36. package/dist/test/speculation-cap.test.js.map +1 -0
  37. package/dist/test/strategy-persistence.test.d.ts +15 -0
  38. package/dist/test/strategy-persistence.test.d.ts.map +1 -0
  39. package/dist/test/strategy-persistence.test.js +244 -0
  40. package/dist/test/strategy-persistence.test.js.map +1 -0
  41. package/dist/test/tool-pruning.test.d.ts +10 -0
  42. package/dist/test/tool-pruning.test.d.ts.map +1 -0
  43. package/dist/test/tool-pruning.test.js +140 -0
  44. package/dist/test/tool-pruning.test.js.map +1 -0
  45. package/dist/tsconfig.tsbuildinfo +1 -1
  46. package/package.json +1 -1
  47. package/src/context-manager.ts +142 -10
  48. package/src/strategies/autobiographical.ts +815 -61
  49. package/src/types/index.ts +14 -1
  50. package/src/types/strategy.ts +223 -0
@@ -39,6 +39,18 @@ export class AutobiographicalStrategy {
39
39
  headWindowStartId = null;
40
40
  /** Cached result of getHeadWindowStartIndex to avoid repeated linear scans. */
41
41
  _cachedHeadStartIndex = null;
42
+ /** Chronicle store for persistent state. Set in `initialize()`. */
43
+ store = null;
44
+ /** Namespace for state-id scoping. Set in `initialize()`. */
45
+ ns = '';
46
+ get summariesStateId() { return `${this.ns}/autobio:summaries`; }
47
+ get counterStateId() { return `${this.ns}/autobio:counter`; }
48
+ get mergeQueueStateId() { return `${this.ns}/autobio:mergeQueue`; }
49
+ get pinsStateId() { return `${this.ns}/autobio:pins`; }
50
+ /** Protected ranges (pins + documents). Loaded from chronicle in initialize. */
51
+ pins = [];
52
+ /** Monotonically increasing counter for pin ids. Persisted as part of the pins snapshot. */
53
+ pinIdCounter = 0;
42
54
  constructor(config = {}) {
43
55
  this.config = { ...DEFAULT_AUTOBIOGRAPHICAL_CONFIG, ...config };
44
56
  // Hierarchical is on by default; set hierarchical: false to use legacy single-level
@@ -52,8 +64,14 @@ export class AutobiographicalStrategy {
52
64
  }
53
65
  }
54
66
  async initialize(ctx) {
67
+ // Bind to the chronicle store + namespace for persistent strategy state.
68
+ this.store = ctx.store;
69
+ this.ns = ctx.namespace;
70
+ this.registerStates();
71
+ this.loadPersistedState();
55
72
  // Restore headWindowStartId from last topic transition message
56
73
  const messages = ctx.messageStore.getAll();
74
+ this.headWindowStartId = null;
57
75
  for (let i = messages.length - 1; i >= 0; i--) {
58
76
  if (this.isTopicTransitionMessage(messages[i])) {
59
77
  this.headWindowStartId = messages[i].id;
@@ -62,6 +80,282 @@ export class AutobiographicalStrategy {
62
80
  }
63
81
  this.rebuildChunks(ctx.messageStore);
64
82
  }
83
+ /**
84
+ * Register the three Chronicle state slots this strategy uses.
85
+ * Idempotent — chronicle throws if a state is already registered, which we
86
+ * swallow (the existing slot is what we want).
87
+ */
88
+ registerStates() {
89
+ if (!this.store)
90
+ return;
91
+ try {
92
+ this.store.registerState({
93
+ id: this.summariesStateId,
94
+ strategy: 'append_log',
95
+ deltaSnapshotEvery: 50,
96
+ fullSnapshotEvery: 10,
97
+ });
98
+ }
99
+ catch { /* already registered */ }
100
+ try {
101
+ this.store.registerState({
102
+ id: this.counterStateId,
103
+ strategy: 'snapshot',
104
+ });
105
+ }
106
+ catch { /* already registered */ }
107
+ try {
108
+ this.store.registerState({
109
+ id: this.mergeQueueStateId,
110
+ strategy: 'snapshot',
111
+ });
112
+ }
113
+ catch { /* already registered */ }
114
+ try {
115
+ this.store.registerState({
116
+ id: this.pinsStateId,
117
+ strategy: 'snapshot',
118
+ });
119
+ }
120
+ catch { /* already registered */ }
121
+ }
122
+ /**
123
+ * Load summaries, counter, and pending merges from chronicle into the
124
+ * in-memory mirrors. Called on every (re)initialize so branch switches
125
+ * pick up the new branch's state.
126
+ */
127
+ loadPersistedState() {
128
+ if (!this.store) {
129
+ this.summaries = [];
130
+ this.summaryIdCounter = 0;
131
+ this.mergeQueue = [];
132
+ this.pins = [];
133
+ this.pinIdCounter = 0;
134
+ return;
135
+ }
136
+ const summaries = this.store.getStateJson(this.summariesStateId);
137
+ this.summaries = Array.isArray(summaries) ? summaries : [];
138
+ const counter = this.store.getStateJson(this.counterStateId);
139
+ this.summaryIdCounter = typeof counter === 'number' ? counter : 0;
140
+ const queue = this.store.getStateJson(this.mergeQueueStateId);
141
+ this.mergeQueue = Array.isArray(queue)
142
+ ? queue
143
+ : [];
144
+ const pinsState = this.store.getStateJson(this.pinsStateId);
145
+ if (pinsState && typeof pinsState === 'object' && Array.isArray(pinsState.pins)) {
146
+ const ps = pinsState;
147
+ this.pins = ps.pins;
148
+ this.pinIdCounter = typeof ps.counter === 'number' ? ps.counter : ps.pins.length;
149
+ }
150
+ else {
151
+ this.pins = [];
152
+ this.pinIdCounter = 0;
153
+ }
154
+ }
155
+ /** Persist the current pins + counter as a single snapshot. */
156
+ persistPins() {
157
+ this.store?.setStateJson(this.pinsStateId, {
158
+ pins: this.pins,
159
+ counter: this.pinIdCounter,
160
+ });
161
+ }
162
+ // ============================================================================
163
+ // Pins / documents (protected ranges)
164
+ // ============================================================================
165
+ /**
166
+ * Pin a range of messages so they aren't compressed and render raw at
167
+ * their original position. Returns the pin id.
168
+ */
169
+ pinRange(firstMessageId, lastMessageId, opts) {
170
+ const id = `pin-${this.pinIdCounter++}`;
171
+ this.pins.push({
172
+ id,
173
+ firstMessageId,
174
+ lastMessageId,
175
+ kind: 'pin',
176
+ name: opts?.name,
177
+ created: Date.now(),
178
+ });
179
+ this.persistPins();
180
+ return id;
181
+ }
182
+ /**
183
+ * Mark a single message as a "document" — semantically a body of
184
+ * information the agent wants to retain in full. Functionally a
185
+ * single-message pin with `kind: 'document'`.
186
+ */
187
+ markDocument(messageId, opts) {
188
+ const id = `pin-${this.pinIdCounter++}`;
189
+ this.pins.push({
190
+ id,
191
+ firstMessageId: messageId,
192
+ lastMessageId: messageId,
193
+ kind: 'document',
194
+ name: opts?.name,
195
+ created: Date.now(),
196
+ });
197
+ this.persistPins();
198
+ return id;
199
+ }
200
+ /** Remove a pin or document mark by id. Returns true if removed. */
201
+ unpin(pinId) {
202
+ const before = this.pins.length;
203
+ this.pins = this.pins.filter(p => p.id !== pinId);
204
+ if (this.pins.length < before) {
205
+ this.persistPins();
206
+ return true;
207
+ }
208
+ return false;
209
+ }
210
+ /** Read-only list of all current pins. */
211
+ listPins() {
212
+ return this.pins;
213
+ }
214
+ // ============================================================================
215
+ // Search (gap #7)
216
+ // ============================================================================
217
+ /**
218
+ * Look up a single summary by id. Returns null if not found.
219
+ */
220
+ getSummary(id) {
221
+ return this.summaries.find(s => s.id === id) ?? null;
222
+ }
223
+ /**
224
+ * Search summaries by substring or regex over their content.
225
+ *
226
+ * Result ordering: matches by descending hit count, then by descending
227
+ * `created` timestamp (newest first within the same hit count).
228
+ *
229
+ * Default behavior: only "live" (unmerged) summaries are searched. Set
230
+ * `includeMerged: true` to also include summaries that have been folded
231
+ * into a higher level.
232
+ */
233
+ searchSummaries(query) {
234
+ const limit = query.limit ?? 50;
235
+ const includeMerged = query.includeMerged ?? false;
236
+ // Build the matcher
237
+ let matcher = null;
238
+ if (query.regex) {
239
+ const flags = query.regex.flags.includes('g') ? query.regex.flags : query.regex.flags + 'g';
240
+ const re = new RegExp(query.regex.source, flags);
241
+ matcher = (content) => {
242
+ const matches = content.match(re);
243
+ return matches ? matches.length : 0;
244
+ };
245
+ }
246
+ else if (query.text) {
247
+ const needle = query.text.toLowerCase();
248
+ matcher = (content) => {
249
+ const hay = content.toLowerCase();
250
+ let count = 0;
251
+ let idx = 0;
252
+ while ((idx = hay.indexOf(needle, idx)) !== -1) {
253
+ count++;
254
+ idx += needle.length || 1;
255
+ }
256
+ return count;
257
+ };
258
+ }
259
+ else {
260
+ // No pattern: every summary "matches" once
261
+ matcher = () => 1;
262
+ }
263
+ const levelsFilter = query.levels && query.levels.length > 0 ? new Set(query.levels) : null;
264
+ const results = [];
265
+ for (const s of this.summaries) {
266
+ if (!includeMerged && s.mergedInto)
267
+ continue;
268
+ if (levelsFilter && !levelsFilter.has(s.level))
269
+ continue;
270
+ const matches = matcher(s.content);
271
+ if (matches > 0) {
272
+ results.push({ summary: s, matches });
273
+ }
274
+ }
275
+ results.sort((a, b) => {
276
+ if (b.matches !== a.matches)
277
+ return b.matches - a.matches;
278
+ return b.summary.created - a.summary.created;
279
+ });
280
+ return results.slice(0, limit);
281
+ }
282
+ /**
283
+ * Whether a given message position is inside any protected range.
284
+ * Uses a position map (computed by caller) so callers can avoid
285
+ * repeated per-message lookups in tight loops.
286
+ */
287
+ isPositionPinned(position, pinPositions) {
288
+ return pinPositions.has(position);
289
+ }
290
+ /**
291
+ * Build a set of message-store positions covered by any pin. O(N pins · K range).
292
+ * Returns positions for which the message exists; orphan pins (deleted
293
+ * messages) are silently skipped.
294
+ */
295
+ pinnedPositions(messages) {
296
+ if (this.pins.length === 0)
297
+ return new Set();
298
+ const positionOf = new Map();
299
+ for (let i = 0; i < messages.length; i++) {
300
+ positionOf.set(messages[i].id, i);
301
+ }
302
+ const out = new Set();
303
+ for (const pin of this.pins) {
304
+ const first = positionOf.get(pin.firstMessageId);
305
+ const last = positionOf.get(pin.lastMessageId);
306
+ if (first === undefined || last === undefined)
307
+ continue;
308
+ const lo = Math.min(first, last);
309
+ const hi = Math.max(first, last);
310
+ for (let i = lo; i <= hi; i++)
311
+ out.add(i);
312
+ }
313
+ return out;
314
+ }
315
+ /**
316
+ * Append a summary to the in-memory list and to the chronicle AppendLog.
317
+ * Single point so subclasses inherit persistence.
318
+ */
319
+ pushSummary(entry) {
320
+ this.summaries.push(entry);
321
+ this.store?.appendToStateJson(this.summariesStateId, entry);
322
+ }
323
+ /**
324
+ * Mark a summary as merged into a higher-level summary, updating the
325
+ * chronicle copy at the same index. Index is the position in `this.summaries`.
326
+ */
327
+ setMergedInto(entry, mergedIntoId) {
328
+ entry.mergedInto = mergedIntoId;
329
+ if (!this.store)
330
+ return;
331
+ const index = this.summaries.indexOf(entry);
332
+ if (index < 0)
333
+ return;
334
+ this.store.editStateItem(this.summariesStateId, index, Buffer.from(JSON.stringify(entry)));
335
+ }
336
+ /**
337
+ * Allocate the next summary-id counter value and persist the new counter.
338
+ */
339
+ nextSummaryIdCounter() {
340
+ const value = this.summaryIdCounter++;
341
+ this.store?.setStateJson(this.counterStateId, this.summaryIdCounter);
342
+ return value;
343
+ }
344
+ /**
345
+ * Push to the merge queue and persist the new queue snapshot.
346
+ */
347
+ enqueueMerge(merge) {
348
+ this.mergeQueue.push(merge);
349
+ this.store?.setStateJson(this.mergeQueueStateId, this.mergeQueue);
350
+ }
351
+ /**
352
+ * Pop from the merge queue and persist the new queue snapshot.
353
+ */
354
+ dequeueMerge() {
355
+ const merge = this.mergeQueue.shift();
356
+ this.store?.setStateJson(this.mergeQueueStateId, this.mergeQueue);
357
+ return merge;
358
+ }
65
359
  checkReadiness() {
66
360
  if (this.pendingCompression) {
67
361
  return {
@@ -87,13 +381,75 @@ export class AutobiographicalStrategy {
87
381
  }
88
382
  async onNewMessage(message, ctx) {
89
383
  this.rebuildChunks(ctx.messageStore);
90
- // Auto-tick: fire compression in the background so it runs without
91
- // the framework explicitly calling tick(). compile() will await
92
- // pendingCompression via checkReadiness().
93
- if (this.config.autoTickOnNewMessage && this.compressionQueue.length > 0 && !this.pendingCompression) {
94
- this.tick(ctx).catch((err) => console.error('AutobiographicalStrategy: auto-tick error:', err));
384
+ // Auto-tick: fire speculative compression in the background. After
385
+ // each tick completes, if the queue still has work AND we're under
386
+ // the speculation cap AND preflight allows, schedule another tick.
387
+ // This drains the queue ahead of need rather than one-chunk-per-
388
+ // user-turn (reactive). Combined with ContextManager.compile not
389
+ // awaiting pendingCompression, the agent's response and background
390
+ // compression run truly in parallel.
391
+ if (this.config.autoTickOnNewMessage && !this.pendingCompression) {
392
+ this.driveSpeculativeDrain(ctx);
95
393
  }
96
394
  }
395
+ /**
396
+ * Background-drain loop: keeps calling tick() while there's queued work,
397
+ * subject to the speculation cap and preflight hook. Recurses via
398
+ * `queueMicrotask` so one chunk's compression doesn't block the
399
+ * scheduling of the next.
400
+ *
401
+ * Stops if a tick fails to make progress (queue size unchanged) — guards
402
+ * against runaway recursion when tick is a no-op (e.g. no membrane
403
+ * configured, or a subclass override that doesn't process the queue).
404
+ */
405
+ driveSpeculativeDrain(ctx) {
406
+ if (this.pendingCompression)
407
+ return;
408
+ if (this.compressionQueue.length === 0 && this.mergeQueue.length === 0)
409
+ return;
410
+ if (this.isAtSpeculativeCap())
411
+ return;
412
+ if (!this.shouldCompressPreflight())
413
+ return;
414
+ const beforeChunks = this.compressionQueue.length;
415
+ const beforeMerges = this.mergeQueue.length;
416
+ this.tick(ctx)
417
+ .then(() => {
418
+ const afterChunks = this.compressionQueue.length;
419
+ const afterMerges = this.mergeQueue.length;
420
+ // Made progress if either queue shrank.
421
+ const progressed = afterChunks < beforeChunks || afterMerges < beforeMerges;
422
+ if (!progressed)
423
+ return;
424
+ // Recurse to drain more. queueMicrotask defers until the current
425
+ // task is done, letting other code (the agent's stream consumer)
426
+ // interleave.
427
+ queueMicrotask(() => this.driveSpeculativeDrain(ctx));
428
+ })
429
+ .catch((err) => {
430
+ console.error('AutobiographicalStrategy: speculative-drain error:', err);
431
+ });
432
+ }
433
+ /**
434
+ * Whether the strategy's pending+queued L1 budget has reached the cap
435
+ * configured by `maxSpeculativeL1s`. If no cap is set, always false.
436
+ */
437
+ isAtSpeculativeCap() {
438
+ const cap = this.config.maxSpeculativeL1s;
439
+ if (cap === undefined || cap < 0)
440
+ return false;
441
+ const unmergedL1s = this.summaries.filter(s => s.level === 1 && !s.mergedInto).length;
442
+ return unmergedL1s + this.compressionQueue.length > cap;
443
+ }
444
+ /**
445
+ * Preflight hook for whether speculative compression should fire on
446
+ * `onNewMessage`. Returns true by default (current eager behavior).
447
+ * Subclasses can override for predictive scheduling — e.g. only fire
448
+ * when the live tail token count is approaching some threshold.
449
+ */
450
+ shouldCompressPreflight() {
451
+ return true;
452
+ }
97
453
  async tick(ctx) {
98
454
  if (this.pendingCompression)
99
455
  return;
@@ -119,11 +475,26 @@ export class AutobiographicalStrategy {
119
475
  return;
120
476
  }
121
477
  // Priority 2: Execute pending merges (hierarchical only)
478
+ //
479
+ // Peek at the head rather than dequeueing eagerly: dequeueMerge persists
480
+ // the shorter queue *before* the LLM call leaves the building, so a
481
+ // transient failure (429, network drop, timeout, executeMerge throw)
482
+ // would silently lose the merge from disk and the sources would sit at
483
+ // level N-1 with no mergedInto pointers forever. Commit the removal
484
+ // only after the merge succeeds; on failure, the queue keeps its entry
485
+ // and the next tick() retries it.
122
486
  if (this.config.hierarchical && this.mergeQueue.length > 0) {
123
- const merge = this.mergeQueue.shift();
487
+ const merge = this.mergeQueue[0];
124
488
  this.pendingCompression = this.executeMerge(merge.level, merge.sourceIds, ctx);
125
489
  try {
126
490
  await this.pendingCompression;
491
+ // Success: drop from head and persist the shorter queue. We
492
+ // re-check that head is still our merge in case some future code
493
+ // path mutates the queue mid-await (today no other site does,
494
+ // but the assertion makes that invariant explicit).
495
+ if (this.mergeQueue[0] === merge) {
496
+ this.dequeueMerge();
497
+ }
127
498
  }
128
499
  finally {
129
500
  this.pendingCompression = null;
@@ -150,6 +521,39 @@ export class AutobiographicalStrategy {
150
521
  pendingMerges: this.mergeQueue.length,
151
522
  };
152
523
  }
524
+ /**
525
+ * Richer per-render stats: requires a message store view to compute the
526
+ * head + tail (recent window) sizes. Returns counts AND token sums per
527
+ * summary level, so observers can see "how much of the agent's context
528
+ * is in raw tail vs folded into L1/L2/L3."
529
+ *
530
+ * Useful for TUI / dashboards. The token sums use the strategy's own
531
+ * token estimates (which match what `select()` uses for budget math).
532
+ */
533
+ getRenderStats(store) {
534
+ const messages = store.getAll();
535
+ const headStart = this.getHeadWindowStartIndex(store);
536
+ const headEnd = this.getHeadWindowEnd(store);
537
+ const recentStart = this.getRecentWindowStart(store);
538
+ const sumTokens = (slice) => slice.reduce((acc, m) => acc + store.estimateTokens(m), 0);
539
+ const headMsgs = messages.slice(headStart, headEnd);
540
+ const tailMsgs = messages.slice(recentStart);
541
+ const live = (level) => this.summaries.filter(s => s.level === level && !s.mergedInto);
542
+ const sumLevelTokens = (level) => live(level).reduce((acc, s) => acc + s.tokens, 0);
543
+ return {
544
+ head: { messages: headMsgs.length, tokens: sumTokens(headMsgs) },
545
+ tail: { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) },
546
+ summaries: {
547
+ l1: { count: live(1).length, tokens: sumLevelTokens(1) },
548
+ l2: { count: live(2).length, tokens: sumLevelTokens(2) },
549
+ l3: { count: live(3).length, tokens: sumLevelTokens(3) },
550
+ },
551
+ pending: {
552
+ chunks: this.chunks.filter(c => !c.compressed).length,
553
+ merges: this.mergeQueue.length,
554
+ },
555
+ };
556
+ }
153
557
  // ============================================================================
154
558
  // Legacy (single-level) path
155
559
  // ============================================================================
@@ -166,7 +570,7 @@ export class AutobiographicalStrategy {
166
570
  const msg = messages[i];
167
571
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
168
572
  const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
169
- if (totalTokens + tokens > maxTokens)
573
+ if (this.isOverBudget(totalTokens + tokens, maxTokens))
170
574
  break;
171
575
  entries.push({
172
576
  index: entries.length,
@@ -208,7 +612,7 @@ export class AutobiographicalStrategy {
208
612
  };
209
613
  const pairTokens = this.estimateTokens(questionEntry.content) +
210
614
  this.estimateTokens(answerEntry.content);
211
- if (totalTokens + pairTokens > maxTokens)
615
+ if (this.isOverBudget(totalTokens + pairTokens, maxTokens))
212
616
  break;
213
617
  entries.push(questionEntry);
214
618
  entries.push(answerEntry);
@@ -219,7 +623,7 @@ export class AutobiographicalStrategy {
219
623
  for (const msg of chunk.messages) {
220
624
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
221
625
  const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
222
- if (totalTokens + tokens > maxTokens)
626
+ if (this.isOverBudget(totalTokens + tokens, maxTokens))
223
627
  break;
224
628
  entries.push({
225
629
  index: entries.length,
@@ -243,7 +647,7 @@ export class AutobiographicalStrategy {
243
647
  const msg = messages[i];
244
648
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
245
649
  const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
246
- if (totalTokens + tokens > maxTokens)
650
+ if (this.isOverBudget(totalTokens + tokens, maxTokens))
247
651
  break;
248
652
  entries.push({
249
653
  index: entries.length,
@@ -258,6 +662,7 @@ export class AutobiographicalStrategy {
258
662
  const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
259
663
  this.emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokens);
260
664
  this.trimOrphanedToolUse(entries);
665
+ this.pruneToolEntries(entries);
261
666
  return entries;
262
667
  }
263
668
  /**
@@ -281,7 +686,7 @@ export class AutobiographicalStrategy {
281
686
  const tokens = msgCap > 0
282
687
  ? Math.min(store.estimateTokens(msg), msgCap + 50)
283
688
  : store.estimateTokens(msg);
284
- if (totalTokensBefore + acceptedTokens + tokens > maxTokens)
689
+ if (this.isOverBudget(totalTokensBefore + acceptedTokens + tokens, maxTokens))
285
690
  break;
286
691
  accepted.push(i);
287
692
  acceptedTokens += tokens;
@@ -329,7 +734,6 @@ export class AutobiographicalStrategy {
329
734
  config: {
330
735
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
331
736
  maxTokens: 2000,
332
- temperature: 0,
333
737
  },
334
738
  };
335
739
  try {
@@ -450,7 +854,6 @@ export class AutobiographicalStrategy {
450
854
  config: {
451
855
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
452
856
  maxTokens: Math.round(targetTokens * 1.5),
453
- temperature: 0,
454
857
  },
455
858
  };
456
859
  try {
@@ -461,7 +864,7 @@ export class AutobiographicalStrategy {
461
864
  .join('\n');
462
865
  const messageIds = chunk.messages.map(m => m.id);
463
866
  const entry = {
464
- id: `L1-${this.summaryIdCounter++}`,
867
+ id: `L1-${this.nextSummaryIdCounter()}`,
465
868
  level: 1,
466
869
  content: summaryText,
467
870
  tokens: Math.ceil(summaryText.length / 4),
@@ -474,7 +877,7 @@ export class AutobiographicalStrategy {
474
877
  created: Date.now(),
475
878
  phaseType: chunk.phaseType,
476
879
  };
477
- this.summaries.push(entry);
880
+ this.pushSummary(entry);
478
881
  chunk.compressed = true;
479
882
  chunk.summaryId = entry.id;
480
883
  this._compressionCount++;
@@ -488,23 +891,37 @@ export class AutobiographicalStrategy {
488
891
  /**
489
892
  * Check if unmerged summary counts exceed the merge threshold.
490
893
  * Enqueues merge operations if so.
894
+ *
895
+ * Skips L1s/L2s that are already in a pending merge — without this guard,
896
+ * each new summary above threshold re-enqueues a merge for the same
897
+ * already-eligible siblings, producing N near-identical higher-level
898
+ * summaries when the queue eventually drains.
491
899
  */
492
900
  checkMergeThreshold() {
493
901
  const threshold = this.config.mergeThreshold ?? 6;
902
+ // IDs that are already part of a queued merge — exclude them from
903
+ // eligibility so we don't re-enqueue.
904
+ const queuedL1 = new Set();
905
+ const queuedL2 = new Set();
906
+ for (const m of this.mergeQueue) {
907
+ const set = m.level === 2 ? queuedL1 : queuedL2;
908
+ for (const id of m.sourceIds)
909
+ set.add(id);
910
+ }
494
911
  // Check L1 → L2
495
- const unmergedL1 = this.summaries.filter(s => s.level === 1 && !s.mergedInto);
912
+ const unmergedL1 = this.summaries.filter(s => s.level === 1 && !s.mergedInto && !queuedL1.has(s.id));
496
913
  if (unmergedL1.length >= threshold) {
497
914
  const toMerge = unmergedL1.slice(0, threshold);
498
- this.mergeQueue.push({
915
+ this.enqueueMerge({
499
916
  level: 2,
500
917
  sourceIds: toMerge.map(s => s.id),
501
918
  });
502
919
  }
503
920
  // Check L2 → L3
504
- const unmergedL2 = this.summaries.filter(s => s.level === 2 && !s.mergedInto);
921
+ const unmergedL2 = this.summaries.filter(s => s.level === 2 && !s.mergedInto && !queuedL2.has(s.id));
505
922
  if (unmergedL2.length >= threshold) {
506
923
  const toMerge = unmergedL2.slice(0, threshold);
507
- this.mergeQueue.push({
924
+ this.enqueueMerge({
508
925
  level: 3,
509
926
  sourceIds: toMerge.map(s => s.id),
510
927
  });
@@ -525,6 +942,14 @@ export class AutobiographicalStrategy {
525
942
  console.warn('executeMerge: some source summaries not found, skipping');
526
943
  return;
527
944
  }
945
+ // Defensive: if every source is already mergedInto something, this is a
946
+ // stale queue entry (could happen if multiple merges for the same
947
+ // sourceIds were enqueued before the dedup fix in checkMergeThreshold).
948
+ // Skip rather than produce a redundant near-identical higher-level entry.
949
+ if (sources.every(s => s.mergedInto)) {
950
+ console.warn(`executeMerge: all sources already merged into ${sources[0].mergedInto}, skipping (stale queue entry)`);
951
+ return;
952
+ }
528
953
  const targetTokens = this.config.summaryTargetTokens ?? 2000;
529
954
  const participant = this.config.summaryParticipant ?? 'Claude';
530
955
  // Build message array
@@ -564,7 +989,6 @@ export class AutobiographicalStrategy {
564
989
  config: {
565
990
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
566
991
  maxTokens: Math.round(targetTokens * 1.5),
567
- temperature: 0,
568
992
  },
569
993
  };
570
994
  try {
@@ -580,7 +1004,7 @@ export class AutobiographicalStrategy {
580
1004
  };
581
1005
  const sourceLevel = (targetLevel - 1);
582
1006
  const newEntry = {
583
- id: `L${targetLevel}-${this.summaryIdCounter++}`,
1007
+ id: `L${targetLevel}-${this.nextSummaryIdCounter()}`,
584
1008
  level: targetLevel,
585
1009
  content: mergedText,
586
1010
  tokens: Math.ceil(mergedText.length / 4),
@@ -589,10 +1013,14 @@ export class AutobiographicalStrategy {
589
1013
  sourceRange,
590
1014
  created: Date.now(),
591
1015
  };
592
- this.summaries.push(newEntry);
593
- // Mark sources as merged
1016
+ // Append the new merged entry first, then mark sources. Persist each
1017
+ // mergedInto edit individually so chronicle reflects the same shape as
1018
+ // the in-memory mirror. (If the process crashes mid-loop, restart sees
1019
+ // the new entry plus a partial set of marked sources; un-marked sources
1020
+ // would re-trigger a merge — accept the rare duplicate over data loss.)
1021
+ this.pushSummary(newEntry);
594
1022
  for (const source of sources) {
595
- source.mergedInto = newEntry.id;
1023
+ this.setMergedInto(source, newEntry.id);
596
1024
  }
597
1025
  // Check if this merge triggers a further merge
598
1026
  this.checkMergeThreshold();
@@ -619,7 +1047,7 @@ export class AutobiographicalStrategy {
619
1047
  const msg = messages[i];
620
1048
  const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
621
1049
  const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
622
- if (totalTokens + tokens > maxTokens)
1050
+ if (this.isOverBudget(totalTokens + tokens, maxTokens))
623
1051
  break;
624
1052
  entries.push({
625
1053
  index: entries.length,
@@ -654,7 +1082,7 @@ export class AutobiographicalStrategy {
654
1082
  for (const s of shownL3) {
655
1083
  if (l3Used + s.tokens > l3Budget)
656
1084
  break;
657
- if (totalTokens + totalSummaryTokens + s.tokens > maxTokens)
1085
+ if (this.isOverBudget(totalTokens + totalSummaryTokens + s.tokens, maxTokens))
658
1086
  break;
659
1087
  selectedSummaries.push(s);
660
1088
  l3Used += s.tokens;
@@ -667,7 +1095,7 @@ export class AutobiographicalStrategy {
667
1095
  for (const s of shownL2) {
668
1096
  if (l2Used + s.tokens > l2Effective)
669
1097
  break;
670
- if (totalTokens + totalSummaryTokens + s.tokens > maxTokens)
1098
+ if (this.isOverBudget(totalTokens + totalSummaryTokens + s.tokens, maxTokens))
671
1099
  break;
672
1100
  selectedSummaries.push(s);
673
1101
  l2Used += s.tokens;
@@ -680,32 +1108,173 @@ export class AutobiographicalStrategy {
680
1108
  const { selected: l1Selected, tokensUsed: l1Used } = this.selectL1Summaries(shownL1, l1Effective, l1Remaining);
681
1109
  selectedSummaries.push(...l1Selected);
682
1110
  totalSummaryTokens += l1Used;
683
- // Emit summaries as a single Q&A pair
684
- if (selectedSummaries.length > 0) {
685
- const contextLabel = this.config.summaryContextLabel ?? 'What do you remember from earlier?';
686
- const combinedText = selectedSummaries.map(s => s.content).join('\n\n---\n\n');
687
- const questionEntry = {
688
- index: entries.length,
689
- participant: 'Context Manager',
690
- content: [{ type: 'text', text: contextLabel }],
691
- sourceRelation: 'derived',
692
- };
693
- // Synthesised summary turns must respect maxMessageTokens. With L1+L2+L3
694
- // budgets defaulting to 30k each, an unconstrained concatenation can push
695
- // a single assistant turn past 90k tokens, eating the inference budget
696
- // and starving recent messages (postmortem 2026-05-04, bug B).
697
- const answerContent = [{ type: 'text', text: combinedText }];
698
- const answerEntry = {
699
- index: entries.length + 1,
700
- participant: this.config.summaryParticipant ?? 'Claude',
701
- content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
702
- sourceRelation: 'derived',
703
- };
704
- const pairTokens = this.estimateTokens(questionEntry.content) +
705
- this.estimateTokens(answerEntry.content);
706
- entries.push(questionEntry);
707
- entries.push(answerEntry);
708
- totalTokens += pairTokens;
1111
+ // Emit summaries + pinned messages between head and recent windows.
1112
+ //
1113
+ // Default (positionedRecallPairs=true): one Q/A pair per summary,
1114
+ // interleaved with raw pinned messages, all sorted chronologically by
1115
+ // source-range / message position. Each memory appears in its temporal
1116
+ // place rather than as a wall of unrelated recollections.
1117
+ //
1118
+ // Legacy (positionedRecallPairs=false): summaries concatenated into one
1119
+ // Q/A pair between head and tail; pinned messages still emit raw, in
1120
+ // their chronological positions, after the combined recall pair.
1121
+ const positionOf = new Map();
1122
+ for (let i = 0; i < messages.length; i++) {
1123
+ positionOf.set(messages[i].id, i);
1124
+ }
1125
+ const pinnedPositionsSet = this.pinnedPositions(messages);
1126
+ // Pinned messages between head and recent (head/recent pinned ones
1127
+ // already emit raw via Phase 0 / Phase 4).
1128
+ const pinnedInMiddle = [];
1129
+ const pinnedIdsInMiddle = new Set();
1130
+ for (let i = headEnd; i < recentStart; i++) {
1131
+ if (pinnedPositionsSet.has(i)) {
1132
+ pinnedInMiddle.push({ msg: messages[i], position: i });
1133
+ pinnedIdsInMiddle.add(messages[i].id);
1134
+ }
1135
+ }
1136
+ // Uncompressed-chunk fallback: messages in the middle region whose
1137
+ // chunk hasn't been summarized yet. Without this, a message that
1138
+ // rolled out of the recent window into a queued-but-not-yet-compressed
1139
+ // chunk would vanish from rendered context — there'd be no summary to
1140
+ // emit (compression hasn't run) and Phase 4 only walks recentStart
1141
+ // onwards. Mirrors selectLegacy's "Uncompressed: emit raw" behavior
1142
+ // around line 738, but here we interleave chronologically with
1143
+ // summaries and pins via the unified items list below.
1144
+ //
1145
+ // This matters because compile() was made non-blocking in commit
1146
+ // `3e42e98` (drops the prior `await readiness.pendingWork`); without
1147
+ // this fallback, the trade was silent data-loss for messages caught
1148
+ // in the queued-but-not-yet-compressed window. Now compile()'s
1149
+ // freshness contract is: summaries may lag the very latest L1, but
1150
+ // no message ever disappears.
1151
+ const uncompressedInMiddle = [];
1152
+ for (const chunk of this.chunks) {
1153
+ if (chunk.compressed)
1154
+ continue;
1155
+ for (const msg of chunk.messages) {
1156
+ const pos = positionOf.get(msg.id);
1157
+ if (pos === undefined)
1158
+ continue;
1159
+ if (pos < headEnd || pos >= recentStart)
1160
+ continue;
1161
+ if (pinnedIdsInMiddle.has(msg.id))
1162
+ continue;
1163
+ uncompressedInMiddle.push({ msg, position: pos });
1164
+ }
1165
+ }
1166
+ // Merged list of raw messages to emit in the middle region —
1167
+ // either a pin or a message whose chunk hasn't compressed yet.
1168
+ // Both render identically (raw, at their chronological position).
1169
+ const middleRaw = [
1170
+ ...pinnedInMiddle,
1171
+ ...uncompressedInMiddle,
1172
+ ];
1173
+ if (selectedSummaries.length > 0 || middleRaw.length > 0) {
1174
+ const summaryParticipant = this.config.summaryParticipant ?? 'Claude';
1175
+ if (this.config.positionedRecallPairs !== false) {
1176
+ const items = [];
1177
+ for (const s of selectedSummaries) {
1178
+ const pos = positionOf.get(s.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
1179
+ items.push({ kind: 'summary', position: pos, summary: s });
1180
+ }
1181
+ for (const p of middleRaw) {
1182
+ items.push({ kind: 'pin', position: p.position, msg: p.msg });
1183
+ }
1184
+ items.sort((a, b) => a.position - b.position);
1185
+ for (const item of items) {
1186
+ if (item.kind === 'summary') {
1187
+ const summary = item.summary;
1188
+ const headerText = this.buildRecallHeader(summary);
1189
+ const questionEntry = {
1190
+ index: entries.length,
1191
+ participant: 'Context Manager',
1192
+ content: [{ type: 'text', text: headerText }],
1193
+ sourceRelation: 'derived',
1194
+ };
1195
+ const answerContent = [{ type: 'text', text: summary.content }];
1196
+ const answerEntry = {
1197
+ index: entries.length + 1,
1198
+ participant: summaryParticipant,
1199
+ content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
1200
+ sourceRelation: 'derived',
1201
+ };
1202
+ const pairTokens = this.estimateTokens(questionEntry.content) +
1203
+ this.estimateTokens(answerEntry.content);
1204
+ if (this.isOverBudget(totalTokens + pairTokens, maxTokens))
1205
+ break;
1206
+ entries.push(questionEntry);
1207
+ entries.push(answerEntry);
1208
+ totalTokens += pairTokens;
1209
+ }
1210
+ else {
1211
+ const msg = item.msg;
1212
+ const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1213
+ const tokens = msgCap > 0
1214
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
1215
+ : store.estimateTokens(msg);
1216
+ if (this.isOverBudget(totalTokens + tokens, maxTokens))
1217
+ break;
1218
+ entries.push({
1219
+ index: entries.length,
1220
+ sourceMessageId: msg.id,
1221
+ sourceRelation: 'copy',
1222
+ participant: msg.participant,
1223
+ content,
1224
+ });
1225
+ totalTokens += tokens;
1226
+ }
1227
+ }
1228
+ }
1229
+ else {
1230
+ // Legacy combined-pair mode for summaries; pins still emit raw at
1231
+ // their positions after the combined pair.
1232
+ if (selectedSummaries.length > 0) {
1233
+ const contextLabel = this.config.summaryContextLabel ?? 'What do you remember from earlier?';
1234
+ const combinedText = selectedSummaries.map(s => s.content).join('\n\n---\n\n');
1235
+ const questionEntry = {
1236
+ index: entries.length,
1237
+ participant: 'Context Manager',
1238
+ content: [{ type: 'text', text: contextLabel }],
1239
+ sourceRelation: 'derived',
1240
+ };
1241
+ // Synthesised summary turns must respect maxMessageTokens. With L1+L2+L3
1242
+ // budgets defaulting to 30k each, an unconstrained concatenation can push
1243
+ // a single assistant turn past 90k tokens, eating the inference budget
1244
+ // and starving recent messages (postmortem 2026-05-04, bug B).
1245
+ const answerContent = [{ type: 'text', text: combinedText }];
1246
+ const answerEntry = {
1247
+ index: entries.length + 1,
1248
+ participant: summaryParticipant,
1249
+ content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
1250
+ sourceRelation: 'derived',
1251
+ };
1252
+ const pairTokens = this.estimateTokens(questionEntry.content) +
1253
+ this.estimateTokens(answerEntry.content);
1254
+ entries.push(questionEntry);
1255
+ entries.push(answerEntry);
1256
+ totalTokens += pairTokens;
1257
+ }
1258
+ // Sort by position so uncompressed-middle messages and pins both
1259
+ // appear in their chronological place after the combined recall pair.
1260
+ const middleRawSorted = [...middleRaw].sort((a, b) => a.position - b.position);
1261
+ for (const { msg } of middleRawSorted) {
1262
+ const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
1263
+ const tokens = msgCap > 0
1264
+ ? Math.min(store.estimateTokens(msg), msgCap + 50)
1265
+ : store.estimateTokens(msg);
1266
+ if (this.isOverBudget(totalTokens + tokens, maxTokens))
1267
+ break;
1268
+ entries.push({
1269
+ index: entries.length,
1270
+ sourceMessageId: msg.id,
1271
+ sourceRelation: 'copy',
1272
+ participant: msg.participant,
1273
+ content,
1274
+ });
1275
+ totalTokens += tokens;
1276
+ }
1277
+ }
709
1278
  }
710
1279
  // Phase 4: Recent uncompressed messages (skip head window overlap).
711
1280
  // Newest-first eviction so that when summaries/head consume most of the
@@ -714,6 +1283,7 @@ export class AutobiographicalStrategy {
714
1283
  const effectiveRecentStart = Math.max(recentStart, headEnd);
715
1284
  this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
716
1285
  this.trimOrphanedToolUse(entries);
1286
+ this.pruneToolEntries(entries);
717
1287
  return entries;
718
1288
  }
719
1289
  // ============================================================================
@@ -743,13 +1313,56 @@ export class AutobiographicalStrategy {
743
1313
  for (const s of shownL1) {
744
1314
  if (used + s.tokens > budget)
745
1315
  break;
746
- if (used + s.tokens > maxTokens)
1316
+ if (this.isOverBudget(used + s.tokens, maxTokens))
747
1317
  break;
748
1318
  selected.push(s);
749
1319
  used += s.tokens;
750
1320
  }
751
1321
  return { selected, tokensUsed: used };
752
1322
  }
1323
+ /**
1324
+ * True if `projectedTotal` exceeds `max` AND the strategy is configured
1325
+ * to enforce budget. When `enforceBudget: false`, always returns false
1326
+ * — the rendering pipeline emits the full ideal context regardless of
1327
+ * budget overage. Caller's API will reject if it exceeds the model's
1328
+ * context window; the philosophy is "surface overage, don't hide it."
1329
+ */
1330
+ isOverBudget(projectedTotal, max) {
1331
+ if (this.config.enforceBudget === false)
1332
+ return false;
1333
+ return projectedTotal > max;
1334
+ }
1335
+ /**
1336
+ * Sort selected summaries by source-range start position, so per-pair
1337
+ * recall emission appears in chronological order. Falls back to the
1338
+ * created timestamp for summaries whose source-range first message is
1339
+ * no longer in the store.
1340
+ */
1341
+ sortSummariesChronologically(summaries, messages) {
1342
+ const positionOf = new Map();
1343
+ for (let i = 0; i < messages.length; i++) {
1344
+ positionOf.set(messages[i].id, i);
1345
+ }
1346
+ return [...summaries].sort((a, b) => {
1347
+ const posA = positionOf.get(a.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
1348
+ const posB = positionOf.get(b.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
1349
+ if (posA !== posB)
1350
+ return posA - posB;
1351
+ return a.created - b.created;
1352
+ });
1353
+ }
1354
+ /**
1355
+ * Render the per-pair recall header from the configured template.
1356
+ * Substitutions: {id} {level} {first} {last}.
1357
+ */
1358
+ buildRecallHeader(summary) {
1359
+ const template = this.config.recallHeaderTemplate ?? '[Recall {id}]';
1360
+ return template
1361
+ .replace(/\{id\}/g, summary.id)
1362
+ .replace(/\{level\}/g, String(summary.level))
1363
+ .replace(/\{first\}/g, summary.sourceRange.first)
1364
+ .replace(/\{last\}/g, summary.sourceRange.last);
1365
+ }
753
1366
  // ============================================================================
754
1367
  // Head window reset / topic transition
755
1368
  // ============================================================================
@@ -809,7 +1422,6 @@ export class AutobiographicalStrategy {
809
1422
  config: {
810
1423
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
811
1424
  maxTokens: 1500,
812
- temperature: 0,
813
1425
  },
814
1426
  };
815
1427
  const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
@@ -838,16 +1450,26 @@ export class AutobiographicalStrategy {
838
1450
  // Shared utilities
839
1451
  // ============================================================================
840
1452
  /**
841
- * Get messages in the compressible zone: outside both head window and recent window.
842
- * Returns messages from [0, headStart) [headEnd, recentStart).
1453
+ * Get messages in the compressible zone: outside both head window and
1454
+ * recent window AND not inside any pinned range. Returns messages from
1455
+ * [0, headStart) ∪ [headEnd, recentStart) minus any positions covered
1456
+ * by a pin or document mark.
843
1457
  */
844
1458
  getCompressibleMessages(store) {
845
1459
  const messages = store.getAll();
846
1460
  const headStart = this.getHeadWindowStartIndex(store);
847
1461
  const headEnd = this.getHeadWindowEnd(store);
848
1462
  const recentStart = this.getRecentWindowStart(store);
849
- return messages.slice(0, recentStart)
850
- .filter((_, i) => i < headStart || i >= headEnd);
1463
+ const pinned = this.pinnedPositions(messages);
1464
+ const out = [];
1465
+ for (let i = 0; i < recentStart; i++) {
1466
+ if (i >= headStart && i < headEnd)
1467
+ continue;
1468
+ if (pinned.has(i))
1469
+ continue;
1470
+ out.push(messages[i]);
1471
+ }
1472
+ return out;
851
1473
  }
852
1474
  /**
853
1475
  * Rebuild chunk boundaries based on current messages.
@@ -1012,6 +1634,92 @@ export class AutobiographicalStrategy {
1012
1634
  }
1013
1635
  }
1014
1636
  }
1637
+ /**
1638
+ * Prune tool_use / tool_result blocks in-place:
1639
+ * 1. Truncate `tool_use.input` blocks whose serialized JSON exceeds
1640
+ * `toolUseInputMaxTokens`.
1641
+ * 2. For each tool name, keep only the last N `tool_result` blocks
1642
+ * per `toolResultMaxLastN`; older ones get their `content` replaced
1643
+ * with a brief marker referencing the tool name and how many newer
1644
+ * results exist below.
1645
+ *
1646
+ * Both passes are no-ops when the corresponding config is unset/0.
1647
+ * Pruning runs AFTER selection and orphan-trimming, so it doesn't
1648
+ * affect chunk formation or the recall/pin layout.
1649
+ */
1650
+ pruneToolEntries(entries) {
1651
+ // Pass 1: build toolUseId → toolName map and apply input truncation
1652
+ const toolUseInputCap = this.config.toolUseInputMaxTokens ?? 0;
1653
+ const toolUseIdToName = new Map();
1654
+ for (const entry of entries) {
1655
+ for (let i = 0; i < entry.content.length; i++) {
1656
+ const block = entry.content[i];
1657
+ if (block.type !== 'tool_use')
1658
+ continue;
1659
+ toolUseIdToName.set(block.id, block.name);
1660
+ if (toolUseInputCap > 0) {
1661
+ const inputJson = JSON.stringify(block.input);
1662
+ const inputTokens = Math.ceil(inputJson.length / 4);
1663
+ if (inputTokens > toolUseInputCap) {
1664
+ const keys = Object.keys(block.input).slice(0, 5);
1665
+ entry.content[i] = {
1666
+ ...block,
1667
+ input: {
1668
+ _truncated: true,
1669
+ _originalTokens: inputTokens,
1670
+ _keys: keys,
1671
+ },
1672
+ };
1673
+ }
1674
+ }
1675
+ }
1676
+ }
1677
+ // Pass 2: collect tool_result occurrences per tool name, in order
1678
+ const occurrencesByTool = new Map();
1679
+ for (const entry of entries) {
1680
+ for (let i = 0; i < entry.content.length; i++) {
1681
+ const block = entry.content[i];
1682
+ if (block.type !== 'tool_result')
1683
+ continue;
1684
+ const toolName = toolUseIdToName.get(block.toolUseId);
1685
+ if (!toolName)
1686
+ continue;
1687
+ let arr = occurrencesByTool.get(toolName);
1688
+ if (!arr) {
1689
+ arr = [];
1690
+ occurrencesByTool.set(toolName, arr);
1691
+ }
1692
+ arr.push({ entry, blockIndex: i });
1693
+ }
1694
+ }
1695
+ // Pass 3: apply per-tool max-last-N
1696
+ const cfg = this.config.toolResultMaxLastN;
1697
+ if (cfg === undefined)
1698
+ return;
1699
+ for (const [toolName, occs] of occurrencesByTool) {
1700
+ let limit;
1701
+ if (typeof cfg === 'number')
1702
+ limit = cfg;
1703
+ else if (typeof cfg === 'object')
1704
+ limit = cfg[toolName];
1705
+ if (limit === undefined || limit < 0)
1706
+ continue;
1707
+ const excessCount = occs.length - limit;
1708
+ if (excessCount <= 0)
1709
+ continue;
1710
+ for (let i = 0; i < excessCount; i++) {
1711
+ const { entry, blockIndex } = occs[i];
1712
+ const orig = entry.content[blockIndex];
1713
+ if (orig.type !== 'tool_result')
1714
+ continue;
1715
+ const fresherCount = occs.length - i - 1;
1716
+ entry.content[blockIndex] = {
1717
+ ...orig,
1718
+ content: `[Result truncated — tool '${toolName}' has ${fresherCount} more recent result${fresherCount === 1 ? '' : 's'} below]`,
1719
+ };
1720
+ }
1721
+ }
1722
+ }
1015
1723
  isChunkOldEnough(chunk) {
1016
1724
  return true;
1017
1725
  }