@animalabs/context-manager 0.5.1 → 0.5.3

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 (45) 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 +73 -0
  12. package/dist/src/normalize-tool-messages.d.ts.map +1 -0
  13. package/dist/src/normalize-tool-messages.js +128 -0
  14. package/dist/src/normalize-tool-messages.js.map +1 -0
  15. package/dist/src/strategies/autobiographical.d.ts +53 -0
  16. package/dist/src/strategies/autobiographical.d.ts.map +1 -1
  17. package/dist/src/strategies/autobiographical.js +178 -43
  18. package/dist/src/strategies/autobiographical.js.map +1 -1
  19. package/dist/src/types/strategy.d.ts +17 -6
  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/chunker-tool-boundary.test.d.ts +12 -0
  23. package/dist/test/chunker-tool-boundary.test.d.ts.map +1 -0
  24. package/dist/test/chunker-tool-boundary.test.js +89 -0
  25. package/dist/test/chunker-tool-boundary.test.js.map +1 -0
  26. package/dist/test/compression-shape-invariants.test.d.ts +22 -0
  27. package/dist/test/compression-shape-invariants.test.d.ts.map +1 -0
  28. package/dist/test/compression-shape-invariants.test.js +312 -0
  29. package/dist/test/compression-shape-invariants.test.js.map +1 -0
  30. package/dist/test/normalize-tool-messages.test.d.ts +2 -0
  31. package/dist/test/normalize-tool-messages.test.d.ts.map +1 -0
  32. package/dist/test/normalize-tool-messages.test.js +168 -0
  33. package/dist/test/normalize-tool-messages.test.js.map +1 -0
  34. package/dist/test/recall-cap.test.d.ts +2 -0
  35. package/dist/test/recall-cap.test.d.ts.map +1 -0
  36. package/dist/test/recall-cap.test.js +103 -0
  37. package/dist/test/recall-cap.test.js.map +1 -0
  38. package/dist/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +2 -2
  40. package/src/context-manager.ts +25 -6
  41. package/src/index.ts +3 -0
  42. package/src/message-store.ts +12 -38
  43. package/src/normalize-tool-messages.ts +132 -0
  44. package/src/strategies/autobiographical.ts +194 -41
  45. package/src/types/strategy.ts +17 -6
@@ -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,132 @@
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
+ }
72
+
73
+ /**
74
+ * Strip `tool_use` blocks whose IDs have no matching `tool_result` anywhere
75
+ * in the message list, and `tool_result` blocks whose IDs have no matching
76
+ * `tool_use`. If stripping leaves a message with no blocks, replace its
77
+ * content with a placeholder text block so the message stays structurally
78
+ * valid (collapse/rendering paths assume non-empty content).
79
+ *
80
+ * Why this exists: the Anthropic API requires every `tool_use` to be
81
+ * followed by its `tool_result` in the immediately-next message (and every
82
+ * `tool_result` to follow its `tool_use`). Two ways this becomes a problem
83
+ * in compression:
84
+ *
85
+ * - chunk boundaries: the chunker can cut a cycle, leaving a `tool_use`
86
+ * at the tail of chunk N and its `tool_result` at the head of N+1.
87
+ * `rebuildChunks` defers closing on a `tool_use` to avoid this, but
88
+ * can't help when the `tool_use` is the very last message in the
89
+ * store (no next message to ride along).
90
+ * - any future call site that builds a request from raw messages without
91
+ * also handling cycle pairing.
92
+ *
93
+ * The placeholder is intentionally generic ("[tool call omitted]") rather
94
+ * than naming a specific cause: this function fires for the chunk-boundary
95
+ * case AND the very-last-message case AND any future not-yet-anticipated
96
+ * source of unpaired blocks. Anything more specific would mislead the
97
+ * debugger reading the compressed conversation later.
98
+ */
99
+ export function stripUnpairedToolBlocks<T extends { participant: string; content: ContentBlock[] }>(
100
+ messages: readonly T[],
101
+ ): Array<{ participant: string; content: ContentBlock[] }> {
102
+ const useIds = new Set<string>();
103
+ const resultIds = new Set<string>();
104
+ for (const msg of messages) {
105
+ for (const block of msg.content) {
106
+ if (block.type === 'tool_use') useIds.add((block as { id: string }).id);
107
+ else if (block.type === 'tool_result') {
108
+ resultIds.add((block as { toolUseId: string }).toolUseId);
109
+ }
110
+ }
111
+ }
112
+ return messages.map((msg) => {
113
+ const trimmed = msg.content.filter((block) => {
114
+ if (block.type === 'tool_use') {
115
+ return resultIds.has((block as { id: string }).id);
116
+ }
117
+ if (block.type === 'tool_result') {
118
+ return useIds.has((block as { toolUseId: string }).toolUseId);
119
+ }
120
+ return true;
121
+ });
122
+ if (trimmed.length === msg.content.length) {
123
+ return { participant: msg.participant, content: msg.content };
124
+ }
125
+ return {
126
+ participant: msg.participant,
127
+ content: trimmed.length > 0
128
+ ? trimmed
129
+ : [{ type: 'text', text: '[tool call omitted]' }],
130
+ };
131
+ });
132
+ }
@@ -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, stripUnpairedToolBlocks } 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;
@@ -1537,33 +1571,22 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1537
1571
  .filter((s) => !s.mergedInto)
1538
1572
  .sort((a, b) => a.sourceRange.first.localeCompare(b.sourceRange.first));
1539
1573
 
1540
- // Token-budget cap on recall pairs as defense-in-depth. With merged
1541
- // exclusion the set is bounded by `mergeThreshold^depth`, but a
1542
- // conversation with thousands of unmerged top-level summaries can
1543
- // still overflow. Newest-first selection so proximate context is
1544
- // preferred; the kept set is re-sorted chronologically below. We
1545
- // always keep at least one summary even if it's larger than the
1546
- // budget — otherwise a single oversized summary would leave the
1547
- // model with no compressed context at all.
1548
- const recallBudget = this.config.compressionRecallBudgetTokens ?? 150_000;
1549
- const keptSummaries: SummaryEntry[] = [];
1550
- let recallTokens = 0;
1551
- for (let i = priorSummaries.length - 1; i >= 0; i--) {
1552
- const s = priorSummaries[i]!;
1553
- const approxTokens = Math.ceil(s.content.length / 4);
1554
- if (recallTokens + approxTokens > recallBudget && keptSummaries.length > 0) break;
1555
- keptSummaries.push(s);
1556
- recallTokens += approxTokens;
1557
- }
1558
- keptSummaries.reverse();
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
+ );
1559
1581
  if (keptSummaries.length < priorSummaries.length) {
1560
1582
  const dropped = priorSummaries.length - keptSummaries.length;
1561
1583
  console.warn(
1562
- `autobio: recall-pair budget capped (${keptSummaries.length}/${priorSummaries.length} summaries kept, ` +
1584
+ `autobio: compression recall-pair budget capped (${keptSummaries.length}/${priorSummaries.length} summaries kept, ` +
1563
1585
  `~${recallTokens} tokens, budget ${recallBudget}; ${dropped} oldest dropped this compression).`,
1564
1586
  );
1565
1587
  logCompressionCall({
1566
1588
  event: 'recall-budget-capped',
1589
+ site: 'compression',
1567
1590
  kept: keptSummaries.length,
1568
1591
  total: priorSummaries.length,
1569
1592
  tokens: recallTokens,
@@ -1619,9 +1642,18 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1619
1642
  // raw messages reappear.
1620
1643
  const chunkFirstId = chunk.messages[0]?.id;
1621
1644
  if (chunkFirstId) {
1622
- const priorSummaryMessageIds = new Set<string>();
1645
+ // Expand summary sourceIds down to leaf message IDs. An L2 in
1646
+ // `priorSummaries` has L1 IDs in its sourceIds, not message IDs;
1647
+ // a flat walk would miss every message it transitively covers.
1648
+ // (Bug 10 — same shape as executeMerge.)
1649
+ const summariesById = new Map<string, SummaryEntry>();
1650
+ for (const s of this.summaries) summariesById.set(s.id, s);
1651
+ const priorSummaryMessageIds = new Set<MessageId>();
1652
+ for (const s of this.summaries) {
1653
+ if (s.level === 1) this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
1654
+ }
1623
1655
  for (const s of priorSummaries) {
1624
- for (const id of s.sourceIds) priorSummaryMessageIds.add(id);
1656
+ this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
1625
1657
  }
1626
1658
  const chunkStartIdx = allMessages.findIndex((m) => m.id === chunkFirstId);
1627
1659
  for (let i = headEndIdx; i < chunkStartIdx && i < allMessages.length; i++) {
@@ -1660,8 +1692,21 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1660
1692
  content: [{ type: 'text', text: instructionText }],
1661
1693
  });
1662
1694
 
1695
+ // Split any bundled tool_use+tool_result cycles in non-user turns into
1696
+ // separate API-shape messages. claude.ai-imported sessions carry these
1697
+ // bundles (a tool_result in an assistant message rejects the request);
1698
+ // for fresh imports the conhost importer splits at ingest time, but
1699
+ // already-warmed sessions hit this path. See `normalize-tool-messages.ts`.
1700
+ const split = splitMixedToolMessages(llmMessages);
1701
+
1663
1702
  // Collapse consecutive same-participant messages for API compliance
1664
- const collapsed = this.collapseConsecutiveMessages(llmMessages);
1703
+ const collapsed = this.collapseConsecutiveMessages(split);
1704
+
1705
+ // Defense in depth against chunk boundaries that cut a tool cycle
1706
+ // (rebuildChunks tries to avoid this, but covers only the most common
1707
+ // case). The API rejects any tool_use that isn't immediately followed
1708
+ // by its tool_result, and any tool_result that doesn't follow a use.
1709
+ const cleaned = stripUnpairedToolBlocks(collapsed);
1665
1710
 
1666
1711
  // NO system prompt. The agent's identity is established by the head
1667
1712
  // (the actual conversation opening — user message + agent reply that
@@ -1672,7 +1717,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1672
1717
  // structural one carried by the conversation itself. Anchoring
1673
1718
  // identity by the chronicle's actual head is more honest.
1674
1719
  const request: NormalizedRequest = {
1675
- messages: collapsed.map(m => ({ participant: m.participant, content: m.content })),
1720
+ messages: cleaned.map(m => ({ participant: m.participant, content: m.content })),
1676
1721
  config: {
1677
1722
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
1678
1723
  maxTokens: Math.round(targetTokens * 1.5),
@@ -1723,7 +1768,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1723
1768
  logCompressionCall({
1724
1769
  operation: 'compress_l1',
1725
1770
  system: null,
1726
- messages: collapsed.map((m) => ({
1771
+ messages: cleaned.map((m) => ({
1727
1772
  participant: m.participant,
1728
1773
  // Flatten content for logging — store text only; binary content
1729
1774
  // would bloat the log and isn't typical in compression input.
@@ -1955,15 +2000,19 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1955
2000
  llmMessages.push({ participant: m.participant, content: m.content });
1956
2001
  }
1957
2002
 
1958
- // ---- 1b. PRIOR L1 RECALL PAIRS (chronologically before merge range) ----
1959
- // L1s whose entire source range is before the merge range and that
1960
- // aren't part of the merge tree. Sort by source position.
1961
- const priorL1s = this.summaries
1962
- .filter((s) => s.level === 1)
2003
+ // ---- 1b. PRIOR RECALL PAIRS (chronologically before merge range) ----
2004
+ // The unmerged frontier of summaries whose source range is before the
2005
+ // merge range and which aren't part of the merge tree. Originally this
2006
+ // was filtered to `level === 1` (the "L1 fidelity for prior content"
2007
+ // intent) but at 4000+ messages that produces hundreds of L1s and
2008
+ // overflows the model window. Switching to the unmerged frontier
2009
+ // (`!mergedInto`) lets a merged L1 drop out in favour of its L2/L3
2010
+ // parent — the same rule used everywhere else and now in
2011
+ // `compressChunkHierarchical`. The cap below is the defense-in-depth.
2012
+ const priorSummariesAll = this.summaries
2013
+ .filter((s) => !s.mergedInto)
1963
2014
  .filter((s) => {
1964
- // Exclude any L1 that's an ancestor of our merge target
1965
2015
  for (const lid of s.sourceIds) if (sourceLeafIds.has(lid)) return false;
1966
- // Include only if it starts strictly before the merge range
1967
2016
  const firstIdx = allMessages.findIndex((m) => m.id === s.sourceRange.first);
1968
2017
  return firstIdx >= 0 && (mergeStartIdx < 0 || firstIdx < mergeStartIdx);
1969
2018
  })
@@ -1973,12 +2022,47 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1973
2022
  return ai - bi;
1974
2023
  });
1975
2024
 
1976
- const priorL1MessageIds = new Set<MessageId>();
1977
- for (const s of priorL1s) {
1978
- for (const id of s.sourceIds) priorL1MessageIds.add(id);
2025
+ const mergeRecallBudget = this.config.compressionRecallBudgetTokens ?? 100_000;
2026
+ const { kept: keptPriorSummaries, keptTokens: mergeRecallTokens } = this.capRecallPairs(
2027
+ priorSummariesAll,
2028
+ mergeRecallBudget,
2029
+ );
2030
+ if (keptPriorSummaries.length < priorSummariesAll.length) {
2031
+ const dropped = priorSummariesAll.length - keptPriorSummaries.length;
2032
+ console.warn(
2033
+ `autobio: merge recall-pair budget capped (${keptPriorSummaries.length}/${priorSummariesAll.length} summaries kept, ` +
2034
+ `~${mergeRecallTokens} tokens, budget ${mergeRecallBudget}; ${dropped} oldest dropped this merge).`,
2035
+ );
2036
+ logCompressionCall({
2037
+ event: 'recall-budget-capped',
2038
+ site: 'merge',
2039
+ targetLevel,
2040
+ kept: keptPriorSummaries.length,
2041
+ total: priorSummariesAll.length,
2042
+ tokens: mergeRecallTokens,
2043
+ budgetTokens: mergeRecallBudget,
2044
+ });
1979
2045
  }
1980
2046
 
1981
- for (const s of priorL1s) {
2047
+ // The full unmerged-frontier set covers what's "compressed somewhere"
2048
+ // for the raw-middle dedup below — a budget-dropped recall pair
2049
+ // doesn't make its underlying raw messages reappear.
2050
+ //
2051
+ // Critical: `sourceIds` on an L2+ summary points at L1 IDs, not raw
2052
+ // message IDs. The dedup happens against raw message IDs, so we must
2053
+ // recursively expand each summary down to its leaf message IDs.
2054
+ // Without this, every message under any L2 leaks back in as raw text
2055
+ // (Bug 10: 525-message merge requests on a 4234-msg conversation).
2056
+ // Also expand merged L1s as defense in depth.
2057
+ const priorSummaryMessageIds = new Set<MessageId>();
2058
+ for (const s of this.summaries) {
2059
+ if (s.level === 1) this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
2060
+ }
2061
+ for (const s of priorSummariesAll) {
2062
+ this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
2063
+ }
2064
+
2065
+ for (const s of keptPriorSummaries) {
1982
2066
  llmMessages.push({
1983
2067
  participant: 'Context Manager',
1984
2068
  content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
@@ -1990,12 +2074,12 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1990
2074
  }
1991
2075
 
1992
2076
  // Raw middle: any messages between the head window and the merge
1993
- // range that aren't covered by a prior L1 or the merge tree.
2077
+ // range that aren't covered by a prior summary or the merge tree.
1994
2078
  // Usually empty (chunking is contiguous).
1995
2079
  if (mergeStartIdx >= 0) {
1996
2080
  for (let i = headEndIdx; i < mergeStartIdx; i++) {
1997
2081
  const m = allMessages[i];
1998
- if (priorL1MessageIds.has(m.id)) continue;
2082
+ if (priorSummaryMessageIds.has(m.id)) continue;
1999
2083
  if (sourceLeafIds.has(m.id)) continue;
2000
2084
  llmMessages.push({ participant: m.participant, content: m.content });
2001
2085
  }
@@ -2081,13 +2165,16 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2081
2165
  }],
2082
2166
  });
2083
2167
 
2084
- const collapsed = this.collapseConsecutiveMessages(llmMessages);
2168
+ // Same bundled-tool-cycle defense as compressChunkHierarchical.
2169
+ const split = splitMixedToolMessages(llmMessages);
2170
+ const collapsed = this.collapseConsecutiveMessages(split);
2171
+ const cleaned = stripUnpairedToolBlocks(collapsed);
2085
2172
 
2086
2173
  // NO system prompt — identity is established by the head window
2087
2174
  // (present at the start of llmMessages above) and by the prior
2088
2175
  // recall pairs. Same rationale as compressChunkHierarchical.
2089
2176
  const request: NormalizedRequest = {
2090
- messages: collapsed.map(m => ({ participant: m.participant, content: m.content })),
2177
+ messages: cleaned.map(m => ({ participant: m.participant, content: m.content })),
2091
2178
  config: {
2092
2179
  model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
2093
2180
  maxTokens: Math.round(targetTokens * 1.5),
@@ -2147,7 +2234,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2147
2234
  logCompressionCall({
2148
2235
  operation: `merge_l${targetLevel}`,
2149
2236
  system: null,
2150
- messages: collapsed.map((m) => ({
2237
+ messages: cleaned.map((m) => ({
2151
2238
  participant: m.participant,
2152
2239
  text: m.content
2153
2240
  .filter((b: ContentBlock) => b.type === 'text')
@@ -2733,6 +2820,48 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2733
2820
  return current;
2734
2821
  }
2735
2822
 
2823
+ /**
2824
+ * Recursively expand a summary's `sourceIds` down to the leaf message IDs
2825
+ * it covers, adding each leaf into `out`.
2826
+ *
2827
+ * Required because `SummaryEntry.sourceIds` are level-relative: an L1's
2828
+ * sourceIds are raw message IDs (sourceLevel=0), but an L2's sourceIds
2829
+ * are L1 IDs (sourceLevel=1), and so on. Any dedup that walks `sourceIds`
2830
+ * directly only works for L1s — once L2+ summaries enter the picture
2831
+ * (which happens as soon as `mergeThreshold` L1s accumulate during
2832
+ * interleaved compression+merge ticks), the dedup silently fails and
2833
+ * already-summarized messages leak back into the request as raw text.
2834
+ * That's how Bug 10 produced 200k+ token merge prompts on a 4234-msg
2835
+ * import.
2836
+ *
2837
+ * Callers should also expand merged L1s (not just the unmerged frontier)
2838
+ * as defense in depth — a stale `mergedInto` pointer or a partially
2839
+ * applied merge shouldn't surface raw messages.
2840
+ *
2841
+ * `visited` guards against pathological cycles in the summary graph (a
2842
+ * corrupted store or a future merge regression that lets a summary
2843
+ * reference itself). The hierarchy is a DAG by construction, but a
2844
+ * stack overflow during compression — exactly when the safety net is
2845
+ * supposed to save the session — is too steep a price for trusting that.
2846
+ */
2847
+ protected expandSummaryToLeafMessageIds(
2848
+ summary: SummaryEntry,
2849
+ summariesById: ReadonlyMap<string, SummaryEntry>,
2850
+ out: Set<MessageId>,
2851
+ visited: Set<string> = new Set(),
2852
+ ): void {
2853
+ if (visited.has(summary.id)) return;
2854
+ visited.add(summary.id);
2855
+ if (summary.sourceLevel === 0) {
2856
+ for (const id of summary.sourceIds) out.add(id);
2857
+ return;
2858
+ }
2859
+ for (const childId of summary.sourceIds) {
2860
+ const child = summariesById.get(childId);
2861
+ if (child) this.expandSummaryToLeafMessageIds(child, summariesById, out, visited);
2862
+ }
2863
+ }
2864
+
2736
2865
  // ============================================================================
2737
2866
  // Hierarchical (threshold-driven) path
2738
2867
  // ============================================================================
@@ -3349,6 +3478,17 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3349
3478
  currentTokens >= this.config.targetChunkTokens &&
3350
3479
  currentChunk.length >= 4;
3351
3480
 
3481
+ // Don't close a chunk on a message containing a tool_use block —
3482
+ // the matching tool_result lives in the immediately-following user
3483
+ // message, and the Anthropic API rejects a request where a tool_use
3484
+ // isn't immediately followed by its tool_result. Defer the close
3485
+ // by one iteration so the result rides along in the same chunk.
3486
+ // The stripUnpairedToolBlocks runtime pass is a safety net for the
3487
+ // rare case where a tool_use is the very last message in the store.
3488
+ if (shouldClose && this.lastMessageContainsToolUse(currentChunk)) {
3489
+ continue;
3490
+ }
3491
+
3352
3492
  if (shouldClose) {
3353
3493
  const chunk = this.createChunk(
3354
3494
  this.chunks.length,
@@ -3387,6 +3527,19 @@ export class AutobiographicalStrategy implements ResettableStrategy {
3387
3527
  }
3388
3528
  }
3389
3529
 
3530
+ /**
3531
+ * Returns true if the last message in the chunk-in-progress contains a
3532
+ * `tool_use` block. Used by `rebuildChunks` to defer chunk closure until
3533
+ * the matching `tool_result` (in the immediately-following user message)
3534
+ * is pulled into the same chunk. See `stripUnpairedToolBlocks` for the
3535
+ * runtime safety net.
3536
+ */
3537
+ protected lastMessageContainsToolUse(chunk: StoredMessage[]): boolean {
3538
+ const last = chunk[chunk.length - 1];
3539
+ if (!last) return false;
3540
+ return last.content.some((b) => b.type === 'tool_use');
3541
+ }
3542
+
3390
3543
  protected createChunk(
3391
3544
  index: number,
3392
3545
  startIndex: number,
@@ -339,14 +339,25 @@ export interface AutobiographicalConfig {
339
339
 
340
340
  /**
341
341
  * Cap on the total tokens of recall-pair prior-summary content
342
- * included in each chunk compression request (default: 150000).
342
+ * included in each LLM request that builds recall pairs:
343
+ *
344
+ * - L1 chunk compression (`compressChunkHierarchical`)
345
+ * - L_n merges (`executeMerge`)
343
346
  *
344
347
  * Defends against the case where the unmerged frontier itself is
345
- * large enough to overflow the API window. The cap walks summaries
346
- * newest-first so proximate context survives; the kept set is then
347
- * re-sorted chronologically. Set higher (or to Infinity) if you want
348
- * to surface overflow via a 400 rather than silently drop oldest
349
- * memories from the compression context.
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.
350
361
  */
351
362
  compressionRecallBudgetTokens?: number;
352
363