@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
@@ -1,6 +1,7 @@
1
1
  import { NativeFormatter } from '@animalabs/membrane';
2
2
  import { DEFAULT_AUTOBIOGRAPHICAL_CONFIG } from '../types/index.js';
3
3
  import { getSummaryParentId } from '../types/strategy.js';
4
+ import { splitMixedToolMessages } from '../normalize-tool-messages.js';
4
5
  import { appendFileSync, mkdirSync } from 'node:fs';
5
6
  import { dirname } from 'node:path';
6
7
  import { Picker, OverBudgetError } from '../adaptive/picker.js';
@@ -622,6 +623,36 @@ export class AutobiographicalStrategy {
622
623
  * Mark a summary as merged into a higher-level summary, updating the
623
624
  * chronicle copy at the same index. Index is the position in `this.summaries`.
624
625
  */
626
+ /**
627
+ * Token-budget cap for recall-pair summary sets. Walks newest→oldest
628
+ * keeping each summary that still fits; skips (rather than breaks at)
629
+ * a summary that would put us over budget, so a heterogeneous set
630
+ * fills the remaining slots with smaller siblings instead of stopping
631
+ * at the first oversized one. The kept set is re-sorted chronologically.
632
+ *
633
+ * Used by both `compressChunkHierarchical` (the L1 compression prompt
634
+ * recall pairs) and `executeMerge` (the merge prompt recall pairs).
635
+ * Without the cap, both sites grow their recall set linearly with
636
+ * conversation length and overflow the 200k window around the same
637
+ * point — observed empirically at ~chunk 118 in a 4000-message import.
638
+ *
639
+ * Per-summary +50 token overhead accounts for the "[CM] Recall memory
640
+ * <id>." question turn that wraps each recall body. Rough but defensive.
641
+ */
642
+ capRecallPairs(summariesChronological, maxTokens) {
643
+ const kept = [];
644
+ let total = 0;
645
+ for (let i = summariesChronological.length - 1; i >= 0; i--) {
646
+ const s = summariesChronological[i];
647
+ const est = (s.tokens ?? Math.ceil(s.content.length / 4)) + 50;
648
+ if (total + est > maxTokens)
649
+ continue;
650
+ kept.push(s);
651
+ total += est;
652
+ }
653
+ kept.reverse();
654
+ return { kept, keptTokens: total };
655
+ }
625
656
  setMergedInto(entry, mergedIntoId) {
626
657
  entry.mergedInto = mergedIntoId;
627
658
  if (!this.store)
@@ -1316,32 +1347,17 @@ export class AutobiographicalStrategy {
1316
1347
  const priorSummaries = this.summaries
1317
1348
  .filter((s) => !s.mergedInto)
1318
1349
  .sort((a, b) => a.sourceRange.first.localeCompare(b.sourceRange.first));
1319
- // Token-budget cap on recall pairs as defense-in-depth. With merged
1320
- // exclusion the set is bounded by `mergeThreshold^depth`, but a
1321
- // conversation with thousands of unmerged top-level summaries can
1322
- // still overflow. Newest-first selection so proximate context is
1323
- // preferred; the kept set is re-sorted chronologically below. We
1324
- // always keep at least one summary even if it's larger than the
1325
- // budget — otherwise a single oversized summary would leave the
1326
- // model with no compressed context at all.
1327
- const recallBudget = this.config.compressionRecallBudgetTokens ?? 150_000;
1328
- const keptSummaries = [];
1329
- let recallTokens = 0;
1330
- for (let i = priorSummaries.length - 1; i >= 0; i--) {
1331
- const s = priorSummaries[i];
1332
- const approxTokens = Math.ceil(s.content.length / 4);
1333
- if (recallTokens + approxTokens > recallBudget && keptSummaries.length > 0)
1334
- break;
1335
- keptSummaries.push(s);
1336
- recallTokens += approxTokens;
1337
- }
1338
- keptSummaries.reverse();
1350
+ // Token-budget cap (see capRecallPairs). Defense-in-depth: even with
1351
+ // merged exclusion the unmerged frontier can be large at extreme scale.
1352
+ const recallBudget = this.config.compressionRecallBudgetTokens ?? 100_000;
1353
+ const { kept: keptSummaries, keptTokens: recallTokens } = this.capRecallPairs(priorSummaries, recallBudget);
1339
1354
  if (keptSummaries.length < priorSummaries.length) {
1340
1355
  const dropped = priorSummaries.length - keptSummaries.length;
1341
- console.warn(`autobio: recall-pair budget capped (${keptSummaries.length}/${priorSummaries.length} summaries kept, ` +
1356
+ console.warn(`autobio: compression recall-pair budget capped (${keptSummaries.length}/${priorSummaries.length} summaries kept, ` +
1342
1357
  `~${recallTokens} tokens, budget ${recallBudget}; ${dropped} oldest dropped this compression).`);
1343
1358
  logCompressionCall({
1344
1359
  event: 'recall-budget-capped',
1360
+ site: 'compression',
1345
1361
  kept: keptSummaries.length,
1346
1362
  total: priorSummaries.length,
1347
1363
  tokens: recallTokens,
@@ -1433,8 +1449,14 @@ export class AutobiographicalStrategy {
1433
1449
  participant: 'Context Manager',
1434
1450
  content: [{ type: 'text', text: instructionText }],
1435
1451
  });
1452
+ // Split any bundled tool_use+tool_result cycles in non-user turns into
1453
+ // separate API-shape messages. claude.ai-imported sessions carry these
1454
+ // bundles (a tool_result in an assistant message rejects the request);
1455
+ // for fresh imports the conhost importer splits at ingest time, but
1456
+ // already-warmed sessions hit this path. See `normalize-tool-messages.ts`.
1457
+ const split = splitMixedToolMessages(llmMessages);
1436
1458
  // Collapse consecutive same-participant messages for API compliance
1437
- const collapsed = this.collapseConsecutiveMessages(llmMessages);
1459
+ const collapsed = this.collapseConsecutiveMessages(split);
1438
1460
  // NO system prompt. The agent's identity is established by the head
1439
1461
  // (the actual conversation opening — user message + agent reply that
1440
1462
  // grounded the original instance). A system prompt would (a) add a
@@ -1703,17 +1725,21 @@ export class AutobiographicalStrategy {
1703
1725
  const m = allMessages[i];
1704
1726
  llmMessages.push({ participant: m.participant, content: m.content });
1705
1727
  }
1706
- // ---- 1b. PRIOR L1 RECALL PAIRS (chronologically before merge range) ----
1707
- // L1s whose entire source range is before the merge range and that
1708
- // aren't part of the merge tree. Sort by source position.
1709
- const priorL1s = this.summaries
1710
- .filter((s) => s.level === 1)
1728
+ // ---- 1b. PRIOR RECALL PAIRS (chronologically before merge range) ----
1729
+ // The unmerged frontier of summaries whose source range is before the
1730
+ // merge range and which aren't part of the merge tree. Originally this
1731
+ // was filtered to `level === 1` (the "L1 fidelity for prior content"
1732
+ // intent) but at 4000+ messages that produces hundreds of L1s and
1733
+ // overflows the model window. Switching to the unmerged frontier
1734
+ // (`!mergedInto`) lets a merged L1 drop out in favour of its L2/L3
1735
+ // parent — the same rule used everywhere else and now in
1736
+ // `compressChunkHierarchical`. The cap below is the defense-in-depth.
1737
+ const priorSummariesAll = this.summaries
1738
+ .filter((s) => !s.mergedInto)
1711
1739
  .filter((s) => {
1712
- // Exclude any L1 that's an ancestor of our merge target
1713
1740
  for (const lid of s.sourceIds)
1714
1741
  if (sourceLeafIds.has(lid))
1715
1742
  return false;
1716
- // Include only if it starts strictly before the merge range
1717
1743
  const firstIdx = allMessages.findIndex((m) => m.id === s.sourceRange.first);
1718
1744
  return firstIdx >= 0 && (mergeStartIdx < 0 || firstIdx < mergeStartIdx);
1719
1745
  })
@@ -1722,12 +1748,31 @@ export class AutobiographicalStrategy {
1722
1748
  const bi = allMessages.findIndex((m) => m.id === b.sourceRange.first);
1723
1749
  return ai - bi;
1724
1750
  });
1725
- const priorL1MessageIds = new Set();
1726
- for (const s of priorL1s) {
1751
+ const mergeRecallBudget = this.config.compressionRecallBudgetTokens ?? 100_000;
1752
+ const { kept: keptPriorSummaries, keptTokens: mergeRecallTokens } = this.capRecallPairs(priorSummariesAll, mergeRecallBudget);
1753
+ if (keptPriorSummaries.length < priorSummariesAll.length) {
1754
+ const dropped = priorSummariesAll.length - keptPriorSummaries.length;
1755
+ console.warn(`autobio: merge recall-pair budget capped (${keptPriorSummaries.length}/${priorSummariesAll.length} summaries kept, ` +
1756
+ `~${mergeRecallTokens} tokens, budget ${mergeRecallBudget}; ${dropped} oldest dropped this merge).`);
1757
+ logCompressionCall({
1758
+ event: 'recall-budget-capped',
1759
+ site: 'merge',
1760
+ targetLevel,
1761
+ kept: keptPriorSummaries.length,
1762
+ total: priorSummariesAll.length,
1763
+ tokens: mergeRecallTokens,
1764
+ budgetTokens: mergeRecallBudget,
1765
+ });
1766
+ }
1767
+ // The full unmerged-frontier set covers what's "compressed somewhere"
1768
+ // for the raw-middle dedup below — a budget-dropped recall pair
1769
+ // doesn't make its underlying raw messages reappear.
1770
+ const priorSummaryMessageIds = new Set();
1771
+ for (const s of priorSummariesAll) {
1727
1772
  for (const id of s.sourceIds)
1728
- priorL1MessageIds.add(id);
1773
+ priorSummaryMessageIds.add(id);
1729
1774
  }
1730
- for (const s of priorL1s) {
1775
+ for (const s of keptPriorSummaries) {
1731
1776
  llmMessages.push({
1732
1777
  participant: 'Context Manager',
1733
1778
  content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
@@ -1738,12 +1783,12 @@ export class AutobiographicalStrategy {
1738
1783
  });
1739
1784
  }
1740
1785
  // Raw middle: any messages between the head window and the merge
1741
- // range that aren't covered by a prior L1 or the merge tree.
1786
+ // range that aren't covered by a prior summary or the merge tree.
1742
1787
  // Usually empty (chunking is contiguous).
1743
1788
  if (mergeStartIdx >= 0) {
1744
1789
  for (let i = headEndIdx; i < mergeStartIdx; i++) {
1745
1790
  const m = allMessages[i];
1746
- if (priorL1MessageIds.has(m.id))
1791
+ if (priorSummaryMessageIds.has(m.id))
1747
1792
  continue;
1748
1793
  if (sourceLeafIds.has(m.id))
1749
1794
  continue;
@@ -1823,7 +1868,9 @@ export class AutobiographicalStrategy {
1823
1868
  text: mergeInstructionText,
1824
1869
  }],
1825
1870
  });
1826
- const collapsed = this.collapseConsecutiveMessages(llmMessages);
1871
+ // Same bundled-tool-cycle defense as compressChunkHierarchical.
1872
+ const split = splitMixedToolMessages(llmMessages);
1873
+ const collapsed = this.collapseConsecutiveMessages(split);
1827
1874
  // NO system prompt — identity is established by the head window
1828
1875
  // (present at the start of llmMessages above) and by the prior
1829
1876
  // recall pairs. Same rationale as compressChunkHierarchical.