@animalabs/context-manager 0.5.1 → 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 +83 -36
  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/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 +96 -35
  37. package/src/types/strategy.ts +17 -6
@@ -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;
@@ -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,
@@ -1660,8 +1683,15 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1660
1683
  content: [{ type: 'text', text: instructionText }],
1661
1684
  });
1662
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
+
1663
1693
  // Collapse consecutive same-participant messages for API compliance
1664
- const collapsed = this.collapseConsecutiveMessages(llmMessages);
1694
+ const collapsed = this.collapseConsecutiveMessages(split);
1665
1695
 
1666
1696
  // NO system prompt. The agent's identity is established by the head
1667
1697
  // (the actual conversation opening — user message + agent reply that
@@ -1955,15 +1985,19 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1955
1985
  llmMessages.push({ participant: m.participant, content: m.content });
1956
1986
  }
1957
1987
 
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)
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)
1963
1999
  .filter((s) => {
1964
- // Exclude any L1 that's an ancestor of our merge target
1965
2000
  for (const lid of s.sourceIds) if (sourceLeafIds.has(lid)) return false;
1966
- // Include only if it starts strictly before the merge range
1967
2001
  const firstIdx = allMessages.findIndex((m) => m.id === s.sourceRange.first);
1968
2002
  return firstIdx >= 0 && (mergeStartIdx < 0 || firstIdx < mergeStartIdx);
1969
2003
  })
@@ -1973,12 +2007,37 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1973
2007
  return ai - bi;
1974
2008
  });
1975
2009
 
1976
- const priorL1MessageIds = new Set<MessageId>();
1977
- for (const s of priorL1s) {
1978
- 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);
1979
2038
  }
1980
2039
 
1981
- for (const s of priorL1s) {
2040
+ for (const s of keptPriorSummaries) {
1982
2041
  llmMessages.push({
1983
2042
  participant: 'Context Manager',
1984
2043
  content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
@@ -1990,12 +2049,12 @@ export class AutobiographicalStrategy implements ResettableStrategy {
1990
2049
  }
1991
2050
 
1992
2051
  // 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.
2052
+ // range that aren't covered by a prior summary or the merge tree.
1994
2053
  // Usually empty (chunking is contiguous).
1995
2054
  if (mergeStartIdx >= 0) {
1996
2055
  for (let i = headEndIdx; i < mergeStartIdx; i++) {
1997
2056
  const m = allMessages[i];
1998
- if (priorL1MessageIds.has(m.id)) continue;
2057
+ if (priorSummaryMessageIds.has(m.id)) continue;
1999
2058
  if (sourceLeafIds.has(m.id)) continue;
2000
2059
  llmMessages.push({ participant: m.participant, content: m.content });
2001
2060
  }
@@ -2081,7 +2140,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
2081
2140
  }],
2082
2141
  });
2083
2142
 
2084
- const collapsed = this.collapseConsecutiveMessages(llmMessages);
2143
+ // Same bundled-tool-cycle defense as compressChunkHierarchical.
2144
+ const split = splitMixedToolMessages(llmMessages);
2145
+ const collapsed = this.collapseConsecutiveMessages(split);
2085
2146
 
2086
2147
  // NO system prompt — identity is established by the head window
2087
2148
  // (present at the start of llmMessages above) and by the prior
@@ -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