@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
@@ -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)
@@ -1288,11 +1319,14 @@ export class AutobiographicalStrategy {
1288
1319
  const agentParticipant = this.config.summaryParticipant ?? 'Claude';
1289
1320
  // Build the KV-preserving prompt per hermes-autobio spec:
1290
1321
  //
1291
- // 1. Prior L1 summaries — narrativized as CM-asks / agent-recalls
1292
- // pairs, in source order. ALL existing L1s at L1 fidelity
1293
- // regardless of merge state ("fill lower orbitals first").
1322
+ // 1. Prior summaries — narrativized as CM-asks / agent-recalls
1323
+ // pairs, in source order. The unmerged frontier of the
1324
+ // summary forest: any summary that has not yet been merged
1325
+ // into a higher level. After merges run, the L_{k+1} replaces
1326
+ // its L_k children — using the children plus their parent
1327
+ // doubles the prompt size unboundedly.
1294
1328
  // 2. Head — raw messages before the chunk that aren't already
1295
- // represented by a prior L1.
1329
+ // represented by a prior summary.
1296
1330
  // 3. Marker — in-band signal that a memory is about to form.
1297
1331
  // 4. Chunk — raw messages being compressed, as the agent
1298
1332
  // experienced them.
@@ -1302,13 +1336,35 @@ export class AutobiographicalStrategy {
1302
1336
  // future information into the model's KV state and corrupt the
1303
1337
  // as-of framing of memory formation.
1304
1338
  const llmMessages = [];
1305
- // ---- 1. Prior L1 recall pairs ----
1306
- // Sort by source position so the agent walks its prior memories
1307
- // chronologically, matching how it would have formed them.
1308
- const priorL1s = this.summaries
1309
- .filter((s) => s.level === 1)
1339
+ // ---- 1. Prior recall pairs ----
1340
+ // Filter to the unmerged frontier: any summary whose `mergedInto`
1341
+ // is unset. After merge, the children's mergedInto points at the
1342
+ // parent and the parent stands alone with that source range. The
1343
+ // original "ALL L1s regardless of merge state" rule was a fidelity
1344
+ // optimization that scales catastrophically: a 4000-message import
1345
+ // converged to ~500 L1s that never aged out, blowing the 200k
1346
+ // window around chunk 118.
1347
+ const priorSummaries = this.summaries
1348
+ .filter((s) => !s.mergedInto)
1310
1349
  .sort((a, b) => a.sourceRange.first.localeCompare(b.sourceRange.first));
1311
- for (const s of priorL1s) {
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);
1354
+ if (keptSummaries.length < priorSummaries.length) {
1355
+ const dropped = priorSummaries.length - keptSummaries.length;
1356
+ console.warn(`autobio: compression recall-pair budget capped (${keptSummaries.length}/${priorSummaries.length} summaries kept, ` +
1357
+ `~${recallTokens} tokens, budget ${recallBudget}; ${dropped} oldest dropped this compression).`);
1358
+ logCompressionCall({
1359
+ event: 'recall-budget-capped',
1360
+ site: 'compression',
1361
+ kept: keptSummaries.length,
1362
+ total: priorSummaries.length,
1363
+ tokens: recallTokens,
1364
+ budgetTokens: recallBudget,
1365
+ });
1366
+ }
1367
+ for (const s of keptSummaries) {
1312
1368
  llmMessages.push({
1313
1369
  participant: 'Context Manager',
1314
1370
  content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
@@ -1345,20 +1401,24 @@ export class AutobiographicalStrategy {
1345
1401
  llmMessages.push({ participant: m.participant, content: m.content });
1346
1402
  }
1347
1403
  // Any raw messages between the head and the chunk that aren't yet
1348
- // represented by an L1 — usually empty in adaptive-resolution mode,
1349
- // since chunking proceeds contiguously and L1s exist for everything
1350
- // up to the chunk being processed.
1404
+ // represented by any summary — usually empty in adaptive-resolution
1405
+ // mode, since chunking proceeds contiguously and summaries cover
1406
+ // everything up to the chunk being processed. Uses the full
1407
+ // priorSummaries set (not the budget-capped keptSummaries) because
1408
+ // the dedup question is "is this raw message covered by *any* live
1409
+ // summary?" — a budget-dropped summary doesn't make the underlying
1410
+ // raw messages reappear.
1351
1411
  const chunkFirstId = chunk.messages[0]?.id;
1352
1412
  if (chunkFirstId) {
1353
- const priorL1MessageIds = new Set();
1354
- for (const s of priorL1s) {
1413
+ const priorSummaryMessageIds = new Set();
1414
+ for (const s of priorSummaries) {
1355
1415
  for (const id of s.sourceIds)
1356
- priorL1MessageIds.add(id);
1416
+ priorSummaryMessageIds.add(id);
1357
1417
  }
1358
1418
  const chunkStartIdx = allMessages.findIndex((m) => m.id === chunkFirstId);
1359
1419
  for (let i = headEndIdx; i < chunkStartIdx && i < allMessages.length; i++) {
1360
1420
  const m = allMessages[i];
1361
- if (priorL1MessageIds.has(m.id))
1421
+ if (priorSummaryMessageIds.has(m.id))
1362
1422
  continue;
1363
1423
  llmMessages.push({ participant: m.participant, content: m.content });
1364
1424
  }
@@ -1389,8 +1449,14 @@ export class AutobiographicalStrategy {
1389
1449
  participant: 'Context Manager',
1390
1450
  content: [{ type: 'text', text: instructionText }],
1391
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);
1392
1458
  // Collapse consecutive same-participant messages for API compliance
1393
- const collapsed = this.collapseConsecutiveMessages(llmMessages);
1459
+ const collapsed = this.collapseConsecutiveMessages(split);
1394
1460
  // NO system prompt. The agent's identity is established by the head
1395
1461
  // (the actual conversation opening — user message + agent reply that
1396
1462
  // grounded the original instance). A system prompt would (a) add a
@@ -1460,7 +1526,9 @@ export class AutobiographicalStrategy {
1460
1526
  metadata: {
1461
1527
  chunk_message_ids: chunk.messages.map((m) => m.id),
1462
1528
  chunk_size: chunk.messages.length,
1463
- prior_l1_count: priorL1s.length,
1529
+ prior_summary_count: priorSummaries.length,
1530
+ prior_summary_count_kept: keptSummaries.length,
1531
+ prior_summary_tokens: recallTokens,
1464
1532
  has_doc_context: docContext !== null,
1465
1533
  doc_context: docContext,
1466
1534
  target_tokens: targetTokens,
@@ -1657,17 +1725,21 @@ export class AutobiographicalStrategy {
1657
1725
  const m = allMessages[i];
1658
1726
  llmMessages.push({ participant: m.participant, content: m.content });
1659
1727
  }
1660
- // ---- 1b. PRIOR L1 RECALL PAIRS (chronologically before merge range) ----
1661
- // L1s whose entire source range is before the merge range and that
1662
- // aren't part of the merge tree. Sort by source position.
1663
- const priorL1s = this.summaries
1664
- .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)
1665
1739
  .filter((s) => {
1666
- // Exclude any L1 that's an ancestor of our merge target
1667
1740
  for (const lid of s.sourceIds)
1668
1741
  if (sourceLeafIds.has(lid))
1669
1742
  return false;
1670
- // Include only if it starts strictly before the merge range
1671
1743
  const firstIdx = allMessages.findIndex((m) => m.id === s.sourceRange.first);
1672
1744
  return firstIdx >= 0 && (mergeStartIdx < 0 || firstIdx < mergeStartIdx);
1673
1745
  })
@@ -1676,12 +1748,31 @@ export class AutobiographicalStrategy {
1676
1748
  const bi = allMessages.findIndex((m) => m.id === b.sourceRange.first);
1677
1749
  return ai - bi;
1678
1750
  });
1679
- const priorL1MessageIds = new Set();
1680
- 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) {
1681
1772
  for (const id of s.sourceIds)
1682
- priorL1MessageIds.add(id);
1773
+ priorSummaryMessageIds.add(id);
1683
1774
  }
1684
- for (const s of priorL1s) {
1775
+ for (const s of keptPriorSummaries) {
1685
1776
  llmMessages.push({
1686
1777
  participant: 'Context Manager',
1687
1778
  content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
@@ -1692,12 +1783,12 @@ export class AutobiographicalStrategy {
1692
1783
  });
1693
1784
  }
1694
1785
  // Raw middle: any messages between the head window and the merge
1695
- // 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.
1696
1787
  // Usually empty (chunking is contiguous).
1697
1788
  if (mergeStartIdx >= 0) {
1698
1789
  for (let i = headEndIdx; i < mergeStartIdx; i++) {
1699
1790
  const m = allMessages[i];
1700
- if (priorL1MessageIds.has(m.id))
1791
+ if (priorSummaryMessageIds.has(m.id))
1701
1792
  continue;
1702
1793
  if (sourceLeafIds.has(m.id))
1703
1794
  continue;
@@ -1777,7 +1868,9 @@ export class AutobiographicalStrategy {
1777
1868
  text: mergeInstructionText,
1778
1869
  }],
1779
1870
  });
1780
- const collapsed = this.collapseConsecutiveMessages(llmMessages);
1871
+ // Same bundled-tool-cycle defense as compressChunkHierarchical.
1872
+ const split = splitMixedToolMessages(llmMessages);
1873
+ const collapsed = this.collapseConsecutiveMessages(split);
1781
1874
  // NO system prompt — identity is established by the head window
1782
1875
  // (present at the start of llmMessages above) and by the prior
1783
1876
  // recall pairs. Same rationale as compressChunkHierarchical.