@animalabs/context-manager 0.5.1 → 0.5.3
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 +73 -0
- package/dist/src/normalize-tool-messages.d.ts.map +1 -0
- package/dist/src/normalize-tool-messages.js +128 -0
- package/dist/src/normalize-tool-messages.js.map +1 -0
- package/dist/src/strategies/autobiographical.d.ts +53 -0
- package/dist/src/strategies/autobiographical.d.ts.map +1 -1
- package/dist/src/strategies/autobiographical.js +178 -43
- 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/chunker-tool-boundary.test.d.ts +12 -0
- package/dist/test/chunker-tool-boundary.test.d.ts.map +1 -0
- package/dist/test/chunker-tool-boundary.test.js +89 -0
- package/dist/test/chunker-tool-boundary.test.js.map +1 -0
- package/dist/test/compression-shape-invariants.test.d.ts +22 -0
- package/dist/test/compression-shape-invariants.test.d.ts.map +1 -0
- package/dist/test/compression-shape-invariants.test.js +312 -0
- package/dist/test/compression-shape-invariants.test.js.map +1 -0
- 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 +168 -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 +132 -0
- package/src/strategies/autobiographical.ts +194 -41
- package/src/types/strategy.ts +17 -6
package/src/message-store.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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,132 @@
|
|
|
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
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Strip `tool_use` blocks whose IDs have no matching `tool_result` anywhere
|
|
75
|
+
* in the message list, and `tool_result` blocks whose IDs have no matching
|
|
76
|
+
* `tool_use`. If stripping leaves a message with no blocks, replace its
|
|
77
|
+
* content with a placeholder text block so the message stays structurally
|
|
78
|
+
* valid (collapse/rendering paths assume non-empty content).
|
|
79
|
+
*
|
|
80
|
+
* Why this exists: the Anthropic API requires every `tool_use` to be
|
|
81
|
+
* followed by its `tool_result` in the immediately-next message (and every
|
|
82
|
+
* `tool_result` to follow its `tool_use`). Two ways this becomes a problem
|
|
83
|
+
* in compression:
|
|
84
|
+
*
|
|
85
|
+
* - chunk boundaries: the chunker can cut a cycle, leaving a `tool_use`
|
|
86
|
+
* at the tail of chunk N and its `tool_result` at the head of N+1.
|
|
87
|
+
* `rebuildChunks` defers closing on a `tool_use` to avoid this, but
|
|
88
|
+
* can't help when the `tool_use` is the very last message in the
|
|
89
|
+
* store (no next message to ride along).
|
|
90
|
+
* - any future call site that builds a request from raw messages without
|
|
91
|
+
* also handling cycle pairing.
|
|
92
|
+
*
|
|
93
|
+
* The placeholder is intentionally generic ("[tool call omitted]") rather
|
|
94
|
+
* than naming a specific cause: this function fires for the chunk-boundary
|
|
95
|
+
* case AND the very-last-message case AND any future not-yet-anticipated
|
|
96
|
+
* source of unpaired blocks. Anything more specific would mislead the
|
|
97
|
+
* debugger reading the compressed conversation later.
|
|
98
|
+
*/
|
|
99
|
+
export function stripUnpairedToolBlocks<T extends { participant: string; content: ContentBlock[] }>(
|
|
100
|
+
messages: readonly T[],
|
|
101
|
+
): Array<{ participant: string; content: ContentBlock[] }> {
|
|
102
|
+
const useIds = new Set<string>();
|
|
103
|
+
const resultIds = new Set<string>();
|
|
104
|
+
for (const msg of messages) {
|
|
105
|
+
for (const block of msg.content) {
|
|
106
|
+
if (block.type === 'tool_use') useIds.add((block as { id: string }).id);
|
|
107
|
+
else if (block.type === 'tool_result') {
|
|
108
|
+
resultIds.add((block as { toolUseId: string }).toolUseId);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return messages.map((msg) => {
|
|
113
|
+
const trimmed = msg.content.filter((block) => {
|
|
114
|
+
if (block.type === 'tool_use') {
|
|
115
|
+
return resultIds.has((block as { id: string }).id);
|
|
116
|
+
}
|
|
117
|
+
if (block.type === 'tool_result') {
|
|
118
|
+
return useIds.has((block as { toolUseId: string }).toolUseId);
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
});
|
|
122
|
+
if (trimmed.length === msg.content.length) {
|
|
123
|
+
return { participant: msg.participant, content: msg.content };
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
participant: msg.participant,
|
|
127
|
+
content: trimmed.length > 0
|
|
128
|
+
? trimmed
|
|
129
|
+
: [{ type: 'text', text: '[tool call omitted]' }],
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -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, stripUnpairedToolBlocks } 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
|
|
1541
|
-
// exclusion the
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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,
|
|
@@ -1619,9 +1642,18 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1619
1642
|
// raw messages reappear.
|
|
1620
1643
|
const chunkFirstId = chunk.messages[0]?.id;
|
|
1621
1644
|
if (chunkFirstId) {
|
|
1622
|
-
|
|
1645
|
+
// Expand summary sourceIds down to leaf message IDs. An L2 in
|
|
1646
|
+
// `priorSummaries` has L1 IDs in its sourceIds, not message IDs;
|
|
1647
|
+
// a flat walk would miss every message it transitively covers.
|
|
1648
|
+
// (Bug 10 — same shape as executeMerge.)
|
|
1649
|
+
const summariesById = new Map<string, SummaryEntry>();
|
|
1650
|
+
for (const s of this.summaries) summariesById.set(s.id, s);
|
|
1651
|
+
const priorSummaryMessageIds = new Set<MessageId>();
|
|
1652
|
+
for (const s of this.summaries) {
|
|
1653
|
+
if (s.level === 1) this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
|
|
1654
|
+
}
|
|
1623
1655
|
for (const s of priorSummaries) {
|
|
1624
|
-
|
|
1656
|
+
this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
|
|
1625
1657
|
}
|
|
1626
1658
|
const chunkStartIdx = allMessages.findIndex((m) => m.id === chunkFirstId);
|
|
1627
1659
|
for (let i = headEndIdx; i < chunkStartIdx && i < allMessages.length; i++) {
|
|
@@ -1660,8 +1692,21 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1660
1692
|
content: [{ type: 'text', text: instructionText }],
|
|
1661
1693
|
});
|
|
1662
1694
|
|
|
1695
|
+
// Split any bundled tool_use+tool_result cycles in non-user turns into
|
|
1696
|
+
// separate API-shape messages. claude.ai-imported sessions carry these
|
|
1697
|
+
// bundles (a tool_result in an assistant message rejects the request);
|
|
1698
|
+
// for fresh imports the conhost importer splits at ingest time, but
|
|
1699
|
+
// already-warmed sessions hit this path. See `normalize-tool-messages.ts`.
|
|
1700
|
+
const split = splitMixedToolMessages(llmMessages);
|
|
1701
|
+
|
|
1663
1702
|
// Collapse consecutive same-participant messages for API compliance
|
|
1664
|
-
const collapsed = this.collapseConsecutiveMessages(
|
|
1703
|
+
const collapsed = this.collapseConsecutiveMessages(split);
|
|
1704
|
+
|
|
1705
|
+
// Defense in depth against chunk boundaries that cut a tool cycle
|
|
1706
|
+
// (rebuildChunks tries to avoid this, but covers only the most common
|
|
1707
|
+
// case). The API rejects any tool_use that isn't immediately followed
|
|
1708
|
+
// by its tool_result, and any tool_result that doesn't follow a use.
|
|
1709
|
+
const cleaned = stripUnpairedToolBlocks(collapsed);
|
|
1665
1710
|
|
|
1666
1711
|
// NO system prompt. The agent's identity is established by the head
|
|
1667
1712
|
// (the actual conversation opening — user message + agent reply that
|
|
@@ -1672,7 +1717,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1672
1717
|
// structural one carried by the conversation itself. Anchoring
|
|
1673
1718
|
// identity by the chronicle's actual head is more honest.
|
|
1674
1719
|
const request: NormalizedRequest = {
|
|
1675
|
-
messages:
|
|
1720
|
+
messages: cleaned.map(m => ({ participant: m.participant, content: m.content })),
|
|
1676
1721
|
config: {
|
|
1677
1722
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
1678
1723
|
maxTokens: Math.round(targetTokens * 1.5),
|
|
@@ -1723,7 +1768,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1723
1768
|
logCompressionCall({
|
|
1724
1769
|
operation: 'compress_l1',
|
|
1725
1770
|
system: null,
|
|
1726
|
-
messages:
|
|
1771
|
+
messages: cleaned.map((m) => ({
|
|
1727
1772
|
participant: m.participant,
|
|
1728
1773
|
// Flatten content for logging — store text only; binary content
|
|
1729
1774
|
// would bloat the log and isn't typical in compression input.
|
|
@@ -1955,15 +2000,19 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1955
2000
|
llmMessages.push({ participant: m.participant, content: m.content });
|
|
1956
2001
|
}
|
|
1957
2002
|
|
|
1958
|
-
// ---- 1b. PRIOR
|
|
1959
|
-
//
|
|
1960
|
-
// aren't part of the merge tree.
|
|
1961
|
-
|
|
1962
|
-
|
|
2003
|
+
// ---- 1b. PRIOR RECALL PAIRS (chronologically before merge range) ----
|
|
2004
|
+
// The unmerged frontier of summaries whose source range is before the
|
|
2005
|
+
// merge range and which aren't part of the merge tree. Originally this
|
|
2006
|
+
// was filtered to `level === 1` (the "L1 fidelity for prior content"
|
|
2007
|
+
// intent) but at 4000+ messages that produces hundreds of L1s and
|
|
2008
|
+
// overflows the model window. Switching to the unmerged frontier
|
|
2009
|
+
// (`!mergedInto`) lets a merged L1 drop out in favour of its L2/L3
|
|
2010
|
+
// parent — the same rule used everywhere else and now in
|
|
2011
|
+
// `compressChunkHierarchical`. The cap below is the defense-in-depth.
|
|
2012
|
+
const priorSummariesAll = this.summaries
|
|
2013
|
+
.filter((s) => !s.mergedInto)
|
|
1963
2014
|
.filter((s) => {
|
|
1964
|
-
// Exclude any L1 that's an ancestor of our merge target
|
|
1965
2015
|
for (const lid of s.sourceIds) if (sourceLeafIds.has(lid)) return false;
|
|
1966
|
-
// Include only if it starts strictly before the merge range
|
|
1967
2016
|
const firstIdx = allMessages.findIndex((m) => m.id === s.sourceRange.first);
|
|
1968
2017
|
return firstIdx >= 0 && (mergeStartIdx < 0 || firstIdx < mergeStartIdx);
|
|
1969
2018
|
})
|
|
@@ -1973,12 +2022,47 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1973
2022
|
return ai - bi;
|
|
1974
2023
|
});
|
|
1975
2024
|
|
|
1976
|
-
const
|
|
1977
|
-
|
|
1978
|
-
|
|
2025
|
+
const mergeRecallBudget = this.config.compressionRecallBudgetTokens ?? 100_000;
|
|
2026
|
+
const { kept: keptPriorSummaries, keptTokens: mergeRecallTokens } = this.capRecallPairs(
|
|
2027
|
+
priorSummariesAll,
|
|
2028
|
+
mergeRecallBudget,
|
|
2029
|
+
);
|
|
2030
|
+
if (keptPriorSummaries.length < priorSummariesAll.length) {
|
|
2031
|
+
const dropped = priorSummariesAll.length - keptPriorSummaries.length;
|
|
2032
|
+
console.warn(
|
|
2033
|
+
`autobio: merge recall-pair budget capped (${keptPriorSummaries.length}/${priorSummariesAll.length} summaries kept, ` +
|
|
2034
|
+
`~${mergeRecallTokens} tokens, budget ${mergeRecallBudget}; ${dropped} oldest dropped this merge).`,
|
|
2035
|
+
);
|
|
2036
|
+
logCompressionCall({
|
|
2037
|
+
event: 'recall-budget-capped',
|
|
2038
|
+
site: 'merge',
|
|
2039
|
+
targetLevel,
|
|
2040
|
+
kept: keptPriorSummaries.length,
|
|
2041
|
+
total: priorSummariesAll.length,
|
|
2042
|
+
tokens: mergeRecallTokens,
|
|
2043
|
+
budgetTokens: mergeRecallBudget,
|
|
2044
|
+
});
|
|
1979
2045
|
}
|
|
1980
2046
|
|
|
1981
|
-
|
|
2047
|
+
// The full unmerged-frontier set covers what's "compressed somewhere"
|
|
2048
|
+
// for the raw-middle dedup below — a budget-dropped recall pair
|
|
2049
|
+
// doesn't make its underlying raw messages reappear.
|
|
2050
|
+
//
|
|
2051
|
+
// Critical: `sourceIds` on an L2+ summary points at L1 IDs, not raw
|
|
2052
|
+
// message IDs. The dedup happens against raw message IDs, so we must
|
|
2053
|
+
// recursively expand each summary down to its leaf message IDs.
|
|
2054
|
+
// Without this, every message under any L2 leaks back in as raw text
|
|
2055
|
+
// (Bug 10: 525-message merge requests on a 4234-msg conversation).
|
|
2056
|
+
// Also expand merged L1s as defense in depth.
|
|
2057
|
+
const priorSummaryMessageIds = new Set<MessageId>();
|
|
2058
|
+
for (const s of this.summaries) {
|
|
2059
|
+
if (s.level === 1) this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
|
|
2060
|
+
}
|
|
2061
|
+
for (const s of priorSummariesAll) {
|
|
2062
|
+
this.expandSummaryToLeafMessageIds(s, summariesById, priorSummaryMessageIds);
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
for (const s of keptPriorSummaries) {
|
|
1982
2066
|
llmMessages.push({
|
|
1983
2067
|
participant: 'Context Manager',
|
|
1984
2068
|
content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
|
|
@@ -1990,12 +2074,12 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1990
2074
|
}
|
|
1991
2075
|
|
|
1992
2076
|
// Raw middle: any messages between the head window and the merge
|
|
1993
|
-
// range that aren't covered by a prior
|
|
2077
|
+
// range that aren't covered by a prior summary or the merge tree.
|
|
1994
2078
|
// Usually empty (chunking is contiguous).
|
|
1995
2079
|
if (mergeStartIdx >= 0) {
|
|
1996
2080
|
for (let i = headEndIdx; i < mergeStartIdx; i++) {
|
|
1997
2081
|
const m = allMessages[i];
|
|
1998
|
-
if (
|
|
2082
|
+
if (priorSummaryMessageIds.has(m.id)) continue;
|
|
1999
2083
|
if (sourceLeafIds.has(m.id)) continue;
|
|
2000
2084
|
llmMessages.push({ participant: m.participant, content: m.content });
|
|
2001
2085
|
}
|
|
@@ -2081,13 +2165,16 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2081
2165
|
}],
|
|
2082
2166
|
});
|
|
2083
2167
|
|
|
2084
|
-
|
|
2168
|
+
// Same bundled-tool-cycle defense as compressChunkHierarchical.
|
|
2169
|
+
const split = splitMixedToolMessages(llmMessages);
|
|
2170
|
+
const collapsed = this.collapseConsecutiveMessages(split);
|
|
2171
|
+
const cleaned = stripUnpairedToolBlocks(collapsed);
|
|
2085
2172
|
|
|
2086
2173
|
// NO system prompt — identity is established by the head window
|
|
2087
2174
|
// (present at the start of llmMessages above) and by the prior
|
|
2088
2175
|
// recall pairs. Same rationale as compressChunkHierarchical.
|
|
2089
2176
|
const request: NormalizedRequest = {
|
|
2090
|
-
messages:
|
|
2177
|
+
messages: cleaned.map(m => ({ participant: m.participant, content: m.content })),
|
|
2091
2178
|
config: {
|
|
2092
2179
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
2093
2180
|
maxTokens: Math.round(targetTokens * 1.5),
|
|
@@ -2147,7 +2234,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2147
2234
|
logCompressionCall({
|
|
2148
2235
|
operation: `merge_l${targetLevel}`,
|
|
2149
2236
|
system: null,
|
|
2150
|
-
messages:
|
|
2237
|
+
messages: cleaned.map((m) => ({
|
|
2151
2238
|
participant: m.participant,
|
|
2152
2239
|
text: m.content
|
|
2153
2240
|
.filter((b: ContentBlock) => b.type === 'text')
|
|
@@ -2733,6 +2820,48 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2733
2820
|
return current;
|
|
2734
2821
|
}
|
|
2735
2822
|
|
|
2823
|
+
/**
|
|
2824
|
+
* Recursively expand a summary's `sourceIds` down to the leaf message IDs
|
|
2825
|
+
* it covers, adding each leaf into `out`.
|
|
2826
|
+
*
|
|
2827
|
+
* Required because `SummaryEntry.sourceIds` are level-relative: an L1's
|
|
2828
|
+
* sourceIds are raw message IDs (sourceLevel=0), but an L2's sourceIds
|
|
2829
|
+
* are L1 IDs (sourceLevel=1), and so on. Any dedup that walks `sourceIds`
|
|
2830
|
+
* directly only works for L1s — once L2+ summaries enter the picture
|
|
2831
|
+
* (which happens as soon as `mergeThreshold` L1s accumulate during
|
|
2832
|
+
* interleaved compression+merge ticks), the dedup silently fails and
|
|
2833
|
+
* already-summarized messages leak back into the request as raw text.
|
|
2834
|
+
* That's how Bug 10 produced 200k+ token merge prompts on a 4234-msg
|
|
2835
|
+
* import.
|
|
2836
|
+
*
|
|
2837
|
+
* Callers should also expand merged L1s (not just the unmerged frontier)
|
|
2838
|
+
* as defense in depth — a stale `mergedInto` pointer or a partially
|
|
2839
|
+
* applied merge shouldn't surface raw messages.
|
|
2840
|
+
*
|
|
2841
|
+
* `visited` guards against pathological cycles in the summary graph (a
|
|
2842
|
+
* corrupted store or a future merge regression that lets a summary
|
|
2843
|
+
* reference itself). The hierarchy is a DAG by construction, but a
|
|
2844
|
+
* stack overflow during compression — exactly when the safety net is
|
|
2845
|
+
* supposed to save the session — is too steep a price for trusting that.
|
|
2846
|
+
*/
|
|
2847
|
+
protected expandSummaryToLeafMessageIds(
|
|
2848
|
+
summary: SummaryEntry,
|
|
2849
|
+
summariesById: ReadonlyMap<string, SummaryEntry>,
|
|
2850
|
+
out: Set<MessageId>,
|
|
2851
|
+
visited: Set<string> = new Set(),
|
|
2852
|
+
): void {
|
|
2853
|
+
if (visited.has(summary.id)) return;
|
|
2854
|
+
visited.add(summary.id);
|
|
2855
|
+
if (summary.sourceLevel === 0) {
|
|
2856
|
+
for (const id of summary.sourceIds) out.add(id);
|
|
2857
|
+
return;
|
|
2858
|
+
}
|
|
2859
|
+
for (const childId of summary.sourceIds) {
|
|
2860
|
+
const child = summariesById.get(childId);
|
|
2861
|
+
if (child) this.expandSummaryToLeafMessageIds(child, summariesById, out, visited);
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2736
2865
|
// ============================================================================
|
|
2737
2866
|
// Hierarchical (threshold-driven) path
|
|
2738
2867
|
// ============================================================================
|
|
@@ -3349,6 +3478,17 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3349
3478
|
currentTokens >= this.config.targetChunkTokens &&
|
|
3350
3479
|
currentChunk.length >= 4;
|
|
3351
3480
|
|
|
3481
|
+
// Don't close a chunk on a message containing a tool_use block —
|
|
3482
|
+
// the matching tool_result lives in the immediately-following user
|
|
3483
|
+
// message, and the Anthropic API rejects a request where a tool_use
|
|
3484
|
+
// isn't immediately followed by its tool_result. Defer the close
|
|
3485
|
+
// by one iteration so the result rides along in the same chunk.
|
|
3486
|
+
// The stripUnpairedToolBlocks runtime pass is a safety net for the
|
|
3487
|
+
// rare case where a tool_use is the very last message in the store.
|
|
3488
|
+
if (shouldClose && this.lastMessageContainsToolUse(currentChunk)) {
|
|
3489
|
+
continue;
|
|
3490
|
+
}
|
|
3491
|
+
|
|
3352
3492
|
if (shouldClose) {
|
|
3353
3493
|
const chunk = this.createChunk(
|
|
3354
3494
|
this.chunks.length,
|
|
@@ -3387,6 +3527,19 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
3387
3527
|
}
|
|
3388
3528
|
}
|
|
3389
3529
|
|
|
3530
|
+
/**
|
|
3531
|
+
* Returns true if the last message in the chunk-in-progress contains a
|
|
3532
|
+
* `tool_use` block. Used by `rebuildChunks` to defer chunk closure until
|
|
3533
|
+
* the matching `tool_result` (in the immediately-following user message)
|
|
3534
|
+
* is pulled into the same chunk. See `stripUnpairedToolBlocks` for the
|
|
3535
|
+
* runtime safety net.
|
|
3536
|
+
*/
|
|
3537
|
+
protected lastMessageContainsToolUse(chunk: StoredMessage[]): boolean {
|
|
3538
|
+
const last = chunk[chunk.length - 1];
|
|
3539
|
+
if (!last) return false;
|
|
3540
|
+
return last.content.some((b) => b.type === 'tool_use');
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3390
3543
|
protected createChunk(
|
|
3391
3544
|
index: number,
|
|
3392
3545
|
startIndex: number,
|
package/src/types/strategy.ts
CHANGED
|
@@ -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
|
|
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.
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
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
|
|