@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
package/src/context-manager.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { RenderStats } from './types/index.js';
|
|
|
25
25
|
import { MessageStore, MessageStoreEvent } from './message-store.js';
|
|
26
26
|
import { ContextLog } from './context-log.js';
|
|
27
27
|
import { PassthroughStrategy } from './strategies/passthrough.js';
|
|
28
|
+
import { splitMixedToolMessages } from './normalize-tool-messages.js';
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Base configuration for ContextManager.
|
|
@@ -493,12 +494,30 @@ export class ContextManager {
|
|
|
493
494
|
effectiveBudget
|
|
494
495
|
);
|
|
495
496
|
|
|
496
|
-
// Convert to NormalizedMessage[]
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
497
|
+
// Convert to NormalizedMessage[]. We split each entry individually
|
|
498
|
+
// so we know the output-count per input and can re-attach cache
|
|
499
|
+
// markers to the last output of each (matching the marker's
|
|
500
|
+
// "cache up to here" intent).
|
|
501
|
+
//
|
|
502
|
+
// Splitting handles the claude.ai bundled-tool-cycle artifact: a
|
|
503
|
+
// non-user message containing `tool_result` blocks becomes a sequence
|
|
504
|
+
// of agent/user/agent turns so the API accepts it. Already-API-shape
|
|
505
|
+
// messages pass through untouched. See `src/normalize-tool-messages.ts`.
|
|
506
|
+
const messages: NormalizedMessage[] = [];
|
|
507
|
+
for (const entry of entries) {
|
|
508
|
+
const splitParts = splitMixedToolMessages([
|
|
509
|
+
{ participant: entry.participant, content: entry.content },
|
|
510
|
+
]);
|
|
511
|
+
for (let i = 0; i < splitParts.length; i++) {
|
|
512
|
+
const part = splitParts[i];
|
|
513
|
+
const isLast = i === splitParts.length - 1;
|
|
514
|
+
messages.push({
|
|
515
|
+
participant: part.participant,
|
|
516
|
+
content: part.content,
|
|
517
|
+
...(entry.cacheMarker && isLast ? { cacheBreakpoint: true } : {}),
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
502
521
|
|
|
503
522
|
// If no injections, log and return early
|
|
504
523
|
if (!injections || injections.length === 0) {
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,9 @@ export { PassthroughStrategy } from './strategies/passthrough.js';
|
|
|
13
13
|
export { AutobiographicalStrategy, type AutobiographicalProgressSnapshot } from './strategies/autobiographical.js';
|
|
14
14
|
export { KnowledgeStrategy } from './strategies/knowledge.js';
|
|
15
15
|
|
|
16
|
+
// Utilities
|
|
17
|
+
export { splitMixedToolMessages } from './normalize-tool-messages.js';
|
|
18
|
+
|
|
16
19
|
// Types
|
|
17
20
|
export type {
|
|
18
21
|
// Message types
|
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,71 @@
|
|
|
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
|
+
}
|
|
@@ -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 } 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,
|
|
@@ -1660,8 +1683,15 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1660
1683
|
content: [{ type: 'text', text: instructionText }],
|
|
1661
1684
|
});
|
|
1662
1685
|
|
|
1686
|
+
// Split any bundled tool_use+tool_result cycles in non-user turns into
|
|
1687
|
+
// separate API-shape messages. claude.ai-imported sessions carry these
|
|
1688
|
+
// bundles (a tool_result in an assistant message rejects the request);
|
|
1689
|
+
// for fresh imports the conhost importer splits at ingest time, but
|
|
1690
|
+
// already-warmed sessions hit this path. See `normalize-tool-messages.ts`.
|
|
1691
|
+
const split = splitMixedToolMessages(llmMessages);
|
|
1692
|
+
|
|
1663
1693
|
// Collapse consecutive same-participant messages for API compliance
|
|
1664
|
-
const collapsed = this.collapseConsecutiveMessages(
|
|
1694
|
+
const collapsed = this.collapseConsecutiveMessages(split);
|
|
1665
1695
|
|
|
1666
1696
|
// NO system prompt. The agent's identity is established by the head
|
|
1667
1697
|
// (the actual conversation opening — user message + agent reply that
|
|
@@ -1955,15 +1985,19 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1955
1985
|
llmMessages.push({ participant: m.participant, content: m.content });
|
|
1956
1986
|
}
|
|
1957
1987
|
|
|
1958
|
-
// ---- 1b. PRIOR
|
|
1959
|
-
//
|
|
1960
|
-
// aren't part of the merge tree.
|
|
1961
|
-
|
|
1962
|
-
|
|
1988
|
+
// ---- 1b. PRIOR RECALL PAIRS (chronologically before merge range) ----
|
|
1989
|
+
// The unmerged frontier of summaries whose source range is before the
|
|
1990
|
+
// merge range and which aren't part of the merge tree. Originally this
|
|
1991
|
+
// was filtered to `level === 1` (the "L1 fidelity for prior content"
|
|
1992
|
+
// intent) but at 4000+ messages that produces hundreds of L1s and
|
|
1993
|
+
// overflows the model window. Switching to the unmerged frontier
|
|
1994
|
+
// (`!mergedInto`) lets a merged L1 drop out in favour of its L2/L3
|
|
1995
|
+
// parent — the same rule used everywhere else and now in
|
|
1996
|
+
// `compressChunkHierarchical`. The cap below is the defense-in-depth.
|
|
1997
|
+
const priorSummariesAll = this.summaries
|
|
1998
|
+
.filter((s) => !s.mergedInto)
|
|
1963
1999
|
.filter((s) => {
|
|
1964
|
-
// Exclude any L1 that's an ancestor of our merge target
|
|
1965
2000
|
for (const lid of s.sourceIds) if (sourceLeafIds.has(lid)) return false;
|
|
1966
|
-
// Include only if it starts strictly before the merge range
|
|
1967
2001
|
const firstIdx = allMessages.findIndex((m) => m.id === s.sourceRange.first);
|
|
1968
2002
|
return firstIdx >= 0 && (mergeStartIdx < 0 || firstIdx < mergeStartIdx);
|
|
1969
2003
|
})
|
|
@@ -1973,12 +2007,37 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1973
2007
|
return ai - bi;
|
|
1974
2008
|
});
|
|
1975
2009
|
|
|
1976
|
-
const
|
|
1977
|
-
|
|
1978
|
-
|
|
2010
|
+
const mergeRecallBudget = this.config.compressionRecallBudgetTokens ?? 100_000;
|
|
2011
|
+
const { kept: keptPriorSummaries, keptTokens: mergeRecallTokens } = this.capRecallPairs(
|
|
2012
|
+
priorSummariesAll,
|
|
2013
|
+
mergeRecallBudget,
|
|
2014
|
+
);
|
|
2015
|
+
if (keptPriorSummaries.length < priorSummariesAll.length) {
|
|
2016
|
+
const dropped = priorSummariesAll.length - keptPriorSummaries.length;
|
|
2017
|
+
console.warn(
|
|
2018
|
+
`autobio: merge recall-pair budget capped (${keptPriorSummaries.length}/${priorSummariesAll.length} summaries kept, ` +
|
|
2019
|
+
`~${mergeRecallTokens} tokens, budget ${mergeRecallBudget}; ${dropped} oldest dropped this merge).`,
|
|
2020
|
+
);
|
|
2021
|
+
logCompressionCall({
|
|
2022
|
+
event: 'recall-budget-capped',
|
|
2023
|
+
site: 'merge',
|
|
2024
|
+
targetLevel,
|
|
2025
|
+
kept: keptPriorSummaries.length,
|
|
2026
|
+
total: priorSummariesAll.length,
|
|
2027
|
+
tokens: mergeRecallTokens,
|
|
2028
|
+
budgetTokens: mergeRecallBudget,
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// The full unmerged-frontier set covers what's "compressed somewhere"
|
|
2033
|
+
// for the raw-middle dedup below — a budget-dropped recall pair
|
|
2034
|
+
// doesn't make its underlying raw messages reappear.
|
|
2035
|
+
const priorSummaryMessageIds = new Set<MessageId>();
|
|
2036
|
+
for (const s of priorSummariesAll) {
|
|
2037
|
+
for (const id of s.sourceIds) priorSummaryMessageIds.add(id);
|
|
1979
2038
|
}
|
|
1980
2039
|
|
|
1981
|
-
for (const s of
|
|
2040
|
+
for (const s of keptPriorSummaries) {
|
|
1982
2041
|
llmMessages.push({
|
|
1983
2042
|
participant: 'Context Manager',
|
|
1984
2043
|
content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
|
|
@@ -1990,12 +2049,12 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1990
2049
|
}
|
|
1991
2050
|
|
|
1992
2051
|
// Raw middle: any messages between the head window and the merge
|
|
1993
|
-
// range that aren't covered by a prior
|
|
2052
|
+
// range that aren't covered by a prior summary or the merge tree.
|
|
1994
2053
|
// Usually empty (chunking is contiguous).
|
|
1995
2054
|
if (mergeStartIdx >= 0) {
|
|
1996
2055
|
for (let i = headEndIdx; i < mergeStartIdx; i++) {
|
|
1997
2056
|
const m = allMessages[i];
|
|
1998
|
-
if (
|
|
2057
|
+
if (priorSummaryMessageIds.has(m.id)) continue;
|
|
1999
2058
|
if (sourceLeafIds.has(m.id)) continue;
|
|
2000
2059
|
llmMessages.push({ participant: m.participant, content: m.content });
|
|
2001
2060
|
}
|
|
@@ -2081,7 +2140,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
2081
2140
|
}],
|
|
2082
2141
|
});
|
|
2083
2142
|
|
|
2084
|
-
|
|
2143
|
+
// Same bundled-tool-cycle defense as compressChunkHierarchical.
|
|
2144
|
+
const split = splitMixedToolMessages(llmMessages);
|
|
2145
|
+
const collapsed = this.collapseConsecutiveMessages(split);
|
|
2085
2146
|
|
|
2086
2147
|
// NO system prompt — identity is established by the head window
|
|
2087
2148
|
// (present at the start of llmMessages above) and by the prior
|
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
|
|