@animalabs/context-manager 0.5.0 → 0.5.2

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 (37) hide show
  1. package/dist/src/context-manager.d.ts.map +1 -1
  2. package/dist/src/context-manager.js +25 -6
  3. package/dist/src/context-manager.js.map +1 -1
  4. package/dist/src/index.d.ts +1 -0
  5. package/dist/src/index.d.ts.map +1 -1
  6. package/dist/src/index.js +2 -0
  7. package/dist/src/index.js.map +1 -1
  8. package/dist/src/message-store.d.ts.map +1 -1
  9. package/dist/src/message-store.js +7 -36
  10. package/dist/src/message-store.js.map +1 -1
  11. package/dist/src/normalize-tool-messages.d.ts +40 -0
  12. package/dist/src/normalize-tool-messages.d.ts.map +1 -0
  13. package/dist/src/normalize-tool-messages.js +69 -0
  14. package/dist/src/normalize-tool-messages.js.map +1 -0
  15. package/dist/src/strategies/autobiographical.d.ts +20 -0
  16. package/dist/src/strategies/autobiographical.d.ts.map +1 -1
  17. package/dist/src/strategies/autobiographical.js +126 -33
  18. package/dist/src/strategies/autobiographical.js.map +1 -1
  19. package/dist/src/types/strategy.d.ts +23 -0
  20. package/dist/src/types/strategy.d.ts.map +1 -1
  21. package/dist/src/types/strategy.js.map +1 -1
  22. package/dist/test/normalize-tool-messages.test.d.ts +2 -0
  23. package/dist/test/normalize-tool-messages.test.d.ts.map +1 -0
  24. package/dist/test/normalize-tool-messages.test.js +108 -0
  25. package/dist/test/normalize-tool-messages.test.js.map +1 -0
  26. package/dist/test/recall-cap.test.d.ts +2 -0
  27. package/dist/test/recall-cap.test.d.ts.map +1 -0
  28. package/dist/test/recall-cap.test.js +103 -0
  29. package/dist/test/recall-cap.test.js.map +1 -0
  30. package/dist/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +2 -2
  32. package/src/context-manager.ts +25 -6
  33. package/src/index.ts +3 -0
  34. package/src/message-store.ts +12 -38
  35. package/src/normalize-tool-messages.ts +71 -0
  36. package/src/strategies/autobiographical.ts +143 -33
  37. package/src/types/strategy.ts +24 -0
@@ -25,6 +25,7 @@ import type { RenderStats } from './types/index.js';
25
25
  import { MessageStore, MessageStoreEvent } from './message-store.js';
26
26
  import { ContextLog } from './context-log.js';
27
27
  import { PassthroughStrategy } from './strategies/passthrough.js';
28
+ import { splitMixedToolMessages } from './normalize-tool-messages.js';
28
29
 
29
30
  /**
30
31
  * Base configuration for ContextManager.
@@ -493,12 +494,30 @@ export class ContextManager {
493
494
  effectiveBudget
494
495
  );
495
496
 
496
- // Convert to NormalizedMessage[]
497
- const messages: NormalizedMessage[] = entries.map((entry) => ({
498
- participant: entry.participant,
499
- content: entry.content,
500
- ...(entry.cacheMarker ? { cacheBreakpoint: true } : {}),
501
- }));
497
+ // Convert to NormalizedMessage[]. We split each entry individually
498
+ // so we know the output-count per input and can re-attach cache
499
+ // markers to the last output of each (matching the marker's
500
+ // "cache up to here" intent).
501
+ //
502
+ // Splitting handles the claude.ai bundled-tool-cycle artifact: a
503
+ // non-user message containing `tool_result` blocks becomes a sequence
504
+ // of agent/user/agent turns so the API accepts it. Already-API-shape
505
+ // messages pass through untouched. See `src/normalize-tool-messages.ts`.
506
+ const messages: NormalizedMessage[] = [];
507
+ for (const entry of entries) {
508
+ const splitParts = splitMixedToolMessages([
509
+ { participant: entry.participant, content: entry.content },
510
+ ]);
511
+ for (let i = 0; i < splitParts.length; i++) {
512
+ const part = splitParts[i];
513
+ const isLast = i === splitParts.length - 1;
514
+ messages.push({
515
+ participant: part.participant,
516
+ content: part.content,
517
+ ...(entry.cacheMarker && isLast ? { cacheBreakpoint: true } : {}),
518
+ });
519
+ }
520
+ }
502
521
 
503
522
  // If no injections, log and return early
504
523
  if (!injections || injections.length === 0) {
package/src/index.ts CHANGED
@@ -13,6 +13,9 @@ export { PassthroughStrategy } from './strategies/passthrough.js';
13
13
  export { AutobiographicalStrategy, type AutobiographicalProgressSnapshot } from './strategies/autobiographical.js';
14
14
  export { KnowledgeStrategy } from './strategies/knowledge.js';
15
15
 
16
+ // Utilities
17
+ export { splitMixedToolMessages } from './normalize-tool-messages.js';
18
+
16
19
  // Types
17
20
  export type {
18
21
  // Message types
@@ -130,7 +130,6 @@ export class MessageStore {
130
130
  // Extract blobs from content
131
131
  const storedContent = this.blobManager.extractBlobs(content);
132
132
 
133
- // Append to Chronicle first to get the record ID
134
133
  const partialInternal = {
135
134
  participant,
136
135
  content: storedContent,
@@ -140,45 +139,22 @@ export class MessageStore {
140
139
  ...(extra ?? {}),
141
140
  };
142
141
 
143
- const record = this.store.appendToStateJson(this.stateId, partialInternal);
142
+ // Single atomic op: chronicle peeks the next record id+sequence under its
143
+ // write lock, splices them into the payload as `id` and `sequence`, and
144
+ // writes one record. The reconstructed state sees a fully-populated
145
+ // StoredMessageInternal, and `branchAt(messageId)` forks at this
146
+ // message's own sequence — exactly the post-fork-visible point.
147
+ const record = this.store.appendToStateJsonWithIdentity(
148
+ this.stateId,
149
+ partialInternal,
150
+ 'id',
151
+ 'sequence',
152
+ );
144
153
  const index = this.length() - 1;
145
154
 
146
- // The append's payload doesn't carry the chronicle record's id /
147
- // sequence — those are only known once the append returns. We patch
148
- // them in via editStateItem below. The patch creates a new chronicle
149
- // record at `record.sequence + 1` (the next sequence; nothing else
150
- // writes between the append and the edit inside this function).
151
- //
152
- // We store the EDIT'S sequence (not the original append's) as the
153
- // canonical "this message is fully readable at and after this seq"
154
- // marker. Why: `ContextManager.branchAt(messageId)` forwards
155
- // `message.sequence` to `chronicle.createBranchAt`, which forks
156
- // visibility at that exact seq. If `sequence` pointed at the original
157
- // append (record.sequence), the new branch would see the pre-patch
158
- // payload (id/sequence undefined). Pointing at the edit's seq
159
- // includes the patched payload in the inherited records — so a
160
- // forked branch sees this message in its fully-established form.
161
- //
162
- // Invariant: this function does exactly ONE chronicle write between
163
- // the append and the edit (the edit itself). If that ever changes,
164
- // the +1 expression must be updated to match (or refactored to
165
- // capture editStateItem's returned record.sequence — which is
166
- // chicken-and-egg here since we need the value inside the payload
167
- // we're writing).
168
- const commitSequence = record.sequence + 1;
169
- const fullInternal: StoredMessageInternal = {
170
- id: record.id,
171
- sequence: commitSequence,
172
- ...partialInternal,
173
- };
174
- this.store.editStateItem(this.stateId, index, Buffer.from(JSON.stringify(fullInternal)));
175
-
176
- // Build full message with ID and sequence from record. The sequence
177
- // we expose is the commit sequence (matches what's stored), so
178
- // `branchAt(messageId)` forks at the right place for any caller.
179
155
  const message: StoredMessage = {
180
156
  id: record.id,
181
- sequence: commitSequence,
157
+ sequence: record.sequence,
182
158
  participant,
183
159
  content, // Original content with inline data
184
160
  metadata,
@@ -187,9 +163,7 @@ export class MessageStore {
187
163
  ...(extra ?? {}),
188
164
  };
189
165
 
190
- // Update index
191
166
  this.idToIndex.set(message.id, index);
192
-
193
167
  this.emit({ type: 'add', message });
194
168
  return message;
195
169
  }
@@ -0,0 +1,71 @@
1
+ import type { ContentBlock } from '@animalabs/membrane';
2
+
3
+ /**
4
+ * Anthropic-API shape requires `tool_use` blocks in assistant turns and
5
+ * `tool_result` blocks in user turns, with each tool_result in the message
6
+ * *immediately following* its tool_use. claude.ai exports bundle the entire
7
+ * tool cycle into a single assistant message, and any downstream consumer
8
+ * that ferries those messages through unchanged hits a 400.
9
+ *
10
+ * This helper walks a list of messages and, for every non-user message
11
+ * containing one or more `tool_result` blocks, splits it so:
12
+ *
13
+ * - blocks before the first tool_result stay under the original participant
14
+ * - each run of consecutive tool_results becomes its own `user` message
15
+ * - blocks after the tool_results go back to the original participant
16
+ *
17
+ * Messages that are already user-side or that don't contain tool_results
18
+ * pass through unchanged. The participant-name check is case-insensitive
19
+ * (matching the convention used elsewhere in CM) so it works regardless
20
+ * of whether the session uses 'user', 'User', or some custom label.
21
+ *
22
+ * Called by:
23
+ * - `ContextManager.compile()` — live session path, prevents the
24
+ * bundled-cycle 400 for already-imported sessions.
25
+ * - `AutobiographicalStrategy.compressChunkHierarchical()` — defense in
26
+ * depth at the compression LLM call site.
27
+ * - `AutobiographicalStrategy.executeMerge()` — same, at the merge call site.
28
+ *
29
+ * For new imports, conhost's `scripts/import-claudeai-export.ts` already
30
+ * splits at ingest time so Chronicle stores hold API-shape messages. This
31
+ * runtime pass handles legacy sessions imported before that fix landed,
32
+ * and acts as a safety net for any future bundling source.
33
+ */
34
+ export function splitMixedToolMessages<T extends { participant: string; content: ContentBlock[] }>(
35
+ messages: readonly T[],
36
+ ): Array<{ participant: string; content: ContentBlock[] }> {
37
+ const out: Array<{ participant: string; content: ContentBlock[] }> = [];
38
+ for (const msg of messages) {
39
+ const isUser = msg.participant.toLowerCase() === 'user';
40
+ if (isUser || !msg.content.some((b) => b.type === 'tool_result')) {
41
+ out.push({ participant: msg.participant, content: msg.content });
42
+ continue;
43
+ }
44
+ let preTool: ContentBlock[] = [];
45
+ let pendingResults: ContentBlock[] = [];
46
+ const flushPre = () => {
47
+ if (preTool.length > 0) {
48
+ out.push({ participant: msg.participant, content: preTool });
49
+ preTool = [];
50
+ }
51
+ };
52
+ const flushResults = () => {
53
+ if (pendingResults.length > 0) {
54
+ out.push({ participant: 'user', content: pendingResults });
55
+ pendingResults = [];
56
+ }
57
+ };
58
+ for (const block of msg.content) {
59
+ if (block.type === 'tool_result') {
60
+ flushPre();
61
+ pendingResults.push(block);
62
+ } else {
63
+ flushResults();
64
+ preTool.push(block);
65
+ }
66
+ }
67
+ flushResults();
68
+ flushPre();
69
+ }
70
+ return out;
71
+ }
@@ -20,6 +20,7 @@ import type {
20
20
  } from '../types/index.js';
21
21
  import { DEFAULT_AUTOBIOGRAPHICAL_CONFIG } from '../types/index.js';
22
22
  import { getSummaryParentId } from '../types/strategy.js';
23
+ import { splitMixedToolMessages } from '../normalize-tool-messages.js';
23
24
  import { appendFileSync, mkdirSync } from 'node:fs';
24
25
  import { dirname } from 'node:path';
25
26
  import { Picker, OverBudgetError, type PickerChunk } from '../adaptive/picker.js';
@@ -747,6 +748,39 @@ export class AutobiographicalStrategy implements ResettableStrategy {
747
748
  * Mark a summary as merged into a higher-level summary, updating the
748
749
  * chronicle copy at the same index. Index is the position in `this.summaries`.
749
750
  */
751
+ /**
752
+ * Token-budget cap for recall-pair summary sets. Walks newest→oldest
753
+ * keeping each summary that still fits; skips (rather than breaks at)
754
+ * a summary that would put us over budget, so a heterogeneous set
755
+ * fills the remaining slots with smaller siblings instead of stopping
756
+ * at the first oversized one. The kept set is re-sorted chronologically.
757
+ *
758
+ * Used by both `compressChunkHierarchical` (the L1 compression prompt
759
+ * recall pairs) and `executeMerge` (the merge prompt recall pairs).
760
+ * Without the cap, both sites grow their recall set linearly with
761
+ * conversation length and overflow the 200k window around the same
762
+ * point — observed empirically at ~chunk 118 in a 4000-message import.
763
+ *
764
+ * Per-summary +50 token overhead accounts for the "[CM] Recall memory
765
+ * <id>." question turn that wraps each recall body. Rough but defensive.
766
+ */
767
+ protected capRecallPairs(
768
+ summariesChronological: SummaryEntry[],
769
+ maxTokens: number,
770
+ ): { kept: SummaryEntry[]; keptTokens: number } {
771
+ const kept: SummaryEntry[] = [];
772
+ let total = 0;
773
+ for (let i = summariesChronological.length - 1; i >= 0; i--) {
774
+ const s = summariesChronological[i]!;
775
+ const est = (s.tokens ?? Math.ceil(s.content.length / 4)) + 50;
776
+ if (total + est > maxTokens) continue;
777
+ kept.push(s);
778
+ total += est;
779
+ }
780
+ kept.reverse();
781
+ return { kept, keptTokens: total };
782
+ }
783
+
750
784
  protected setMergedInto(entry: SummaryEntry, mergedIntoId: string): void {
751
785
  entry.mergedInto = mergedIntoId;
752
786
  if (!this.store) return;
@@ -1507,11 +1541,14 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1507
1541
 
1508
1542
  // Build the KV-preserving prompt per hermes-autobio spec:
1509
1543
  //
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").
1544
+ // 1. Prior summaries — narrativized as CM-asks / agent-recalls
1545
+ // pairs, in source order. The unmerged frontier of the
1546
+ // summary forest: any summary that has not yet been merged
1547
+ // into a higher level. After merges run, the L_{k+1} replaces
1548
+ // its L_k children — using the children plus their parent
1549
+ // doubles the prompt size unboundedly.
1513
1550
  // 2. Head — raw messages before the chunk that aren't already
1514
- // represented by a prior L1.
1551
+ // represented by a prior summary.
1515
1552
  // 3. Marker — in-band signal that a memory is about to form.
1516
1553
  // 4. Chunk — raw messages being compressed, as the agent
1517
1554
  // experienced them.
@@ -1522,13 +1559,42 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1522
1559
  // as-of framing of memory formation.
1523
1560
  const llmMessages: Array<{ participant: string; content: ContentBlock[] }> = [];
1524
1561
 
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)
1562
+ // ---- 1. Prior recall pairs ----
1563
+ // Filter to the unmerged frontier: any summary whose `mergedInto`
1564
+ // is unset. After merge, the children's mergedInto points at the
1565
+ // parent and the parent stands alone with that source range. The
1566
+ // original "ALL L1s regardless of merge state" rule was a fidelity
1567
+ // optimization that scales catastrophically: a 4000-message import
1568
+ // converged to ~500 L1s that never aged out, blowing the 200k
1569
+ // window around chunk 118.
1570
+ const priorSummaries = this.summaries
1571
+ .filter((s) => !s.mergedInto)
1530
1572
  .sort((a, b) => a.sourceRange.first.localeCompare(b.sourceRange.first));
1531
- for (const s of priorL1s) {
1573
+
1574
+ // Token-budget cap (see capRecallPairs). Defense-in-depth: even with
1575
+ // merged exclusion the unmerged frontier can be large at extreme scale.
1576
+ const recallBudget = this.config.compressionRecallBudgetTokens ?? 100_000;
1577
+ const { kept: keptSummaries, keptTokens: recallTokens } = this.capRecallPairs(
1578
+ priorSummaries,
1579
+ recallBudget,
1580
+ );
1581
+ if (keptSummaries.length < priorSummaries.length) {
1582
+ const dropped = priorSummaries.length - keptSummaries.length;
1583
+ console.warn(
1584
+ `autobio: compression recall-pair budget capped (${keptSummaries.length}/${priorSummaries.length} summaries kept, ` +
1585
+ `~${recallTokens} tokens, budget ${recallBudget}; ${dropped} oldest dropped this compression).`,
1586
+ );
1587
+ logCompressionCall({
1588
+ event: 'recall-budget-capped',
1589
+ site: 'compression',
1590
+ kept: keptSummaries.length,
1591
+ total: priorSummaries.length,
1592
+ tokens: recallTokens,
1593
+ budgetTokens: recallBudget,
1594
+ });
1595
+ }
1596
+
1597
+ for (const s of keptSummaries) {
1532
1598
  llmMessages.push({
1533
1599
  participant: 'Context Manager',
1534
1600
  content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
@@ -1567,19 +1633,23 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1567
1633
  }
1568
1634
 
1569
1635
  // 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.
1636
+ // represented by any summary — usually empty in adaptive-resolution
1637
+ // mode, since chunking proceeds contiguously and summaries cover
1638
+ // everything up to the chunk being processed. Uses the full
1639
+ // priorSummaries set (not the budget-capped keptSummaries) because
1640
+ // the dedup question is "is this raw message covered by *any* live
1641
+ // summary?" — a budget-dropped summary doesn't make the underlying
1642
+ // raw messages reappear.
1573
1643
  const chunkFirstId = chunk.messages[0]?.id;
1574
1644
  if (chunkFirstId) {
1575
- const priorL1MessageIds = new Set<string>();
1576
- for (const s of priorL1s) {
1577
- for (const id of s.sourceIds) priorL1MessageIds.add(id);
1645
+ const priorSummaryMessageIds = new Set<string>();
1646
+ for (const s of priorSummaries) {
1647
+ for (const id of s.sourceIds) priorSummaryMessageIds.add(id);
1578
1648
  }
1579
1649
  const chunkStartIdx = allMessages.findIndex((m) => m.id === chunkFirstId);
1580
1650
  for (let i = headEndIdx; i < chunkStartIdx && i < allMessages.length; i++) {
1581
1651
  const m = allMessages[i];
1582
- if (priorL1MessageIds.has(m.id)) continue;
1652
+ if (priorSummaryMessageIds.has(m.id)) continue;
1583
1653
  llmMessages.push({ participant: m.participant, content: m.content });
1584
1654
  }
1585
1655
  }
@@ -1613,8 +1683,15 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1613
1683
  content: [{ type: 'text', text: instructionText }],
1614
1684
  });
1615
1685
 
1686
+ // Split any bundled tool_use+tool_result cycles in non-user turns into
1687
+ // separate API-shape messages. claude.ai-imported sessions carry these
1688
+ // bundles (a tool_result in an assistant message rejects the request);
1689
+ // for fresh imports the conhost importer splits at ingest time, but
1690
+ // already-warmed sessions hit this path. See `normalize-tool-messages.ts`.
1691
+ const split = splitMixedToolMessages(llmMessages);
1692
+
1616
1693
  // Collapse consecutive same-participant messages for API compliance
1617
- const collapsed = this.collapseConsecutiveMessages(llmMessages);
1694
+ const collapsed = this.collapseConsecutiveMessages(split);
1618
1695
 
1619
1696
  // NO system prompt. The agent's identity is established by the head
1620
1697
  // (the actual conversation opening — user message + agent reply that
@@ -1688,7 +1765,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1688
1765
  metadata: {
1689
1766
  chunk_message_ids: chunk.messages.map((m) => m.id),
1690
1767
  chunk_size: chunk.messages.length,
1691
- prior_l1_count: priorL1s.length,
1768
+ prior_summary_count: priorSummaries.length,
1769
+ prior_summary_count_kept: keptSummaries.length,
1770
+ prior_summary_tokens: recallTokens,
1692
1771
  has_doc_context: docContext !== null,
1693
1772
  doc_context: docContext,
1694
1773
  target_tokens: targetTokens,
@@ -1906,15 +1985,19 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1906
1985
  llmMessages.push({ participant: m.participant, content: m.content });
1907
1986
  }
1908
1987
 
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)
1988
+ // ---- 1b. PRIOR RECALL PAIRS (chronologically before merge range) ----
1989
+ // The unmerged frontier of summaries whose source range is before the
1990
+ // merge range and which aren't part of the merge tree. Originally this
1991
+ // was filtered to `level === 1` (the "L1 fidelity for prior content"
1992
+ // intent) but at 4000+ messages that produces hundreds of L1s and
1993
+ // overflows the model window. Switching to the unmerged frontier
1994
+ // (`!mergedInto`) lets a merged L1 drop out in favour of its L2/L3
1995
+ // parent — the same rule used everywhere else and now in
1996
+ // `compressChunkHierarchical`. The cap below is the defense-in-depth.
1997
+ const priorSummariesAll = this.summaries
1998
+ .filter((s) => !s.mergedInto)
1914
1999
  .filter((s) => {
1915
- // Exclude any L1 that's an ancestor of our merge target
1916
2000
  for (const lid of s.sourceIds) if (sourceLeafIds.has(lid)) return false;
1917
- // Include only if it starts strictly before the merge range
1918
2001
  const firstIdx = allMessages.findIndex((m) => m.id === s.sourceRange.first);
1919
2002
  return firstIdx >= 0 && (mergeStartIdx < 0 || firstIdx < mergeStartIdx);
1920
2003
  })
@@ -1924,12 +2007,37 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1924
2007
  return ai - bi;
1925
2008
  });
1926
2009
 
1927
- const priorL1MessageIds = new Set<MessageId>();
1928
- for (const s of priorL1s) {
1929
- for (const id of s.sourceIds) priorL1MessageIds.add(id);
2010
+ const mergeRecallBudget = this.config.compressionRecallBudgetTokens ?? 100_000;
2011
+ const { kept: keptPriorSummaries, keptTokens: mergeRecallTokens } = this.capRecallPairs(
2012
+ priorSummariesAll,
2013
+ mergeRecallBudget,
2014
+ );
2015
+ if (keptPriorSummaries.length < priorSummariesAll.length) {
2016
+ const dropped = priorSummariesAll.length - keptPriorSummaries.length;
2017
+ console.warn(
2018
+ `autobio: merge recall-pair budget capped (${keptPriorSummaries.length}/${priorSummariesAll.length} summaries kept, ` +
2019
+ `~${mergeRecallTokens} tokens, budget ${mergeRecallBudget}; ${dropped} oldest dropped this merge).`,
2020
+ );
2021
+ logCompressionCall({
2022
+ event: 'recall-budget-capped',
2023
+ site: 'merge',
2024
+ targetLevel,
2025
+ kept: keptPriorSummaries.length,
2026
+ total: priorSummariesAll.length,
2027
+ tokens: mergeRecallTokens,
2028
+ budgetTokens: mergeRecallBudget,
2029
+ });
2030
+ }
2031
+
2032
+ // The full unmerged-frontier set covers what's "compressed somewhere"
2033
+ // for the raw-middle dedup below — a budget-dropped recall pair
2034
+ // doesn't make its underlying raw messages reappear.
2035
+ const priorSummaryMessageIds = new Set<MessageId>();
2036
+ for (const s of priorSummariesAll) {
2037
+ for (const id of s.sourceIds) priorSummaryMessageIds.add(id);
1930
2038
  }
1931
2039
 
1932
- for (const s of priorL1s) {
2040
+ for (const s of keptPriorSummaries) {
1933
2041
  llmMessages.push({
1934
2042
  participant: 'Context Manager',
1935
2043
  content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
@@ -1941,12 +2049,12 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1941
2049
  }
1942
2050
 
1943
2051
  // 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.
2052
+ // range that aren't covered by a prior summary or the merge tree.
1945
2053
  // Usually empty (chunking is contiguous).
1946
2054
  if (mergeStartIdx >= 0) {
1947
2055
  for (let i = headEndIdx; i < mergeStartIdx; i++) {
1948
2056
  const m = allMessages[i];
1949
- if (priorL1MessageIds.has(m.id)) continue;
2057
+ if (priorSummaryMessageIds.has(m.id)) continue;
1950
2058
  if (sourceLeafIds.has(m.id)) continue;
1951
2059
  llmMessages.push({ participant: m.participant, content: m.content });
1952
2060
  }
@@ -2032,7 +2140,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2032
2140
  }],
2033
2141
  });
2034
2142
 
2035
- const collapsed = this.collapseConsecutiveMessages(llmMessages);
2143
+ // Same bundled-tool-cycle defense as compressChunkHierarchical.
2144
+ const split = splitMixedToolMessages(llmMessages);
2145
+ const collapsed = this.collapseConsecutiveMessages(split);
2036
2146
 
2037
2147
  // NO system prompt — identity is established by the head window
2038
2148
  // (present at the start of llmMessages above) and by the prior
@@ -337,6 +337,30 @@ export interface AutobiographicalConfig {
337
337
  /** Token budget for L1 summaries in select() (default: 30000) */
338
338
  l1BudgetTokens?: number;
339
339
 
340
+ /**
341
+ * Cap on the total tokens of recall-pair prior-summary content
342
+ * included in each LLM request that builds recall pairs:
343
+ *
344
+ * - L1 chunk compression (`compressChunkHierarchical`)
345
+ * - L_n merges (`executeMerge`)
346
+ *
347
+ * Defends against the case where the unmerged frontier itself is
348
+ * large enough to overflow the API window. Walks newest-first so
349
+ * proximate context survives; the kept set is then re-sorted
350
+ * chronologically. Each summary takes (its content tokens + 50 for
351
+ * the wrapping "[CM] Recall memory <id>." question).
352
+ *
353
+ * Default 100000 — chosen so that even an L_n merge (which packs
354
+ * recall + head + expanded target + instruction into one request)
355
+ * fits inside a 200k window. For larger context models or
356
+ * shallower hierarchies, raise this; on Sonnet/Opus default windows,
357
+ * the practical ceiling is roughly 130k.
358
+ *
359
+ * Set to Infinity to disable the cap and surface overflow as a
360
+ * 400 from the API rather than silently dropping oldest memories.
361
+ */
362
+ compressionRecallBudgetTokens?: number;
363
+
340
364
  /**
341
365
  * When true (default), each selected summary emits as its own positioned
342
366
  * Q/A recall pair, sorted chronologically by source range. When false,