@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.
- package/dist/src/context-manager.d.ts.map +1 -1
- package/dist/src/context-manager.js +25 -6
- package/dist/src/context-manager.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/message-store.d.ts.map +1 -1
- package/dist/src/message-store.js +7 -36
- package/dist/src/message-store.js.map +1 -1
- package/dist/src/normalize-tool-messages.d.ts +40 -0
- package/dist/src/normalize-tool-messages.d.ts.map +1 -0
- package/dist/src/normalize-tool-messages.js +69 -0
- package/dist/src/normalize-tool-messages.js.map +1 -0
- package/dist/src/strategies/autobiographical.d.ts +20 -0
- package/dist/src/strategies/autobiographical.d.ts.map +1 -1
- package/dist/src/strategies/autobiographical.js +83 -36
- package/dist/src/strategies/autobiographical.js.map +1 -1
- package/dist/src/types/strategy.d.ts +17 -6
- package/dist/src/types/strategy.d.ts.map +1 -1
- package/dist/src/types/strategy.js.map +1 -1
- package/dist/test/normalize-tool-messages.test.d.ts +2 -0
- package/dist/test/normalize-tool-messages.test.d.ts.map +1 -0
- package/dist/test/normalize-tool-messages.test.js +108 -0
- package/dist/test/normalize-tool-messages.test.js.map +1 -0
- package/dist/test/recall-cap.test.d.ts +2 -0
- package/dist/test/recall-cap.test.d.ts.map +1 -0
- package/dist/test/recall-cap.test.js +103 -0
- package/dist/test/recall-cap.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/context-manager.ts +25 -6
- package/src/index.ts +3 -0
- package/src/message-store.ts +12 -38
- package/src/normalize-tool-messages.ts +71 -0
- package/src/strategies/autobiographical.ts +96 -35
- 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
|
|
1320
|
-
// exclusion the
|
|
1321
|
-
|
|
1322
|
-
|
|
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(
|
|
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
|
|
1707
|
-
//
|
|
1708
|
-
// aren't part of the merge tree.
|
|
1709
|
-
|
|
1710
|
-
|
|
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
|
|
1726
|
-
|
|
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
|
-
|
|
1773
|
+
priorSummaryMessageIds.add(id);
|
|
1729
1774
|
}
|
|
1730
|
-
for (const s of
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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.
|