@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.
- 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 +126 -33
- package/dist/src/strategies/autobiographical.js.map +1 -1
- package/dist/src/types/strategy.d.ts +23 -0
- 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 +143 -33
- 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
|
|
1292
|
-
// pairs, in source order.
|
|
1293
|
-
//
|
|
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
|
|
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
|
|
1306
|
-
//
|
|
1307
|
-
//
|
|
1308
|
-
|
|
1309
|
-
|
|
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
|
-
|
|
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
|
|
1349
|
-
// since chunking proceeds contiguously and
|
|
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
|
|
1354
|
-
for (const s of
|
|
1413
|
+
const priorSummaryMessageIds = new Set();
|
|
1414
|
+
for (const s of priorSummaries) {
|
|
1355
1415
|
for (const id of s.sourceIds)
|
|
1356
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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
|
|
1661
|
-
//
|
|
1662
|
-
// aren't part of the merge tree.
|
|
1663
|
-
|
|
1664
|
-
|
|
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
|
|
1680
|
-
|
|
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
|
-
|
|
1773
|
+
priorSummaryMessageIds.add(id);
|
|
1683
1774
|
}
|
|
1684
|
-
for (const s of
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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.
|