@animalabs/context-manager 0.4.0 → 0.5.1
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/adaptive/chunker.d.ts +70 -0
- package/dist/src/adaptive/chunker.d.ts.map +1 -0
- package/dist/src/adaptive/chunker.js +233 -0
- package/dist/src/adaptive/chunker.js.map +1 -0
- package/dist/src/adaptive/folding-strategy.d.ts +118 -0
- package/dist/src/adaptive/folding-strategy.d.ts.map +1 -0
- package/dist/src/adaptive/folding-strategy.js +12 -0
- package/dist/src/adaptive/folding-strategy.js.map +1 -0
- package/dist/src/adaptive/index.d.ts +17 -0
- package/dist/src/adaptive/index.d.ts.map +1 -0
- package/dist/src/adaptive/index.js +23 -0
- package/dist/src/adaptive/index.js.map +1 -0
- package/dist/src/adaptive/picker.d.ts +103 -0
- package/dist/src/adaptive/picker.d.ts.map +1 -0
- package/dist/src/adaptive/picker.js +357 -0
- package/dist/src/adaptive/picker.js.map +1 -0
- package/dist/src/adaptive/render.d.ts +39 -0
- package/dist/src/adaptive/render.d.ts.map +1 -0
- package/dist/src/adaptive/render.js +104 -0
- package/dist/src/adaptive/render.js.map +1 -0
- package/dist/src/adaptive/strategies/flat-profile.d.ts +19 -0
- package/dist/src/adaptive/strategies/flat-profile.d.ts.map +1 -0
- package/dist/src/adaptive/strategies/flat-profile.js +86 -0
- package/dist/src/adaptive/strategies/flat-profile.js.map +1 -0
- package/dist/src/adaptive/strategies/oldest-first.d.ts +19 -0
- package/dist/src/adaptive/strategies/oldest-first.d.ts.map +1 -0
- package/dist/src/adaptive/strategies/oldest-first.js +39 -0
- package/dist/src/adaptive/strategies/oldest-first.js.map +1 -0
- package/dist/src/context-manager.d.ts +5 -0
- package/dist/src/context-manager.d.ts.map +1 -1
- package/dist/src/context-manager.js +22 -0
- package/dist/src/context-manager.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/message-store.d.ts +36 -1
- package/dist/src/message-store.d.ts.map +1 -1
- package/dist/src/message-store.js +134 -8
- package/dist/src/message-store.js.map +1 -1
- package/dist/src/strategies/autobiographical.d.ts +217 -2
- package/dist/src/strategies/autobiographical.d.ts.map +1 -1
- package/dist/src/strategies/autobiographical.js +1525 -37
- package/dist/src/strategies/autobiographical.js.map +1 -1
- package/dist/src/strategies/index.d.ts +1 -1
- package/dist/src/strategies/index.d.ts.map +1 -1
- package/dist/src/strategies/index.js.map +1 -1
- package/dist/src/types/message.d.ts +38 -0
- package/dist/src/types/message.d.ts.map +1 -1
- package/dist/src/types/strategy.d.ts +106 -10
- package/dist/src/types/strategy.d.ts.map +1 -1
- package/dist/src/types/strategy.js +7 -0
- package/dist/src/types/strategy.js.map +1 -1
- package/dist/test/adaptive/branching.test.d.ts +20 -0
- package/dist/test/adaptive/branching.test.d.ts.map +1 -0
- package/dist/test/adaptive/branching.test.js +176 -0
- package/dist/test/adaptive/branching.test.js.map +1 -0
- package/dist/test/adaptive/chunker.test.d.ts +2 -0
- package/dist/test/adaptive/chunker.test.d.ts.map +1 -0
- package/dist/test/adaptive/chunker.test.js +120 -0
- package/dist/test/adaptive/chunker.test.js.map +1 -0
- package/dist/test/adaptive/deep-levels.test.d.ts +10 -0
- package/dist/test/adaptive/deep-levels.test.d.ts.map +1 -0
- package/dist/test/adaptive/deep-levels.test.js +167 -0
- package/dist/test/adaptive/deep-levels.test.js.map +1 -0
- package/dist/test/adaptive/doc-plus-chat.test.d.ts +10 -0
- package/dist/test/adaptive/doc-plus-chat.test.d.ts.map +1 -0
- package/dist/test/adaptive/doc-plus-chat.test.js +194 -0
- package/dist/test/adaptive/doc-plus-chat.test.js.map +1 -0
- package/dist/test/adaptive/hard-fail.test.d.ts +9 -0
- package/dist/test/adaptive/hard-fail.test.d.ts.map +1 -0
- package/dist/test/adaptive/hard-fail.test.js +154 -0
- package/dist/test/adaptive/hard-fail.test.js.map +1 -0
- package/dist/test/adaptive/harness.d.ts +109 -0
- package/dist/test/adaptive/harness.d.ts.map +1 -0
- package/dist/test/adaptive/harness.js +263 -0
- package/dist/test/adaptive/harness.js.map +1 -0
- package/dist/test/adaptive/ingestion.test.d.ts +13 -0
- package/dist/test/adaptive/ingestion.test.d.ts.map +1 -0
- package/dist/test/adaptive/ingestion.test.js +306 -0
- package/dist/test/adaptive/ingestion.test.js.map +1 -0
- package/dist/test/adaptive/integration.test.d.ts +12 -0
- package/dist/test/adaptive/integration.test.d.ts.map +1 -0
- package/dist/test/adaptive/integration.test.js +247 -0
- package/dist/test/adaptive/integration.test.js.map +1 -0
- package/dist/test/adaptive/long-chronicle.test.d.ts +13 -0
- package/dist/test/adaptive/long-chronicle.test.d.ts.map +1 -0
- package/dist/test/adaptive/long-chronicle.test.js +186 -0
- package/dist/test/adaptive/long-chronicle.test.js.map +1 -0
- package/dist/test/adaptive/persistence.test.d.ts +11 -0
- package/dist/test/adaptive/persistence.test.d.ts.map +1 -0
- package/dist/test/adaptive/persistence.test.js +200 -0
- package/dist/test/adaptive/persistence.test.js.map +1 -0
- package/dist/test/adaptive/picker.test.d.ts +2 -0
- package/dist/test/adaptive/picker.test.d.ts.map +1 -0
- package/dist/test/adaptive/picker.test.js +333 -0
- package/dist/test/adaptive/picker.test.js.map +1 -0
- package/dist/test/adaptive/render.test.d.ts +2 -0
- package/dist/test/adaptive/render.test.d.ts.map +1 -0
- package/dist/test/adaptive/render.test.js +109 -0
- package/dist/test/adaptive/render.test.js.map +1 -0
- package/dist/test/adaptive/shard-immutability.test.d.ts +14 -0
- package/dist/test/adaptive/shard-immutability.test.d.ts.map +1 -0
- package/dist/test/adaptive/shard-immutability.test.js +184 -0
- package/dist/test/adaptive/shard-immutability.test.js.map +1 -0
- package/dist/test/adaptive/strategy-integration.test.d.ts +9 -0
- package/dist/test/adaptive/strategy-integration.test.d.ts.map +1 -0
- package/dist/test/adaptive/strategy-integration.test.js +293 -0
- package/dist/test/adaptive/strategy-integration.test.js.map +1 -0
- package/dist/test/message-store-sequence.test.d.ts +25 -0
- package/dist/test/message-store-sequence.test.d.ts.map +1 -0
- package/dist/test/message-store-sequence.test.js +157 -0
- package/dist/test/message-store-sequence.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/adaptive/chunker.ts +323 -0
- package/src/adaptive/folding-strategy.ts +129 -0
- package/src/adaptive/index.ts +51 -0
- package/src/adaptive/picker.ts +442 -0
- package/src/adaptive/render.ts +115 -0
- package/src/adaptive/strategies/flat-profile.ts +96 -0
- package/src/adaptive/strategies/oldest-first.ts +48 -0
- package/src/context-manager.ts +32 -0
- package/src/index.ts +1 -1
- package/src/message-store.ts +147 -8
- package/src/strategies/autobiographical.ts +1635 -38
- package/src/strategies/index.ts +1 -1
- package/src/types/message.ts +43 -0
- package/src/types/strategy.ts +122 -10
|
@@ -19,6 +19,177 @@ import type {
|
|
|
19
19
|
SearchResult,
|
|
20
20
|
} from '../types/index.js';
|
|
21
21
|
import { DEFAULT_AUTOBIOGRAPHICAL_CONFIG } from '../types/index.js';
|
|
22
|
+
import { getSummaryParentId } from '../types/strategy.js';
|
|
23
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
24
|
+
import { dirname } from 'node:path';
|
|
25
|
+
import { Picker, OverBudgetError, type PickerChunk } from '../adaptive/picker.js';
|
|
26
|
+
import { FlatProfileStrategy } from '../adaptive/strategies/flat-profile.js';
|
|
27
|
+
import { OldestFirstStrategy } from '../adaptive/strategies/oldest-first.js';
|
|
28
|
+
import type {
|
|
29
|
+
FoldingStrategy,
|
|
30
|
+
FoldingBudget,
|
|
31
|
+
FoldOp,
|
|
32
|
+
ChunkId,
|
|
33
|
+
SummaryId,
|
|
34
|
+
} from '../adaptive/folding-strategy.js';
|
|
35
|
+
import { chunkMessage, DEFAULT_CHUNKER_OPTIONS } from '../adaptive/chunker.js';
|
|
36
|
+
import type { MessageId } from '../types/message.js';
|
|
37
|
+
import type { IngressChunkResult } from '../types/strategy.js';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Append a JSONL entry describing one compression LLM call to the path
|
|
41
|
+
* given by `CONTEXT_MANAGER_COMPRESSION_LOG`. No-op if the env var isn't
|
|
42
|
+
* set. Called at every L1 and merge LLM-call site so we can audit the
|
|
43
|
+
* exact prompts and responses post-hoc — no reconstruction, no
|
|
44
|
+
* assumption about whether the strategy code matches what produced
|
|
45
|
+
* historical summaries.
|
|
46
|
+
*
|
|
47
|
+
* Failures to write are logged to stderr but don't propagate — logging
|
|
48
|
+
* is non-essential observability and should never break compression.
|
|
49
|
+
*/
|
|
50
|
+
function logCompressionCall(entry: Record<string, unknown>): void {
|
|
51
|
+
const logPath = process.env.CONTEXT_MANAGER_COMPRESSION_LOG;
|
|
52
|
+
if (!logPath) return;
|
|
53
|
+
try {
|
|
54
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
55
|
+
const line = JSON.stringify({ ...entry, timestamp: Date.now() }) + '\n';
|
|
56
|
+
appendFileSync(logPath, line);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.warn('compression log write failed:', err);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* In-band marker shown to the summarizer just before the chunk it's
|
|
64
|
+
* about to memorize. Primes attention without disrupting KV state —
|
|
65
|
+
* the agent has seen this exact wording before every prior compression
|
|
66
|
+
* in its history, so the model treats memory formation as a recurring
|
|
67
|
+
* narrated event rather than a fresh instruction each time.
|
|
68
|
+
*
|
|
69
|
+
* Wording matches `hermes-autobio/plugins/autobio/compression.py`
|
|
70
|
+
* `_MARKER` so the in-band primer is consistent across the codebase.
|
|
71
|
+
*/
|
|
72
|
+
const COMPRESSION_MARKER =
|
|
73
|
+
'System: You will soon form a new memory, get ready. ' +
|
|
74
|
+
'The messages that follow are the slice of recent experience you are ' +
|
|
75
|
+
'about to compress. After them, write the memory in your own voice.';
|
|
76
|
+
|
|
77
|
+
/** Standard compression instruction for chat/general chunks. */
|
|
78
|
+
function formatInstruction(targetTokens: number): string {
|
|
79
|
+
return (
|
|
80
|
+
'Write the memory of events since the most recent memory system ' +
|
|
81
|
+
'notification. Speak in the first person from your own perspective. ' +
|
|
82
|
+
'Preserve concrete details — file paths, exact values, decisions, ' +
|
|
83
|
+
`unresolved questions, the user\'s active asks. Target ~${targetTokens} ` +
|
|
84
|
+
'tokens. Output only the memory body — no preamble, no section headers ' +
|
|
85
|
+
'unless they help preservation, no meta-commentary about summarizing.'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Compression instruction for chunks that are part of a substantially larger
|
|
91
|
+
* message (≥ 2× the chunk's own token size).
|
|
92
|
+
*
|
|
93
|
+
* Avoids forcing a "document" or "message" frame — just describes the
|
|
94
|
+
* experience: the agent has been reading a substantial piece of text, of
|
|
95
|
+
* which the slice is a portion. Asks what reading was like and what was
|
|
96
|
+
* learned. This naturally elicits first-person agent voice ("I read…", "I
|
|
97
|
+
* learned…", "I noticed…") and preserves concrete content via the
|
|
98
|
+
* "specific claims, names, dates" guidance.
|
|
99
|
+
*
|
|
100
|
+
* Rationale: when chunks are shards of a much larger user-shared message,
|
|
101
|
+
* the chunk content is heavily first-person from someone other than the
|
|
102
|
+
* agent. Asking "form a memory of what this contained" lets the model
|
|
103
|
+
* adopt the dominant voice of the chunk content. Asking "what was reading
|
|
104
|
+
* this like, what did you learn" forces the model to reflect from its own
|
|
105
|
+
* vantage point — only the agent can describe what reading something was
|
|
106
|
+
* like from its own perspective.
|
|
107
|
+
*/
|
|
108
|
+
function formatReadingChunkInstruction(
|
|
109
|
+
totalTokens: number,
|
|
110
|
+
targetTokens: number,
|
|
111
|
+
): string {
|
|
112
|
+
return (
|
|
113
|
+
`You have just been reading a substantial body of text — approximately ` +
|
|
114
|
+
`${totalTokens} tokens total in this piece, of which what you just read ` +
|
|
115
|
+
`is a portion. Earlier portions are in your memory above as your prior ` +
|
|
116
|
+
`reflections; what comes after, you have not yet read.\n\n` +
|
|
117
|
+
`Reflect on this reading: what was it like? What did you learn? What ` +
|
|
118
|
+
`stood out? Be substantive — name the specific claims, frameworks, ` +
|
|
119
|
+
`people, dates, and phrases that struck you. What is now in your ` +
|
|
120
|
+
`understanding that wasn't before you read this portion?\n\n` +
|
|
121
|
+
`Speak in your own voice as the one reading. Target ~${targetTokens} ` +
|
|
122
|
+
`tokens. Output only the body of your reflection — no preamble.`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Merge instruction for L2/L3+ consolidation (conversation/general case).
|
|
128
|
+
*
|
|
129
|
+
* The model has just been shown content ONE LEVEL DEEPER than its
|
|
130
|
+
* sources: raw messages for an L2 merge (sources are L1s), L1 memories
|
|
131
|
+
* for an L3 merge (sources are L2s), etc. The instruction describes
|
|
132
|
+
* the content the model just saw and asks for a consolidation at
|
|
133
|
+
* `targetLevel`.
|
|
134
|
+
*/
|
|
135
|
+
function formatMergeInstruction(
|
|
136
|
+
targetLevel: number,
|
|
137
|
+
sourceLevelShown: number,
|
|
138
|
+
targetTokens: number,
|
|
139
|
+
): string {
|
|
140
|
+
const seenDescription =
|
|
141
|
+
sourceLevelShown === 0
|
|
142
|
+
? 'the slices of recent experience above (raw conversation)'
|
|
143
|
+
: `the L${sourceLevelShown} memories above`;
|
|
144
|
+
return (
|
|
145
|
+
`You have just reviewed ${seenDescription}, in chronological order. ` +
|
|
146
|
+
`They cover the stretch of experience you are about to consolidate into a ` +
|
|
147
|
+
`single L${targetLevel} memory. Write a memory that preserves the ` +
|
|
148
|
+
`through-line: what happened, what was decided, what remains open, what ` +
|
|
149
|
+
`concrete details future you will want to reach for. Speak in the first ` +
|
|
150
|
+
`person. Target ~${targetTokens} tokens. Output only the memory body — ` +
|
|
151
|
+
`no preamble, no meta-commentary about summarizing.`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Reading-mode merge instruction. Used when the merge's leaf messages
|
|
157
|
+
* are all part of a substantially-larger sharded message — i.e., the
|
|
158
|
+
* agent has been reading a doc/long-message rather than conversing.
|
|
159
|
+
*
|
|
160
|
+
* Analogous to formatReadingChunkInstruction: avoids forcing a
|
|
161
|
+
* "document" or "message" frame, asks what reading the stretch was
|
|
162
|
+
* like and what was understood. Forces the agent's vantage point —
|
|
163
|
+
* only the reader can describe what reading was like — and so prevents
|
|
164
|
+
* the drift into the content author's voice that happens when the
|
|
165
|
+
* instruction asks for an impersonal summary.
|
|
166
|
+
*/
|
|
167
|
+
function formatReadingMergeInstruction(
|
|
168
|
+
targetLevel: number,
|
|
169
|
+
sourceLevelShown: number,
|
|
170
|
+
totalTokens: number,
|
|
171
|
+
targetTokens: number,
|
|
172
|
+
): string {
|
|
173
|
+
const seenDescription =
|
|
174
|
+
sourceLevelShown === 0
|
|
175
|
+
? 'the portions of text you read above (raw passages from a larger piece)'
|
|
176
|
+
: `your earlier L${sourceLevelShown} reflections above on portions you read`;
|
|
177
|
+
return (
|
|
178
|
+
`You have just re-experienced ${seenDescription}, in chronological order. ` +
|
|
179
|
+
`They cover a contiguous stretch of a substantial body of text you have ` +
|
|
180
|
+
`been reading — approximately ${totalTokens} tokens in total across all ` +
|
|
181
|
+
`of it. The portions above cover the stretch you are now consolidating ` +
|
|
182
|
+
`at L${targetLevel}.\n\n` +
|
|
183
|
+
`Reflect across the stretch: what was it like, reading these portions ` +
|
|
184
|
+
`together? What did you come to understand that you couldn't have from ` +
|
|
185
|
+
`any single portion alone? What recurring patterns, frameworks, or ` +
|
|
186
|
+
`concerns emerged? Be substantive — name the specific claims, people, ` +
|
|
187
|
+
`dates, and phrases that defined this stretch of your reading.\n\n` +
|
|
188
|
+
`Speak in your own voice as the one who read these portions. Target ` +
|
|
189
|
+
`~${targetTokens} tokens. Output only the body of your consolidation — ` +
|
|
190
|
+
`no preamble.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
22
193
|
|
|
23
194
|
/**
|
|
24
195
|
* Surrogate-safe string slice. Avoids cutting between a UTF-16 surrogate pair
|
|
@@ -59,6 +230,27 @@ export interface Chunk {
|
|
|
59
230
|
phaseType?: string;
|
|
60
231
|
}
|
|
61
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Point-in-time snapshot of compression progress, returned from
|
|
235
|
+
* `AutobiographicalStrategy.getProgressSnapshot()`. External observers
|
|
236
|
+
* (warmup scripts, dashboards) use this to track convergence without
|
|
237
|
+
* reaching into the strategy's protected fields.
|
|
238
|
+
*/
|
|
239
|
+
export interface AutobiographicalProgressSnapshot {
|
|
240
|
+
/** All chunks the strategy is tracking, compressed or not. */
|
|
241
|
+
totalChunks: number;
|
|
242
|
+
/** Chunks that already have an L1 summary. */
|
|
243
|
+
chunksCompressed: number;
|
|
244
|
+
/** Chunks queued for L1 compression. */
|
|
245
|
+
l1QueueLength: number;
|
|
246
|
+
/** Pending L1→L2 and L2→L3 merges. */
|
|
247
|
+
mergeQueueLength: number;
|
|
248
|
+
/** Stored summary counts per level (1 = raw L1, 2 = L1→L2 merge, 3 = L2→L3 merge). */
|
|
249
|
+
summaryCounts: { l1: number; l2: number; l3: number };
|
|
250
|
+
/** True if a compression or merge LLM call is currently in flight. */
|
|
251
|
+
pending: boolean;
|
|
252
|
+
}
|
|
253
|
+
|
|
62
254
|
/**
|
|
63
255
|
* Autobiographical chunking strategy.
|
|
64
256
|
* Compresses old conversation chunks into summaries in the model's own words.
|
|
@@ -98,12 +290,35 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
98
290
|
protected get counterStateId(): string { return `${this.ns}/autobio:counter`; }
|
|
99
291
|
protected get mergeQueueStateId(): string { return `${this.ns}/autobio:mergeQueue`; }
|
|
100
292
|
protected get pinsStateId(): string { return `${this.ns}/autobio:pins`; }
|
|
293
|
+
protected get resolutionsStateId(): string { return `${this.ns}/autobio:resolutions`; }
|
|
294
|
+
protected get locksStateId(): string { return `${this.ns}/autobio:locks`; }
|
|
101
295
|
|
|
102
296
|
/** Protected ranges (pins + documents). Loaded from chronicle in initialize. */
|
|
103
297
|
protected pins: ProtectedRange[] = [];
|
|
104
298
|
/** Monotonically increasing counter for pin ids. Persisted as part of the pins snapshot. */
|
|
105
299
|
protected pinIdCounter = 0;
|
|
106
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Per-message resolution state for the adaptive-resolution picker.
|
|
303
|
+
* - Key: MessageId
|
|
304
|
+
* - Value: currentResolution (0 = render raw, k>0 = render L_k recall)
|
|
305
|
+
*
|
|
306
|
+
* Maintained only when `config.adaptiveResolution` is true. Loaded from
|
|
307
|
+
* the chronicle on `initialize()` and persisted via the resolutions state
|
|
308
|
+
* slot so resolutions survive process restart and follow branches.
|
|
309
|
+
*/
|
|
310
|
+
protected resolutions: Map<MessageId, number> = new Map();
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Per-message lock state for the adaptive-resolution picker. Set via the
|
|
314
|
+
* programmatic `lockChunk(id)` API on the strategy. Locked messages are
|
|
315
|
+
* skipped by the picker. Persisted via the locks state slot.
|
|
316
|
+
*/
|
|
317
|
+
protected locked: Set<MessageId> = new Set();
|
|
318
|
+
|
|
319
|
+
/** Lazy picker instance, built from config.foldingStrategy. */
|
|
320
|
+
private _adaptivePicker: Picker | null = null;
|
|
321
|
+
|
|
107
322
|
constructor(config: Partial<AutobiographicalConfig> = {}) {
|
|
108
323
|
this.config = { ...DEFAULT_AUTOBIOGRAPHICAL_CONFIG, ...config };
|
|
109
324
|
// Hierarchical is on by default; set hierarchical: false to use legacy single-level
|
|
@@ -115,6 +330,97 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
115
330
|
this.config.l2BudgetTokens ??= 30000;
|
|
116
331
|
this.config.l1BudgetTokens ??= 30000;
|
|
117
332
|
}
|
|
333
|
+
// Adaptive-resolution defaults
|
|
334
|
+
if (this.config.adaptiveResolution) {
|
|
335
|
+
this.config.foldingStrategy ??= 'flat-profile';
|
|
336
|
+
this.config.compressionSlackRatio ??= 0.1;
|
|
337
|
+
this.config.speculativeProduction ??= true;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Lock a message so the adaptive picker won't change its resolution.
|
|
343
|
+
* No-op when adaptiveResolution is false. Set-only programmatic API per
|
|
344
|
+
* the design (no agent-facing tool in V1). Persisted to chronicle.
|
|
345
|
+
*/
|
|
346
|
+
lockChunk(id: MessageId): void {
|
|
347
|
+
if (this.locked.has(id)) return;
|
|
348
|
+
this.locked.add(id);
|
|
349
|
+
this.persistLocks();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Unlock a message so the adaptive picker may again change its resolution.
|
|
354
|
+
* No-op when adaptiveResolution is false. Persisted to chronicle.
|
|
355
|
+
*/
|
|
356
|
+
unlockChunk(id: MessageId): void {
|
|
357
|
+
if (!this.locked.has(id)) return;
|
|
358
|
+
this.locked.delete(id);
|
|
359
|
+
this.persistLocks();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Ingestion-time chunking hook.
|
|
364
|
+
*
|
|
365
|
+
* Active only when `config.adaptiveResolution` is true. Inspects the
|
|
366
|
+
* incoming message's text content; if its approximate token count
|
|
367
|
+
* exceeds the chunker's threshold, splits it into shards with a stable
|
|
368
|
+
* shared `bodyGroupId`. The framework then stores each shard as its own
|
|
369
|
+
* StoredMessage, and the render path concatenates them back into one
|
|
370
|
+
* API message at compile time (preserving KV cache structure).
|
|
371
|
+
*
|
|
372
|
+
* Multi-block content: text blocks are concatenated for chunking, then
|
|
373
|
+
* the resulting shards are emitted as text blocks. Non-text blocks
|
|
374
|
+
* (images, tool results) are passed through unchanged on the first
|
|
375
|
+
* shard only — they don't get split.
|
|
376
|
+
*
|
|
377
|
+
* See `docs/adaptive-resolution-design.md` §3.6.
|
|
378
|
+
*/
|
|
379
|
+
chunkIngressMessage(participant: string, content: ContentBlock[]): IngressChunkResult | null {
|
|
380
|
+
if (!this.config.adaptiveResolution) return null;
|
|
381
|
+
|
|
382
|
+
// Separate text and non-text blocks.
|
|
383
|
+
const textParts: string[] = [];
|
|
384
|
+
const nonTextBlocks: ContentBlock[] = [];
|
|
385
|
+
for (const block of content) {
|
|
386
|
+
if (block.type === 'text') {
|
|
387
|
+
textParts.push(block.text);
|
|
388
|
+
} else {
|
|
389
|
+
nonTextBlocks.push(block);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (textParts.length === 0) return null;
|
|
393
|
+
const combined = textParts.join('');
|
|
394
|
+
|
|
395
|
+
// Threshold and shard size derive from the strategy's existing
|
|
396
|
+
// targetChunkTokens setting: a message over 2x targetChunkTokens
|
|
397
|
+
// gets sharded into pieces of ~targetChunkTokens each. This keeps
|
|
398
|
+
// doc shards the same size as chat-message chunks for L1 production
|
|
399
|
+
// consistency.
|
|
400
|
+
const target = this.config.targetChunkTokens ?? DEFAULT_CHUNKER_OPTIONS.chunkSize;
|
|
401
|
+
const chunkerOpts = {
|
|
402
|
+
chunkThreshold: target * 2,
|
|
403
|
+
chunkSize: target,
|
|
404
|
+
charsPerToken: DEFAULT_CHUNKER_OPTIONS.charsPerToken,
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const sharded = chunkMessage(combined, chunkerOpts);
|
|
408
|
+
if (!sharded.wasSharded) return null;
|
|
409
|
+
|
|
410
|
+
// Build IngressChunkResult. Non-text blocks (if any) go on shard 0
|
|
411
|
+
// so the agent doesn't lose attachments. They're outside the chunker's
|
|
412
|
+
// concern but should still be available on the first shard.
|
|
413
|
+
const shards = sharded.shards.map((s) => ({
|
|
414
|
+
content: ([{ type: 'text', text: s.content }] as ContentBlock[]).concat(
|
|
415
|
+
s.index === 0 ? nonTextBlocks : []
|
|
416
|
+
),
|
|
417
|
+
shardIndex: s.index,
|
|
418
|
+
}));
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
bodyGroupId: sharded.bodyGroupId,
|
|
422
|
+
shards,
|
|
423
|
+
};
|
|
118
424
|
}
|
|
119
425
|
|
|
120
426
|
async initialize(ctx: StrategyContext): Promise<void> {
|
|
@@ -169,6 +475,22 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
169
475
|
strategy: 'snapshot',
|
|
170
476
|
});
|
|
171
477
|
} catch { /* already registered */ }
|
|
478
|
+
// Adaptive-resolution state slots — only registered when the flag is on
|
|
479
|
+
// so chronicles without the flag don't accumulate unused slots.
|
|
480
|
+
if (this.config.adaptiveResolution) {
|
|
481
|
+
try {
|
|
482
|
+
this.store.registerState({
|
|
483
|
+
id: this.resolutionsStateId,
|
|
484
|
+
strategy: 'snapshot',
|
|
485
|
+
});
|
|
486
|
+
} catch { /* already registered */ }
|
|
487
|
+
try {
|
|
488
|
+
this.store.registerState({
|
|
489
|
+
id: this.locksStateId,
|
|
490
|
+
strategy: 'snapshot',
|
|
491
|
+
});
|
|
492
|
+
} catch { /* already registered */ }
|
|
493
|
+
}
|
|
172
494
|
}
|
|
173
495
|
|
|
174
496
|
/**
|
|
@@ -205,6 +527,26 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
205
527
|
this.pins = [];
|
|
206
528
|
this.pinIdCounter = 0;
|
|
207
529
|
}
|
|
530
|
+
|
|
531
|
+
// Adaptive-resolution state — only present when flag was/is on
|
|
532
|
+
if (this.config.adaptiveResolution) {
|
|
533
|
+
const resState = this.store.getStateJson(this.resolutionsStateId);
|
|
534
|
+
this.resolutions = new Map();
|
|
535
|
+
if (resState && typeof resState === 'object') {
|
|
536
|
+
for (const [k, v] of Object.entries(resState as Record<string, unknown>)) {
|
|
537
|
+
if (typeof v === 'number' && v > 0) {
|
|
538
|
+
this.resolutions.set(k, v);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
const lockState = this.store.getStateJson(this.locksStateId);
|
|
543
|
+
this.locked = new Set();
|
|
544
|
+
if (Array.isArray(lockState)) {
|
|
545
|
+
for (const id of lockState) {
|
|
546
|
+
if (typeof id === 'string') this.locked.add(id);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
208
550
|
}
|
|
209
551
|
|
|
210
552
|
/** Persist the current pins + counter as a single snapshot. */
|
|
@@ -215,6 +557,23 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
215
557
|
});
|
|
216
558
|
}
|
|
217
559
|
|
|
560
|
+
/** Persist the current resolutions snapshot. Only stores non-zero entries
|
|
561
|
+
* to keep the slot compact. */
|
|
562
|
+
protected persistResolutions(): void {
|
|
563
|
+
if (!this.store) return;
|
|
564
|
+
const out: Record<string, number> = {};
|
|
565
|
+
for (const [id, level] of this.resolutions) {
|
|
566
|
+
if (level > 0) out[id] = level;
|
|
567
|
+
}
|
|
568
|
+
this.store.setStateJson(this.resolutionsStateId, out);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Persist the current locked-id snapshot. */
|
|
572
|
+
protected persistLocks(): void {
|
|
573
|
+
if (!this.store) return;
|
|
574
|
+
this.store.setStateJson(this.locksStateId, Array.from(this.locked));
|
|
575
|
+
}
|
|
576
|
+
|
|
218
577
|
// ============================================================================
|
|
219
578
|
// Pins / documents (protected ranges)
|
|
220
579
|
// ============================================================================
|
|
@@ -426,6 +785,137 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
426
785
|
return merge;
|
|
427
786
|
}
|
|
428
787
|
|
|
788
|
+
/**
|
|
789
|
+
* Translate produce ops emitted by the picker into concrete work items on
|
|
790
|
+
* the strategy's own queues. Two cases:
|
|
791
|
+
*
|
|
792
|
+
* - `level === 1`: the picker has asked for L1 coverage on a raw chunk.
|
|
793
|
+
* In the autobio chunker model, each chunk maps to exactly one L1, so
|
|
794
|
+
* we locate the chunk whose messages fall in `op.range` and ensure
|
|
795
|
+
* it is queued for L1 compression. If the chunker hasn't realized the
|
|
796
|
+
* message yet, we skip silently — the next `rebuildChunks` will pick
|
|
797
|
+
* it up.
|
|
798
|
+
*
|
|
799
|
+
* - `level >= 2`: the picker has asked for an L_n covering a contiguous
|
|
800
|
+
* range. We gather unmerged L_{n-1} summaries whose source ranges
|
|
801
|
+
* fall within that range (de-duplicated against entries already in
|
|
802
|
+
* `mergeQueue`) and enqueue a single merge over them. The merge fires
|
|
803
|
+
* on the next `tick()`.
|
|
804
|
+
*
|
|
805
|
+
* The handler is conservative: it never enqueues a singleton or empty
|
|
806
|
+
* merge, and it never re-enqueues an id that's already pending. That
|
|
807
|
+
* keeps the next-compile picker loop convergent even when the same
|
|
808
|
+
* produce op gets re-emitted before the work completes.
|
|
809
|
+
*/
|
|
810
|
+
protected handleProducedOps(ops: readonly FoldOp[]): void {
|
|
811
|
+
for (const op of ops) {
|
|
812
|
+
if (op.kind !== 'produce') continue;
|
|
813
|
+
if (op.level === 1) {
|
|
814
|
+
this.enqueueL1ForRange(op.range.firstChunkId, op.range.lastChunkId);
|
|
815
|
+
} else if (op.level >= 2) {
|
|
816
|
+
this.enqueueMergeForRange(
|
|
817
|
+
op.level,
|
|
818
|
+
op.range.firstChunkId,
|
|
819
|
+
op.range.lastChunkId,
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Ensure that chunks whose message range overlaps [firstMsgId..lastMsgId]
|
|
827
|
+
* are queued for L1 compression. No-op if the matching chunk is already
|
|
828
|
+
* compressed or already in the queue.
|
|
829
|
+
*/
|
|
830
|
+
protected enqueueL1ForRange(firstMsgId: MessageId, lastMsgId: MessageId): void {
|
|
831
|
+
const messageIdToChunk = new Map<MessageId, Chunk>();
|
|
832
|
+
for (const ch of this.chunks) {
|
|
833
|
+
for (const m of ch.messages) messageIdToChunk.set(m.id, ch);
|
|
834
|
+
}
|
|
835
|
+
const candidates = new Set<Chunk>();
|
|
836
|
+
const first = messageIdToChunk.get(firstMsgId);
|
|
837
|
+
const last = messageIdToChunk.get(lastMsgId);
|
|
838
|
+
if (first) candidates.add(first);
|
|
839
|
+
if (last) candidates.add(last);
|
|
840
|
+
// Also catch chunks fully spanned by the range (rare, but supports the
|
|
841
|
+
// case where the picker requests an L1 that should logically cover
|
|
842
|
+
// multiple chunks worth of messages — we err on the side of producing
|
|
843
|
+
// L1s for every spanned chunk).
|
|
844
|
+
if (first && last && first.index !== last.index) {
|
|
845
|
+
const [lo, hi] = first.index < last.index
|
|
846
|
+
? [first.index, last.index]
|
|
847
|
+
: [last.index, first.index];
|
|
848
|
+
for (let i = lo; i <= hi; i++) {
|
|
849
|
+
const ch = this.chunks[i];
|
|
850
|
+
if (ch) candidates.add(ch);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
for (const chunk of candidates) {
|
|
854
|
+
if (chunk.compressed) continue;
|
|
855
|
+
if (this.compressionQueue.includes(chunk.index)) continue;
|
|
856
|
+
this.compressionQueue.push(chunk.index);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Enqueue an L_{targetLevel} merge over unmerged L_{targetLevel-1}
|
|
862
|
+
* summaries whose source ranges fall within [firstMsgId..lastMsgId].
|
|
863
|
+
* No-op if fewer than 2 viable sources are available (a singleton merge
|
|
864
|
+
* would just rename a summary without consolidating).
|
|
865
|
+
*/
|
|
866
|
+
protected enqueueMergeForRange(
|
|
867
|
+
targetLevel: number,
|
|
868
|
+
firstMsgId: MessageId,
|
|
869
|
+
lastMsgId: MessageId,
|
|
870
|
+
): void {
|
|
871
|
+
const sourceLevel = targetLevel - 1;
|
|
872
|
+
|
|
873
|
+
// IDs already enqueued at this target level.
|
|
874
|
+
const queuedAtLevel = new Set<string>();
|
|
875
|
+
for (const m of this.mergeQueue) {
|
|
876
|
+
if (m.level === targetLevel) {
|
|
877
|
+
for (const id of m.sourceIds) queuedAtLevel.add(id);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Sequence index per message id, for "within range" tests. Use the
|
|
882
|
+
// current chunk store as the ordering source.
|
|
883
|
+
const messageOrder = new Map<MessageId, number>();
|
|
884
|
+
let seq = 0;
|
|
885
|
+
for (const ch of this.chunks) {
|
|
886
|
+
for (const m of ch.messages) {
|
|
887
|
+
messageOrder.set(m.id, seq++);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
const firstSeq = messageOrder.get(firstMsgId);
|
|
891
|
+
const lastSeq = messageOrder.get(lastMsgId);
|
|
892
|
+
|
|
893
|
+
const inRange = (msgId: MessageId): boolean => {
|
|
894
|
+
if (firstSeq === undefined || lastSeq === undefined) return true;
|
|
895
|
+
const s = messageOrder.get(msgId);
|
|
896
|
+
if (s === undefined) return false;
|
|
897
|
+
const [lo, hi] = firstSeq <= lastSeq ? [firstSeq, lastSeq] : [lastSeq, firstSeq];
|
|
898
|
+
return s >= lo && s <= hi;
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
const sources: SummaryEntry[] = [];
|
|
902
|
+
for (const s of this.summaries) {
|
|
903
|
+
if (s.level !== sourceLevel) continue;
|
|
904
|
+
if (getSummaryParentId(s)) continue;
|
|
905
|
+
if (queuedAtLevel.has(s.id)) continue;
|
|
906
|
+
if (!inRange(s.sourceRange.first) && !inRange(s.sourceRange.last)) continue;
|
|
907
|
+
sources.push(s);
|
|
908
|
+
}
|
|
909
|
+
if (sources.length < 2) return;
|
|
910
|
+
|
|
911
|
+
const N = this.config.mergeThreshold ?? 6;
|
|
912
|
+
const toMerge = sources.slice(0, N);
|
|
913
|
+
this.enqueueMerge({
|
|
914
|
+
level: targetLevel as SummaryLevel,
|
|
915
|
+
sourceIds: toMerge.map((s) => s.id),
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
|
|
429
919
|
checkReadiness(): ReadinessState {
|
|
430
920
|
if (this.pendingCompression) {
|
|
431
921
|
return {
|
|
@@ -580,6 +1070,31 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
580
1070
|
}
|
|
581
1071
|
}
|
|
582
1072
|
|
|
1073
|
+
/**
|
|
1074
|
+
* Snapshot of compression progress. Intended for external observers
|
|
1075
|
+
* (warmup scripts, dashboards) that need to track convergence without
|
|
1076
|
+
* reaching into protected fields. Values are point-in-time copies; mutating
|
|
1077
|
+
* them does not affect strategy state.
|
|
1078
|
+
*/
|
|
1079
|
+
getProgressSnapshot(): AutobiographicalProgressSnapshot {
|
|
1080
|
+
let chunksCompressed = 0;
|
|
1081
|
+
for (const c of this.chunks) if (c.compressed) chunksCompressed++;
|
|
1082
|
+
let l1 = 0, l2 = 0, l3 = 0;
|
|
1083
|
+
for (const s of this.summaries) {
|
|
1084
|
+
if (s.level === 1) l1++;
|
|
1085
|
+
else if (s.level === 2) l2++;
|
|
1086
|
+
else if (s.level === 3) l3++;
|
|
1087
|
+
}
|
|
1088
|
+
return {
|
|
1089
|
+
totalChunks: this.chunks.length,
|
|
1090
|
+
chunksCompressed,
|
|
1091
|
+
l1QueueLength: this.compressionQueue.length,
|
|
1092
|
+
mergeQueueLength: this.mergeQueue.length,
|
|
1093
|
+
summaryCounts: { l1, l2, l3 },
|
|
1094
|
+
pending: this.pendingCompression !== null,
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
583
1098
|
select(
|
|
584
1099
|
store: MessageStoreView,
|
|
585
1100
|
log: ContextLogView,
|
|
@@ -587,6 +1102,9 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
587
1102
|
): ContextEntry[] {
|
|
588
1103
|
this.rebuildChunks(store);
|
|
589
1104
|
|
|
1105
|
+
if (this.config.adaptiveResolution) {
|
|
1106
|
+
return this.selectAdaptive(store, budget);
|
|
1107
|
+
}
|
|
590
1108
|
return this.config.hierarchical
|
|
591
1109
|
? this.selectHierarchical(store, budget)
|
|
592
1110
|
: this.selectLegacy(store, log, budget);
|
|
@@ -984,52 +1502,195 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
984
1502
|
throw new Error('No membrane instance for compression');
|
|
985
1503
|
}
|
|
986
1504
|
|
|
987
|
-
const chunkMessageIds = new Set(chunk.messages.map(m => m.id));
|
|
988
|
-
const { shownL3, shownL2, shownL1 } = this.getAntiRedundantSummaries(chunkMessageIds);
|
|
989
|
-
|
|
990
1505
|
const targetTokens = this.config.summaryTargetTokens ?? 2000;
|
|
991
|
-
const
|
|
1506
|
+
const agentParticipant = this.config.summaryParticipant ?? 'Claude';
|
|
992
1507
|
|
|
993
|
-
// Build
|
|
1508
|
+
// Build the KV-preserving prompt per hermes-autobio spec:
|
|
1509
|
+
//
|
|
1510
|
+
// 1. Prior summaries — narrativized as CM-asks / agent-recalls
|
|
1511
|
+
// pairs, in source order. The unmerged frontier of the
|
|
1512
|
+
// summary forest: any summary that has not yet been merged
|
|
1513
|
+
// into a higher level. After merges run, the L_{k+1} replaces
|
|
1514
|
+
// its L_k children — using the children plus their parent
|
|
1515
|
+
// doubles the prompt size unboundedly.
|
|
1516
|
+
// 2. Head — raw messages before the chunk that aren't already
|
|
1517
|
+
// represented by a prior summary.
|
|
1518
|
+
// 3. Marker — in-band signal that a memory is about to form.
|
|
1519
|
+
// 4. Chunk — raw messages being compressed, as the agent
|
|
1520
|
+
// experienced them.
|
|
1521
|
+
// 5. Instruction — doc-aware if the chunk is part of a bodyGroup.
|
|
1522
|
+
//
|
|
1523
|
+
// There is intentionally NO tail_after_chunk: that would leak
|
|
1524
|
+
// future information into the model's KV state and corrupt the
|
|
1525
|
+
// as-of framing of memory formation.
|
|
994
1526
|
const llmMessages: Array<{ participant: string; content: ContentBlock[] }> = [];
|
|
995
1527
|
|
|
996
|
-
//
|
|
997
|
-
|
|
998
|
-
|
|
1528
|
+
// ---- 1. Prior recall pairs ----
|
|
1529
|
+
// Filter to the unmerged frontier: any summary whose `mergedInto`
|
|
1530
|
+
// is unset. After merge, the children's mergedInto points at the
|
|
1531
|
+
// parent and the parent stands alone with that source range. The
|
|
1532
|
+
// original "ALL L1s regardless of merge state" rule was a fidelity
|
|
1533
|
+
// optimization that scales catastrophically: a 4000-message import
|
|
1534
|
+
// converged to ~500 L1s that never aged out, blowing the 200k
|
|
1535
|
+
// window around chunk 118.
|
|
1536
|
+
const priorSummaries = this.summaries
|
|
1537
|
+
.filter((s) => !s.mergedInto)
|
|
1538
|
+
.sort((a, b) => a.sourceRange.first.localeCompare(b.sourceRange.first));
|
|
1539
|
+
|
|
1540
|
+
// Token-budget cap on recall pairs as defense-in-depth. With merged
|
|
1541
|
+
// exclusion the set is bounded by `mergeThreshold^depth`, but a
|
|
1542
|
+
// conversation with thousands of unmerged top-level summaries can
|
|
1543
|
+
// still overflow. Newest-first selection so proximate context is
|
|
1544
|
+
// preferred; the kept set is re-sorted chronologically below. We
|
|
1545
|
+
// always keep at least one summary even if it's larger than the
|
|
1546
|
+
// budget — otherwise a single oversized summary would leave the
|
|
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();
|
|
1559
|
+
if (keptSummaries.length < priorSummaries.length) {
|
|
1560
|
+
const dropped = priorSummaries.length - keptSummaries.length;
|
|
1561
|
+
console.warn(
|
|
1562
|
+
`autobio: recall-pair budget capped (${keptSummaries.length}/${priorSummaries.length} summaries kept, ` +
|
|
1563
|
+
`~${recallTokens} tokens, budget ${recallBudget}; ${dropped} oldest dropped this compression).`,
|
|
1564
|
+
);
|
|
1565
|
+
logCompressionCall({
|
|
1566
|
+
event: 'recall-budget-capped',
|
|
1567
|
+
kept: keptSummaries.length,
|
|
1568
|
+
total: priorSummaries.length,
|
|
1569
|
+
tokens: recallTokens,
|
|
1570
|
+
budgetTokens: recallBudget,
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
for (const s of keptSummaries) {
|
|
1575
|
+
llmMessages.push({
|
|
1576
|
+
participant: 'Context Manager',
|
|
1577
|
+
content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
|
|
1578
|
+
});
|
|
999
1579
|
llmMessages.push({
|
|
1000
|
-
participant:
|
|
1580
|
+
participant: agentParticipant,
|
|
1001
1581
|
content: [{ type: 'text', text: s.content }],
|
|
1002
1582
|
});
|
|
1003
1583
|
}
|
|
1004
1584
|
|
|
1005
|
-
//
|
|
1006
|
-
|
|
1585
|
+
// ---- 2. Head window (raw, ALWAYS present) ----
|
|
1586
|
+
//
|
|
1587
|
+
// The head is the foundational identity anchor: the actual opening
|
|
1588
|
+
// of the chronicle (the user's first message, the agent's first
|
|
1589
|
+
// reply, the system context if any). It establishes WHO is speaking
|
|
1590
|
+
// to WHOM. Without it, when the chunk content is heavily first-person
|
|
1591
|
+
// from someone other than the agent (e.g., a user-shared document),
|
|
1592
|
+
// the agent loses its first-person grounding and drifts into the
|
|
1593
|
+
// content author's voice.
|
|
1594
|
+
//
|
|
1595
|
+
// The head is the configured head window — not "everything before
|
|
1596
|
+
// the chunk." For doc-heavy chronicles, "everything before" would
|
|
1597
|
+
// be hundreds of thousands of tokens; the recall pairs already
|
|
1598
|
+
// represent that intermediate content. The head is just the
|
|
1599
|
+
// permanent prefix that the original instance always saw.
|
|
1600
|
+
//
|
|
1601
|
+
// Head messages are excluded from compression by `getCompressibleMessages`
|
|
1602
|
+
// (they're outside the chunking range), so they won't appear in
|
|
1603
|
+
// any L1's sourceIds — no overlap with the recall pairs above.
|
|
1604
|
+
const allMessages = ctx.messageStore.getAll();
|
|
1605
|
+
const headStartIdx = this.getHeadWindowStartIndex(ctx.messageStore);
|
|
1606
|
+
const headEndIdx = this.getHeadWindowEnd(ctx.messageStore);
|
|
1607
|
+
for (let i = headStartIdx; i < headEndIdx && i < allMessages.length; i++) {
|
|
1608
|
+
const m = allMessages[i];
|
|
1609
|
+
llmMessages.push({ participant: m.participant, content: m.content });
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// Any raw messages between the head and the chunk that aren't yet
|
|
1613
|
+
// represented by any summary — usually empty in adaptive-resolution
|
|
1614
|
+
// mode, since chunking proceeds contiguously and summaries cover
|
|
1615
|
+
// everything up to the chunk being processed. Uses the full
|
|
1616
|
+
// priorSummaries set (not the budget-capped keptSummaries) because
|
|
1617
|
+
// the dedup question is "is this raw message covered by *any* live
|
|
1618
|
+
// summary?" — a budget-dropped summary doesn't make the underlying
|
|
1619
|
+
// raw messages reappear.
|
|
1620
|
+
const chunkFirstId = chunk.messages[0]?.id;
|
|
1621
|
+
if (chunkFirstId) {
|
|
1622
|
+
const priorSummaryMessageIds = new Set<string>();
|
|
1623
|
+
for (const s of priorSummaries) {
|
|
1624
|
+
for (const id of s.sourceIds) priorSummaryMessageIds.add(id);
|
|
1625
|
+
}
|
|
1626
|
+
const chunkStartIdx = allMessages.findIndex((m) => m.id === chunkFirstId);
|
|
1627
|
+
for (let i = headEndIdx; i < chunkStartIdx && i < allMessages.length; i++) {
|
|
1628
|
+
const m = allMessages[i];
|
|
1629
|
+
if (priorSummaryMessageIds.has(m.id)) continue;
|
|
1630
|
+
llmMessages.push({ participant: m.participant, content: m.content });
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// ---- 3. In-band marker ----
|
|
1007
1635
|
llmMessages.push({
|
|
1008
1636
|
participant: 'Context Manager',
|
|
1009
|
-
content: [{
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1637
|
+
content: [{ type: 'text', text: COMPRESSION_MARKER }],
|
|
1638
|
+
});
|
|
1639
|
+
|
|
1640
|
+
// ---- 4. Chunk messages raw ----
|
|
1641
|
+
for (const m of chunk.messages) {
|
|
1642
|
+
llmMessages.push({ participant: m.participant, content: m.content });
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// ---- 5. Instruction (reading-mode aware) ----
|
|
1646
|
+
//
|
|
1647
|
+
// When the chunk is a portion of a substantially larger sharded message
|
|
1648
|
+
// (≥ 2× chunk size), use the reading-mode instruction. It avoids the
|
|
1649
|
+
// "form a memory of what this contained" framing — which, for content
|
|
1650
|
+
// heavily first-person from someone other than the agent (a user-shared
|
|
1651
|
+
// doc), leads the model to adopt the content author's voice. Instead,
|
|
1652
|
+
// it asks what reading was like and what was learned, forcing the
|
|
1653
|
+
// model to reflect from its own vantage point in agent-first-person.
|
|
1654
|
+
const docContext = this.detectDocContext(chunk, ctx);
|
|
1655
|
+
const instructionText = docContext
|
|
1656
|
+
? this.getReadingChunkInstruction(chunk, docContext.totalTokens, targetTokens)
|
|
1657
|
+
: this.getCompressionInstruction(chunk, targetTokens);
|
|
1658
|
+
llmMessages.push({
|
|
1659
|
+
participant: 'Context Manager',
|
|
1660
|
+
content: [{ type: 'text', text: instructionText }],
|
|
1013
1661
|
});
|
|
1014
1662
|
|
|
1015
1663
|
// Collapse consecutive same-participant messages for API compliance
|
|
1016
1664
|
const collapsed = this.collapseConsecutiveMessages(llmMessages);
|
|
1017
1665
|
|
|
1666
|
+
// NO system prompt. The agent's identity is established by the head
|
|
1667
|
+
// (the actual conversation opening — user message + agent reply that
|
|
1668
|
+
// grounded the original instance). A system prompt would (a) add a
|
|
1669
|
+
// synthetic header the original instance never saw, disturbing KV
|
|
1670
|
+
// consistency between the summarizer and the original instance, and
|
|
1671
|
+
// (b) provide an alternative identity source that competes with the
|
|
1672
|
+
// structural one carried by the conversation itself. Anchoring
|
|
1673
|
+
// identity by the chronicle's actual head is more honest.
|
|
1018
1674
|
const request: NormalizedRequest = {
|
|
1019
1675
|
messages: collapsed.map(m => ({ participant: m.participant, content: m.content })),
|
|
1020
|
-
system: 'You are forming autobiographical memories of a conversation.',
|
|
1021
1676
|
config: {
|
|
1022
1677
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
1023
1678
|
maxTokens: Math.round(targetTokens * 1.5),
|
|
1024
1679
|
},
|
|
1025
1680
|
};
|
|
1026
1681
|
|
|
1682
|
+
const callStart = Date.now();
|
|
1683
|
+
let logResponse: string | undefined;
|
|
1684
|
+
let logError: string | undefined;
|
|
1685
|
+
let logSummaryId: string | undefined;
|
|
1686
|
+
|
|
1027
1687
|
try {
|
|
1028
1688
|
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
1029
1689
|
const summaryText = response.content
|
|
1030
1690
|
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
|
1031
1691
|
.map(b => b.text)
|
|
1032
1692
|
.join('\n');
|
|
1693
|
+
logResponse = summaryText;
|
|
1033
1694
|
|
|
1034
1695
|
const messageIds = chunk.messages.map(m => m.id);
|
|
1035
1696
|
const entry: SummaryEntry = {
|
|
@@ -1051,11 +1712,42 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1051
1712
|
chunk.compressed = true;
|
|
1052
1713
|
chunk.summaryId = entry.id;
|
|
1053
1714
|
this._compressionCount++;
|
|
1715
|
+
logSummaryId = entry.id;
|
|
1054
1716
|
|
|
1055
1717
|
this.checkMergeThreshold();
|
|
1056
1718
|
} catch (error) {
|
|
1057
1719
|
console.error('Failed to compress chunk (hierarchical):', error);
|
|
1720
|
+
logError = error instanceof Error ? error.message : String(error);
|
|
1058
1721
|
throw error;
|
|
1722
|
+
} finally {
|
|
1723
|
+
logCompressionCall({
|
|
1724
|
+
operation: 'compress_l1',
|
|
1725
|
+
system: null,
|
|
1726
|
+
messages: collapsed.map((m) => ({
|
|
1727
|
+
participant: m.participant,
|
|
1728
|
+
// Flatten content for logging — store text only; binary content
|
|
1729
|
+
// would bloat the log and isn't typical in compression input.
|
|
1730
|
+
text: m.content
|
|
1731
|
+
.filter((b: ContentBlock) => b.type === 'text')
|
|
1732
|
+
.map((b: any) => b.text)
|
|
1733
|
+
.join(''),
|
|
1734
|
+
})),
|
|
1735
|
+
metadata: {
|
|
1736
|
+
chunk_message_ids: chunk.messages.map((m) => m.id),
|
|
1737
|
+
chunk_size: chunk.messages.length,
|
|
1738
|
+
prior_summary_count: priorSummaries.length,
|
|
1739
|
+
prior_summary_count_kept: keptSummaries.length,
|
|
1740
|
+
prior_summary_tokens: recallTokens,
|
|
1741
|
+
has_doc_context: docContext !== null,
|
|
1742
|
+
doc_context: docContext,
|
|
1743
|
+
target_tokens: targetTokens,
|
|
1744
|
+
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
1745
|
+
latency_ms: Date.now() - callStart,
|
|
1746
|
+
summary_id: logSummaryId,
|
|
1747
|
+
},
|
|
1748
|
+
response: logResponse,
|
|
1749
|
+
error: logError,
|
|
1750
|
+
});
|
|
1059
1751
|
}
|
|
1060
1752
|
}
|
|
1061
1753
|
|
|
@@ -1069,6 +1761,11 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1069
1761
|
* summaries when the queue eventually drains.
|
|
1070
1762
|
*/
|
|
1071
1763
|
protected checkMergeThreshold(): void {
|
|
1764
|
+
if (this.config.speculativeProduction) {
|
|
1765
|
+
this.checkMergeThresholdRecursive();
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1072
1769
|
const threshold = this.config.mergeThreshold ?? 6;
|
|
1073
1770
|
|
|
1074
1771
|
// IDs that are already part of a queued merge — exclude them from
|
|
@@ -1105,6 +1802,55 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1105
1802
|
}
|
|
1106
1803
|
}
|
|
1107
1804
|
|
|
1805
|
+
/**
|
|
1806
|
+
* Bottom-up speculative pre-producer (design doc §3.5 / §7.2).
|
|
1807
|
+
*
|
|
1808
|
+
* Recursive variant of `checkMergeThreshold` for the unbounded L_n
|
|
1809
|
+
* design. Walks every level present in the archive; for any level k
|
|
1810
|
+
* with ≥ N orphans (no parent), enqueues an L_{k+1} merge. After that
|
|
1811
|
+
* L_{k+1} is produced and `executeMerge` calls this again, the recursion
|
|
1812
|
+
* naturally cascades: 6 L1s → 1 L2; 6 L2s → 1 L3; 6 L3s → 1 L4; ...
|
|
1813
|
+
*
|
|
1814
|
+
* Only fires when `config.speculativeProduction` is true. Default true
|
|
1815
|
+
* for adaptiveResolution=true, false otherwise. The non-speculative path
|
|
1816
|
+
* (above) preserves the original L1→L2→L3 behavior for non-adaptive
|
|
1817
|
+
* deployments.
|
|
1818
|
+
*/
|
|
1819
|
+
protected checkMergeThresholdRecursive(): void {
|
|
1820
|
+
const threshold = this.config.mergeThreshold ?? 6;
|
|
1821
|
+
|
|
1822
|
+
// Build per-level sets of source-ids already enqueued for merging,
|
|
1823
|
+
// so we don't re-enqueue them while a merge is pending.
|
|
1824
|
+
const queuedSources = new Map<number, Set<string>>();
|
|
1825
|
+
for (const m of this.mergeQueue) {
|
|
1826
|
+
// m.level is the TARGET level; the sources are at level (m.level - 1).
|
|
1827
|
+
const sourceLevel = m.level - 1;
|
|
1828
|
+
if (!queuedSources.has(sourceLevel)) queuedSources.set(sourceLevel, new Set());
|
|
1829
|
+
for (const id of m.sourceIds) queuedSources.get(sourceLevel)!.add(id);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// Walk every level present in the archive. Iterate from low to high
|
|
1833
|
+
// so when an L_{k+1} merge is enqueued and immediately produced, this
|
|
1834
|
+
// same check sees the new L_{k+1} on the next call and can roll up.
|
|
1835
|
+
let maxLevel = 0;
|
|
1836
|
+
for (const s of this.summaries) {
|
|
1837
|
+
if (s.level > maxLevel) maxLevel = s.level;
|
|
1838
|
+
}
|
|
1839
|
+
for (let level = 1; level <= maxLevel; level++) {
|
|
1840
|
+
const queued = queuedSources.get(level) ?? new Set();
|
|
1841
|
+
const unmerged = this.summaries.filter(
|
|
1842
|
+
s => s.level === level && !getSummaryParentId(s) && !queued.has(s.id),
|
|
1843
|
+
);
|
|
1844
|
+
if (unmerged.length >= threshold) {
|
|
1845
|
+
const toMerge = unmerged.slice(0, threshold);
|
|
1846
|
+
this.enqueueMerge({
|
|
1847
|
+
level: level + 1,
|
|
1848
|
+
sourceIds: toMerge.map(s => s.id),
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1108
1854
|
/**
|
|
1109
1855
|
* Merge N summaries at one level into a single summary at the next level.
|
|
1110
1856
|
* Uses self-voice consolidation prompt.
|
|
@@ -1141,57 +1887,225 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1141
1887
|
const targetTokens = this.config.summaryTargetTokens ?? 2000;
|
|
1142
1888
|
const participant = this.config.summaryParticipant ?? 'Claude';
|
|
1143
1889
|
|
|
1144
|
-
// Build
|
|
1890
|
+
// Build the merge prompt with one-level-deeper target expansion +
|
|
1891
|
+
// prefix of older context:
|
|
1892
|
+
//
|
|
1893
|
+
// 1. PREFIX — head messages + prior L1 recall pairs for content
|
|
1894
|
+
// that comes chronologically BEFORE the merge range. "Fill
|
|
1895
|
+
// lower orbitals first" per the spec: regardless of how
|
|
1896
|
+
// compressed the live view is, the summarizer always gets L1
|
|
1897
|
+
// fidelity for prior content. Older L2/L3 markers exist for
|
|
1898
|
+
// live-view compactness, not for the summarizer.
|
|
1899
|
+
//
|
|
1900
|
+
// 2. TARGET — the sources expanded ONE LEVEL DEEPER than they
|
|
1901
|
+
// themselves are. For L2 merge (sources at L1): expand to
|
|
1902
|
+
// raw L0 messages — the model sees the actual conversation
|
|
1903
|
+
// that the 6 L1s consolidate. For L3 merge (sources at L2):
|
|
1904
|
+
// expand to the L1s under each L2 (36 L1s as recall pairs).
|
|
1905
|
+
// For L_n merge (sources at L_{n-1}): expand to L_{n-2}.
|
|
1906
|
+
// This gives the model substantively more content to ground
|
|
1907
|
+
// the consolidation in than just the 6 surface summaries.
|
|
1908
|
+
//
|
|
1909
|
+
// 3. INSTRUCTION — "consolidate N memories preserving the
|
|
1910
|
+
// through-line, in first person".
|
|
1911
|
+
//
|
|
1912
|
+
// No tail-after-merge: same as-of principle as L1 compression. The
|
|
1913
|
+
// consolidation is being formed at the moment the last source was
|
|
1914
|
+
// ready, so nothing after that is visible.
|
|
1145
1915
|
const llmMessages: Array<{ participant: string; content: ContentBlock[] }> = [];
|
|
1146
1916
|
|
|
1147
|
-
//
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1917
|
+
// Build lookup maps
|
|
1918
|
+
const summariesById = new Map<string, SummaryEntry>();
|
|
1919
|
+
for (const s of this.summaries) summariesById.set(s.id, s);
|
|
1920
|
+
const allMessages = ctx.messageStore.getAll();
|
|
1921
|
+
const messageById = new Map<MessageId, typeof allMessages[number]>();
|
|
1922
|
+
for (const m of allMessages) messageById.set(m.id, m);
|
|
1923
|
+
|
|
1924
|
+
// Compute every leaf message id covered by this merge's lineage —
|
|
1925
|
+
// these are part of the TARGET and must not also appear in the
|
|
1926
|
+
// PREFIX as head content.
|
|
1927
|
+
const sourceLeafIds = new Set<MessageId>();
|
|
1928
|
+
const collectLeaves = (s: SummaryEntry): void => {
|
|
1929
|
+
if (s.sourceLevel === 0) {
|
|
1930
|
+
for (const id of s.sourceIds) sourceLeafIds.add(id);
|
|
1931
|
+
} else {
|
|
1932
|
+
for (const childId of s.sourceIds) {
|
|
1933
|
+
const child = summariesById.get(childId);
|
|
1934
|
+
if (child) collectLeaves(child);
|
|
1935
|
+
}
|
|
1156
1936
|
}
|
|
1937
|
+
};
|
|
1938
|
+
for (const src of sources) collectLeaves(src);
|
|
1939
|
+
|
|
1940
|
+
// Find the start of the merge range in the message store.
|
|
1941
|
+
const mergeFirstMsgId = sources[0].sourceRange.first;
|
|
1942
|
+
const mergeStartIdx = allMessages.findIndex((m) => m.id === mergeFirstMsgId);
|
|
1943
|
+
|
|
1944
|
+
// ---- 1a. HEAD WINDOW (raw, ALWAYS present) ----
|
|
1945
|
+
//
|
|
1946
|
+
// The head window is the foundational identity anchor — the actual
|
|
1947
|
+
// opening of the chronicle. It establishes who is speaking to whom.
|
|
1948
|
+
// Without it, when the merge target's content is heavily first-person
|
|
1949
|
+
// from someone other than the agent, the agent loses its first-person
|
|
1950
|
+
// grounding and drifts into the content author's voice.
|
|
1951
|
+
const headStartIdx = this.getHeadWindowStartIndex(ctx.messageStore);
|
|
1952
|
+
const headEndIdx = this.getHeadWindowEnd(ctx.messageStore);
|
|
1953
|
+
for (let i = headStartIdx; i < headEndIdx && i < allMessages.length; i++) {
|
|
1954
|
+
const m = allMessages[i];
|
|
1955
|
+
llmMessages.push({ participant: m.participant, content: m.content });
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// ---- 1b. PRIOR L1 RECALL PAIRS (chronologically before merge range) ----
|
|
1959
|
+
// L1s whose entire source range is before the merge range and that
|
|
1960
|
+
// aren't part of the merge tree. Sort by source position.
|
|
1961
|
+
const priorL1s = this.summaries
|
|
1962
|
+
.filter((s) => s.level === 1)
|
|
1963
|
+
.filter((s) => {
|
|
1964
|
+
// Exclude any L1 that's an ancestor of our merge target
|
|
1965
|
+
for (const lid of s.sourceIds) if (sourceLeafIds.has(lid)) return false;
|
|
1966
|
+
// Include only if it starts strictly before the merge range
|
|
1967
|
+
const firstIdx = allMessages.findIndex((m) => m.id === s.sourceRange.first);
|
|
1968
|
+
return firstIdx >= 0 && (mergeStartIdx < 0 || firstIdx < mergeStartIdx);
|
|
1969
|
+
})
|
|
1970
|
+
.sort((a, b) => {
|
|
1971
|
+
const ai = allMessages.findIndex((m) => m.id === a.sourceRange.first);
|
|
1972
|
+
const bi = allMessages.findIndex((m) => m.id === b.sourceRange.first);
|
|
1973
|
+
return ai - bi;
|
|
1974
|
+
});
|
|
1975
|
+
|
|
1976
|
+
const priorL1MessageIds = new Set<MessageId>();
|
|
1977
|
+
for (const s of priorL1s) {
|
|
1978
|
+
for (const id of s.sourceIds) priorL1MessageIds.add(id);
|
|
1157
1979
|
}
|
|
1158
|
-
// For L3 merge: no higher context exists
|
|
1159
1980
|
|
|
1160
|
-
|
|
1161
|
-
|
|
1981
|
+
for (const s of priorL1s) {
|
|
1982
|
+
llmMessages.push({
|
|
1983
|
+
participant: 'Context Manager',
|
|
1984
|
+
content: [{ type: 'text', text: `[CM] Recall memory ${s.id}.` }],
|
|
1985
|
+
});
|
|
1162
1986
|
llmMessages.push({
|
|
1163
1987
|
participant,
|
|
1164
|
-
content: [{ type: 'text', text:
|
|
1988
|
+
content: [{ type: 'text', text: s.content }],
|
|
1165
1989
|
});
|
|
1166
1990
|
}
|
|
1167
1991
|
|
|
1168
|
-
//
|
|
1169
|
-
|
|
1992
|
+
// Raw middle: any messages between the head window and the merge
|
|
1993
|
+
// range that aren't covered by a prior L1 or the merge tree.
|
|
1994
|
+
// Usually empty (chunking is contiguous).
|
|
1995
|
+
if (mergeStartIdx >= 0) {
|
|
1996
|
+
for (let i = headEndIdx; i < mergeStartIdx; i++) {
|
|
1997
|
+
const m = allMessages[i];
|
|
1998
|
+
if (priorL1MessageIds.has(m.id)) continue;
|
|
1999
|
+
if (sourceLeafIds.has(m.id)) continue;
|
|
2000
|
+
llmMessages.push({ participant: m.participant, content: m.content });
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// ---- 2. TARGET: expand sources one level deeper ----
|
|
2005
|
+
// For L2 (sources at L1, sourceLevel=0): expand to raw L0 messages.
|
|
2006
|
+
// For L3+ (sources at L_{n-1}, sourceLevel=n-2): expand to L_{n-2}
|
|
2007
|
+
// summaries as recall pairs.
|
|
2008
|
+
for (const src of sources) {
|
|
2009
|
+
if (src.sourceLevel === 0) {
|
|
2010
|
+
// Source is an L1; its sourceIds are raw message ids. Emit them raw.
|
|
2011
|
+
for (const messageId of src.sourceIds) {
|
|
2012
|
+
const m = messageById.get(messageId);
|
|
2013
|
+
if (m) {
|
|
2014
|
+
llmMessages.push({ participant: m.participant, content: m.content });
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
} else {
|
|
2018
|
+
// Source is L2+; its sourceIds point to summaries one level
|
|
2019
|
+
// below. Emit each as a recall pair.
|
|
2020
|
+
for (const childId of src.sourceIds) {
|
|
2021
|
+
const child = summariesById.get(childId);
|
|
2022
|
+
if (!child) continue;
|
|
2023
|
+
llmMessages.push({
|
|
2024
|
+
participant: 'Context Manager',
|
|
2025
|
+
content: [{ type: 'text', text: `[CM] Recall memory ${child.id}.` }],
|
|
2026
|
+
});
|
|
2027
|
+
llmMessages.push({
|
|
2028
|
+
participant,
|
|
2029
|
+
content: [{ type: 'text', text: child.content }],
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
// ---- 3. INSTRUCTION ----
|
|
2036
|
+
// sourceLevelShown is the level of content the model actually sees
|
|
2037
|
+
// (one level below the sources themselves).
|
|
2038
|
+
const sourceLevelShown =
|
|
2039
|
+
sources[0].sourceLevel === 0 ? 0 : sources[0].level - 1;
|
|
2040
|
+
|
|
2041
|
+
// Reading-mode detection: when ALL the merge's leaf messages are
|
|
2042
|
+
// shards of the same bodyGroup, we know the agent was reading a
|
|
2043
|
+
// substantially larger message rather than conversing. The
|
|
2044
|
+
// reading-mode merge instruction asks what reading the stretch was
|
|
2045
|
+
// like instead of asking for an impersonal consolidation, which
|
|
2046
|
+
// forces the agent's vantage point and prevents drift into the
|
|
2047
|
+
// content author's voice. Same principle as the L1 case.
|
|
2048
|
+
let mergeReadingContext: { totalTokens: number } | null = null;
|
|
2049
|
+
if (sourceLeafIds.size > 0) {
|
|
2050
|
+
const leafBodyGroupIds = new Set<string | undefined>();
|
|
2051
|
+
for (const leafId of sourceLeafIds) {
|
|
2052
|
+
const m = messageById.get(leafId);
|
|
2053
|
+
leafBodyGroupIds.add(m?.bodyGroupId);
|
|
2054
|
+
}
|
|
2055
|
+
if (leafBodyGroupIds.size === 1) {
|
|
2056
|
+
const groupId = [...leafBodyGroupIds][0];
|
|
2057
|
+
if (groupId) {
|
|
2058
|
+
let totalTokens = 0;
|
|
2059
|
+
for (const m of allMessages) {
|
|
2060
|
+
if (m.bodyGroupId === groupId) {
|
|
2061
|
+
totalTokens += ctx.messageStore.estimateTokens(m);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
mergeReadingContext = { totalTokens };
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
const mergeInstructionText = mergeReadingContext
|
|
2069
|
+
? this.getReadingMergeInstruction(
|
|
2070
|
+
targetLevel,
|
|
2071
|
+
sources,
|
|
2072
|
+
mergeReadingContext.totalTokens,
|
|
2073
|
+
targetTokens,
|
|
2074
|
+
)
|
|
2075
|
+
: this.getMergeInstruction(targetLevel, sources, targetTokens);
|
|
1170
2076
|
llmMessages.push({
|
|
1171
2077
|
participant: 'Context Manager',
|
|
1172
2078
|
content: [{
|
|
1173
2079
|
type: 'text',
|
|
1174
|
-
text:
|
|
2080
|
+
text: mergeInstructionText,
|
|
1175
2081
|
}],
|
|
1176
2082
|
});
|
|
1177
2083
|
|
|
1178
2084
|
const collapsed = this.collapseConsecutiveMessages(llmMessages);
|
|
1179
2085
|
|
|
2086
|
+
// NO system prompt — identity is established by the head window
|
|
2087
|
+
// (present at the start of llmMessages above) and by the prior
|
|
2088
|
+
// recall pairs. Same rationale as compressChunkHierarchical.
|
|
1180
2089
|
const request: NormalizedRequest = {
|
|
1181
2090
|
messages: collapsed.map(m => ({ participant: m.participant, content: m.content })),
|
|
1182
|
-
system: 'You are forming autobiographical memories of a conversation.',
|
|
1183
2091
|
config: {
|
|
1184
2092
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
1185
2093
|
maxTokens: Math.round(targetTokens * 1.5),
|
|
1186
2094
|
},
|
|
1187
2095
|
};
|
|
1188
2096
|
|
|
2097
|
+
const callStart = Date.now();
|
|
2098
|
+
let logResponse: string | undefined;
|
|
2099
|
+
let logError: string | undefined;
|
|
2100
|
+
let logNewSummaryId: string | undefined;
|
|
2101
|
+
|
|
1189
2102
|
try {
|
|
1190
2103
|
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
1191
2104
|
const mergedText = response.content
|
|
1192
2105
|
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
|
1193
2106
|
.map(b => b.text)
|
|
1194
2107
|
.join('\n');
|
|
2108
|
+
logResponse = mergedText;
|
|
1195
2109
|
|
|
1196
2110
|
// Compute source range from constituent summaries
|
|
1197
2111
|
const sourceRange = {
|
|
@@ -1210,6 +2124,7 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1210
2124
|
sourceRange,
|
|
1211
2125
|
created: Date.now(),
|
|
1212
2126
|
};
|
|
2127
|
+
logNewSummaryId = newEntry.id;
|
|
1213
2128
|
|
|
1214
2129
|
// Append the new merged entry first, then mark sources. Persist each
|
|
1215
2130
|
// mergedInto edit individually so chronicle reflects the same shape as
|
|
@@ -1226,10 +2141,602 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1226
2141
|
this.checkMergeThreshold();
|
|
1227
2142
|
} catch (error) {
|
|
1228
2143
|
console.error(`Failed to merge summaries into L${targetLevel}:`, error);
|
|
2144
|
+
logError = error instanceof Error ? error.message : String(error);
|
|
1229
2145
|
throw error;
|
|
2146
|
+
} finally {
|
|
2147
|
+
logCompressionCall({
|
|
2148
|
+
operation: `merge_l${targetLevel}`,
|
|
2149
|
+
system: null,
|
|
2150
|
+
messages: collapsed.map((m) => ({
|
|
2151
|
+
participant: m.participant,
|
|
2152
|
+
text: m.content
|
|
2153
|
+
.filter((b: ContentBlock) => b.type === 'text')
|
|
2154
|
+
.map((b: any) => b.text)
|
|
2155
|
+
.join(''),
|
|
2156
|
+
})),
|
|
2157
|
+
metadata: {
|
|
2158
|
+
target_level: targetLevel,
|
|
2159
|
+
source_ids: sourceIds,
|
|
2160
|
+
source_level: sources[0]?.level ?? null,
|
|
2161
|
+
source_level_shown: sourceLevelShown,
|
|
2162
|
+
target_tokens: targetTokens,
|
|
2163
|
+
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
2164
|
+
latency_ms: Date.now() - callStart,
|
|
2165
|
+
summary_id: logNewSummaryId,
|
|
2166
|
+
},
|
|
2167
|
+
response: logResponse,
|
|
2168
|
+
error: logError,
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
// ============================================================================
|
|
2174
|
+
// Adaptive resolution (picker-driven) path
|
|
2175
|
+
// ============================================================================
|
|
2176
|
+
|
|
2177
|
+
/**
|
|
2178
|
+
* Select context entries using the adaptive-resolution picker.
|
|
2179
|
+
*
|
|
2180
|
+
* Builds per-message PickerChunks from compressible messages, runs the
|
|
2181
|
+
* configured FoldingStrategy under token-budget pressure, and emits the
|
|
2182
|
+
* resulting per-message resolutions as ContextEntry[]. Adjacent messages
|
|
2183
|
+
* sharing the same L_k ancestor emit the recall pair once.
|
|
2184
|
+
*
|
|
2185
|
+
* See `docs/adaptive-resolution-design.md` §3, §5.
|
|
2186
|
+
*/
|
|
2187
|
+
protected selectAdaptive(store: MessageStoreView, budget: TokenBudget): ContextEntry[] {
|
|
2188
|
+
const entries: ContextEntry[] = [];
|
|
2189
|
+
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
|
2190
|
+
const messages = store.getAll();
|
|
2191
|
+
const msgCap = this.config.maxMessageTokens;
|
|
2192
|
+
|
|
2193
|
+
// ----- 1. Build head/tail sets and emit head entries -----
|
|
2194
|
+
const headStart = this.getHeadWindowStartIndex(store);
|
|
2195
|
+
const headEnd = this.getHeadWindowEnd(store);
|
|
2196
|
+
const recentStart = this.getRecentWindowStart(store);
|
|
2197
|
+
|
|
2198
|
+
const headMessageIds = new Set<MessageId>();
|
|
2199
|
+
const tailMessageIds = new Set<MessageId>();
|
|
2200
|
+
let headTokens = 0;
|
|
2201
|
+
let tailTokens = 0;
|
|
2202
|
+
|
|
2203
|
+
let totalTokens = 0;
|
|
2204
|
+
|
|
2205
|
+
// Emit head entries verbatim
|
|
2206
|
+
for (let i = headStart; i < headEnd && i < messages.length; i++) {
|
|
2207
|
+
const msg = messages[i];
|
|
2208
|
+
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
2209
|
+
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
2210
|
+
if (totalTokens + tokens > maxTokens) break;
|
|
2211
|
+
entries.push({
|
|
2212
|
+
index: entries.length,
|
|
2213
|
+
sourceMessageId: msg.id,
|
|
2214
|
+
sourceRelation: 'copy',
|
|
2215
|
+
participant: msg.participant,
|
|
2216
|
+
content,
|
|
2217
|
+
});
|
|
2218
|
+
totalTokens += tokens;
|
|
2219
|
+
headMessageIds.add(msg.id);
|
|
2220
|
+
headTokens += tokens;
|
|
2221
|
+
}
|
|
2222
|
+
if (entries.length > 0) {
|
|
2223
|
+
entries[entries.length - 1].cacheMarker = true;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
// Compute tail message IDs (will be emitted at end)
|
|
2227
|
+
const effectiveRecentStart = Math.max(recentStart, headEnd);
|
|
2228
|
+
for (let i = effectiveRecentStart; i < messages.length; i++) {
|
|
2229
|
+
const msg = messages[i];
|
|
2230
|
+
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
2231
|
+
tailMessageIds.add(msg.id);
|
|
2232
|
+
tailTokens += tokens;
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
// ----- 2. Build PickerChunks for messages in the middle -----
|
|
2236
|
+
// For each compressible (non-head, non-tail) message we create one
|
|
2237
|
+
// PickerChunk. Its l1Id is determined by the existing chunks that
|
|
2238
|
+
// group messages into L1 summaries.
|
|
2239
|
+
const chunksByMessageId = new Map<MessageId, Chunk>();
|
|
2240
|
+
for (const ch of this.chunks) {
|
|
2241
|
+
for (const m of ch.messages) {
|
|
2242
|
+
chunksByMessageId.set(m.id, ch);
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
// Pinned-position set so the picker doesn't fold messages the user
|
|
2247
|
+
// explicitly marked as keep-raw. Built once and reused.
|
|
2248
|
+
const pinnedSet = this.pinnedPositions(messages);
|
|
2249
|
+
|
|
2250
|
+
// O(1) summary lookup for findAncestorAt — avoids O(summaries) find()
|
|
2251
|
+
// calls during emission.
|
|
2252
|
+
const summariesById = new Map<string, SummaryEntry>();
|
|
2253
|
+
for (const s of this.summaries) summariesById.set(s.id, s);
|
|
2254
|
+
|
|
2255
|
+
const pickerChunks: PickerChunk[] = [];
|
|
2256
|
+
for (let i = headEnd; i < effectiveRecentStart && i < messages.length; i++) {
|
|
2257
|
+
const msg = messages[i];
|
|
2258
|
+
const ch = chunksByMessageId.get(msg.id);
|
|
2259
|
+
const tokens = msgCap > 0
|
|
2260
|
+
? Math.min(store.estimateTokens(msg), msgCap + 50)
|
|
2261
|
+
: store.estimateTokens(msg);
|
|
2262
|
+
pickerChunks.push({
|
|
2263
|
+
id: msg.id,
|
|
2264
|
+
sequence: i,
|
|
2265
|
+
rawTokens: tokens,
|
|
2266
|
+
currentResolution: this.resolutions.get(msg.id) ?? 0,
|
|
2267
|
+
lockedByAgent: this.locked.has(msg.id),
|
|
2268
|
+
pinned: pinnedSet.has(i),
|
|
2269
|
+
l1Id: ch?.summaryId,
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
// Also include head and tail in PickerChunks (so token accounting matches)
|
|
2274
|
+
// — but mark them as in-head/in-tail so the picker won't fold them.
|
|
2275
|
+
for (let i = headStart; i < headEnd && i < messages.length; i++) {
|
|
2276
|
+
const msg = messages[i];
|
|
2277
|
+
const tokens = msgCap > 0
|
|
2278
|
+
? Math.min(store.estimateTokens(msg), msgCap + 50)
|
|
2279
|
+
: store.estimateTokens(msg);
|
|
2280
|
+
pickerChunks.push({
|
|
2281
|
+
id: msg.id,
|
|
2282
|
+
sequence: i,
|
|
2283
|
+
rawTokens: tokens,
|
|
2284
|
+
currentResolution: 0,
|
|
2285
|
+
lockedByAgent: this.locked.has(msg.id),
|
|
2286
|
+
pinned: true, // treat head as pinned for picker purposes
|
|
2287
|
+
l1Id: undefined,
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
for (let i = effectiveRecentStart; i < messages.length; i++) {
|
|
2291
|
+
const msg = messages[i];
|
|
2292
|
+
const tokens = msgCap > 0
|
|
2293
|
+
? Math.min(store.estimateTokens(msg), msgCap + 50)
|
|
2294
|
+
: store.estimateTokens(msg);
|
|
2295
|
+
pickerChunks.push({
|
|
2296
|
+
id: msg.id,
|
|
2297
|
+
sequence: i,
|
|
2298
|
+
rawTokens: tokens,
|
|
2299
|
+
currentResolution: 0,
|
|
2300
|
+
lockedByAgent: this.locked.has(msg.id),
|
|
2301
|
+
pinned: true, // treat tail as pinned for picker purposes
|
|
2302
|
+
l1Id: undefined,
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// ----- 3. Build summaries map and recall-pair tokens -----
|
|
2307
|
+
const summariesMap = new Map<SummaryId, SummaryEntry>();
|
|
2308
|
+
const recallPairTokens = new Map<SummaryId, number>();
|
|
2309
|
+
for (const s of this.summaries) {
|
|
2310
|
+
summariesMap.set(s.id, s);
|
|
2311
|
+
// recall pair = the summary's text wrapped as a Q&A pair. Approximate
|
|
2312
|
+
// as s.tokens + small overhead for the "What do you remember?" label.
|
|
2313
|
+
recallPairTokens.set(s.id, s.tokens + 20);
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
// ----- 4. Run the picker -----
|
|
2317
|
+
const totalBudget = maxTokens - totalTokens; // tokens left after head
|
|
2318
|
+
const slack = this.config.compressionSlackRatio ?? 0.1;
|
|
2319
|
+
const foldingBudget: FoldingBudget = {
|
|
2320
|
+
totalBudget,
|
|
2321
|
+
targetBudget: totalBudget * (1 - slack),
|
|
2322
|
+
slack,
|
|
2323
|
+
};
|
|
2324
|
+
|
|
2325
|
+
const headSetForPicker = new Set<ChunkId>(headMessageIds);
|
|
2326
|
+
const tailSetForPicker = new Set<ChunkId>(tailMessageIds);
|
|
2327
|
+
|
|
2328
|
+
const picker = this.getAdaptivePicker();
|
|
2329
|
+
const result = picker.run(
|
|
2330
|
+
{
|
|
2331
|
+
chunks: pickerChunks,
|
|
2332
|
+
summaries: summariesMap,
|
|
2333
|
+
recallPairTokens,
|
|
2334
|
+
headChunkIds: headSetForPicker,
|
|
2335
|
+
tailChunkIds: tailSetForPicker,
|
|
2336
|
+
headTokens,
|
|
2337
|
+
tailTokens,
|
|
2338
|
+
},
|
|
2339
|
+
foldingBudget
|
|
2340
|
+
);
|
|
2341
|
+
|
|
2342
|
+
// Commit the new resolutions back to strategy state for next compile.
|
|
2343
|
+
// Persist to chronicle only if anything actually changed — avoids
|
|
2344
|
+
// unnecessary state-slot writes on no-op compiles (which is the common
|
|
2345
|
+
// case in steady state with slack).
|
|
2346
|
+
let resolutionsChanged = false;
|
|
2347
|
+
let deepestLevel = 0;
|
|
2348
|
+
for (const [id, level] of result.finalResolutions) {
|
|
2349
|
+
if (headMessageIds.has(id) || tailMessageIds.has(id)) continue;
|
|
2350
|
+
if (this.locked.has(id)) continue;
|
|
2351
|
+
const prev = this.resolutions.get(id) ?? 0;
|
|
2352
|
+
if (prev !== level) {
|
|
2353
|
+
this.resolutions.set(id, level);
|
|
2354
|
+
resolutionsChanged = true;
|
|
2355
|
+
}
|
|
2356
|
+
if (level > deepestLevel) deepestLevel = level;
|
|
2357
|
+
}
|
|
2358
|
+
if (resolutionsChanged) {
|
|
2359
|
+
this.persistResolutions();
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
// Wire produce ops into the strategy's own production queues so that
|
|
2363
|
+
// requested-but-not-yet-existing summaries actually get built. The
|
|
2364
|
+
// speculative pre-producer covers most cases ambiently, but when it is
|
|
2365
|
+
// disabled (`speculativeProduction: false`) or hasn't reached the level
|
|
2366
|
+
// the picker just asked for, the request would otherwise be dropped and
|
|
2367
|
+
// the picker would re-emit it on every subsequent compile. Handling it
|
|
2368
|
+
// here makes the produce path observable and convergent.
|
|
2369
|
+
//
|
|
2370
|
+
// The actual compression/merge work runs asynchronously via the next
|
|
2371
|
+
// `tick()` invocation (or the speculative drain kicked from
|
|
2372
|
+
// `onNewMessage`). This call only enqueues; it does not await.
|
|
2373
|
+
if (result.produced.length > 0) {
|
|
2374
|
+
this.handleProducedOps(result.produced);
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
// Hard-fail check: if the picker exhausted itself but the final render
|
|
2378
|
+
// would still exceed the HARD budget (not just the soft target), surface
|
|
2379
|
+
// an OverBudgetError to the host rather than silently dropping entries.
|
|
2380
|
+
// The strategy has done all it can; the application has to decide what
|
|
2381
|
+
// to do next (raise budget, switch model, drop windows, etc.).
|
|
2382
|
+
if (result.exhausted && result.finalTokens > totalBudget) {
|
|
2383
|
+
throw new OverBudgetError({
|
|
2384
|
+
budget: totalBudget,
|
|
2385
|
+
actual: result.finalTokens,
|
|
2386
|
+
diagnostics: {
|
|
2387
|
+
headTokens,
|
|
2388
|
+
tailTokens,
|
|
2389
|
+
middleTokens: Math.max(0, result.finalTokens - headTokens - tailTokens),
|
|
2390
|
+
middleChunkCount: pickerChunks.length - headMessageIds.size - tailMessageIds.size,
|
|
2391
|
+
deepestLevel,
|
|
2392
|
+
},
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
// ----- 5. Emit middle entries in source order -----
|
|
2397
|
+
// Walk middle messages. Handle two cases:
|
|
2398
|
+
// - bodyGroupId set: collect all consecutive shards from the same group,
|
|
2399
|
+
// emit ONE combined entry with concatenated content (raw shards + inline
|
|
2400
|
+
// summary text for folded shards). This preserves KV — the model sees
|
|
2401
|
+
// one continuous user message instead of N turns.
|
|
2402
|
+
// - bodyGroupId absent: emit normally (raw L0 message, or Q+A summary pair).
|
|
2403
|
+
const emittedAncestors = new Set<SummaryId>();
|
|
2404
|
+
const summaryLabel = this.config.summaryContextLabel ?? 'What do you remember from earlier?';
|
|
2405
|
+
const summaryParticipant = this.config.summaryParticipant ?? 'Claude';
|
|
2406
|
+
|
|
2407
|
+
let i = headEnd;
|
|
2408
|
+
while (i < effectiveRecentStart && i < messages.length) {
|
|
2409
|
+
const msg = messages[i];
|
|
2410
|
+
|
|
2411
|
+
if (msg.bodyGroupId) {
|
|
2412
|
+
// Collect the full run of consecutive shards sharing this bodyGroupId.
|
|
2413
|
+
const groupId = msg.bodyGroupId;
|
|
2414
|
+
const groupStart = i;
|
|
2415
|
+
while (
|
|
2416
|
+
i < effectiveRecentStart &&
|
|
2417
|
+
i < messages.length &&
|
|
2418
|
+
messages[i].bodyGroupId === groupId
|
|
2419
|
+
) {
|
|
2420
|
+
i++;
|
|
2421
|
+
}
|
|
2422
|
+
const groupMessages = messages.slice(groupStart, i);
|
|
2423
|
+
// Sort by shardIndex to ensure byte-faithful ordering.
|
|
2424
|
+
const sortedShards = [...groupMessages].sort(
|
|
2425
|
+
(a, b) => (a.shardIndex ?? 0) - (b.shardIndex ?? 0)
|
|
2426
|
+
);
|
|
2427
|
+
|
|
2428
|
+
// Walk shards in order, accumulating "runs":
|
|
2429
|
+
// - a 'raw' run is consecutive shards at L0; flushed as ONE User
|
|
2430
|
+
// message with their text concatenated.
|
|
2431
|
+
// - a 'summary' run is consecutive shards under the same L_k
|
|
2432
|
+
// ancestor; flushed as a Q+A recall pair (Context Manager
|
|
2433
|
+
// question + summaryParticipant answer), emitted once per
|
|
2434
|
+
// distinct ancestor.
|
|
2435
|
+
// The run breaks (and the previous run flushes) when:
|
|
2436
|
+
// - resolution transitions raw ↔ folded
|
|
2437
|
+
// - the L_k ancestor changes
|
|
2438
|
+
type Run =
|
|
2439
|
+
| { kind: 'raw'; parts: string[] }
|
|
2440
|
+
| { kind: 'summary'; ancestor: SummaryEntry };
|
|
2441
|
+
let currentRun: Run | null = null;
|
|
2442
|
+
const participant = sortedShards[0].participant;
|
|
2443
|
+
|
|
2444
|
+
const flushRun = (): boolean => {
|
|
2445
|
+
if (!currentRun) return true;
|
|
2446
|
+
if (currentRun.kind === 'raw') {
|
|
2447
|
+
const text = currentRun.parts.join('');
|
|
2448
|
+
const content: ContentBlock[] = [{ type: 'text', text }];
|
|
2449
|
+
// Deliberately do NOT apply maxMessageTokens here: the picker
|
|
2450
|
+
// is the authority on how much of the doc renders raw vs.
|
|
2451
|
+
// summarized. Truncating the composite would silently lose
|
|
2452
|
+
// doc content that the picker explicitly chose to keep raw.
|
|
2453
|
+
// (`maxMessageTokens` is for per-message caps on chat / tool
|
|
2454
|
+
// results, not for sharded bodyGroup composites.)
|
|
2455
|
+
const tokens = this.estimateTokens(content);
|
|
2456
|
+
if (totalTokens + tokens > maxTokens) {
|
|
2457
|
+
currentRun = null;
|
|
2458
|
+
return false;
|
|
2459
|
+
}
|
|
2460
|
+
entries.push({
|
|
2461
|
+
index: entries.length,
|
|
2462
|
+
sourceMessageId: undefined,
|
|
2463
|
+
sourceRelation: 'copy',
|
|
2464
|
+
participant,
|
|
2465
|
+
content,
|
|
2466
|
+
});
|
|
2467
|
+
totalTokens += tokens;
|
|
2468
|
+
} else {
|
|
2469
|
+
// summary run — emit Q+A pair, dedup at the strategy level
|
|
2470
|
+
const ancestor = currentRun.ancestor;
|
|
2471
|
+
if (!emittedAncestors.has(ancestor.id)) {
|
|
2472
|
+
emittedAncestors.add(ancestor.id);
|
|
2473
|
+
const questionEntry: ContextEntry = {
|
|
2474
|
+
index: entries.length,
|
|
2475
|
+
participant: 'Context Manager',
|
|
2476
|
+
content: [{ type: 'text', text: summaryLabel }],
|
|
2477
|
+
sourceRelation: 'derived',
|
|
2478
|
+
};
|
|
2479
|
+
const answerContent: ContentBlock[] = [{ type: 'text', text: ancestor.content }];
|
|
2480
|
+
const answerEntry: ContextEntry = {
|
|
2481
|
+
index: entries.length + 1,
|
|
2482
|
+
participant: summaryParticipant,
|
|
2483
|
+
content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
|
|
2484
|
+
sourceRelation: 'derived',
|
|
2485
|
+
};
|
|
2486
|
+
const pairTokens = this.estimateTokens(questionEntry.content) + this.estimateTokens(answerEntry.content);
|
|
2487
|
+
if (totalTokens + pairTokens > maxTokens) {
|
|
2488
|
+
currentRun = null;
|
|
2489
|
+
return false;
|
|
2490
|
+
}
|
|
2491
|
+
entries.push(questionEntry);
|
|
2492
|
+
entries.push(answerEntry);
|
|
2493
|
+
totalTokens += pairTokens;
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
currentRun = null;
|
|
2497
|
+
return true;
|
|
2498
|
+
};
|
|
2499
|
+
|
|
2500
|
+
let budgetExhausted = false;
|
|
2501
|
+
for (const shard of sortedShards) {
|
|
2502
|
+
const resolution = result.finalResolutions.get(shard.id) ?? 0;
|
|
2503
|
+
if (resolution === 0) {
|
|
2504
|
+
if (currentRun?.kind !== 'raw') {
|
|
2505
|
+
if (!flushRun()) { budgetExhausted = true; break; }
|
|
2506
|
+
currentRun = { kind: 'raw', parts: [] };
|
|
2507
|
+
}
|
|
2508
|
+
for (const block of shard.content) {
|
|
2509
|
+
if (block.type === 'text') (currentRun as { kind: 'raw'; parts: string[] }).parts.push(block.text);
|
|
2510
|
+
}
|
|
2511
|
+
} else {
|
|
2512
|
+
const ancestor = this.findAncestorAt(shard.id, resolution, chunksByMessageId, summariesById);
|
|
2513
|
+
if (!ancestor) {
|
|
2514
|
+
// Fall back to raw
|
|
2515
|
+
if (currentRun?.kind !== 'raw') {
|
|
2516
|
+
if (!flushRun()) { budgetExhausted = true; break; }
|
|
2517
|
+
currentRun = { kind: 'raw', parts: [] };
|
|
2518
|
+
}
|
|
2519
|
+
for (const block of shard.content) {
|
|
2520
|
+
if (block.type === 'text') (currentRun as { kind: 'raw'; parts: string[] }).parts.push(block.text);
|
|
2521
|
+
}
|
|
2522
|
+
continue;
|
|
2523
|
+
}
|
|
2524
|
+
// If we're already in a summary run for the SAME ancestor, this
|
|
2525
|
+
// shard is covered — skip silently.
|
|
2526
|
+
if (currentRun?.kind === 'summary' && currentRun.ancestor.id === ancestor.id) {
|
|
2527
|
+
continue;
|
|
2528
|
+
}
|
|
2529
|
+
if (!flushRun()) { budgetExhausted = true; break; }
|
|
2530
|
+
currentRun = { kind: 'summary', ancestor };
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
if (!budgetExhausted) {
|
|
2534
|
+
if (!flushRun()) budgetExhausted = true;
|
|
2535
|
+
}
|
|
2536
|
+
if (budgetExhausted) break;
|
|
2537
|
+
continue;
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// Non-shard path: existing behavior.
|
|
2541
|
+
const resolution = result.finalResolutions.get(msg.id) ?? 0;
|
|
2542
|
+
if (resolution === 0) {
|
|
2543
|
+
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
2544
|
+
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
2545
|
+
if (totalTokens + tokens > maxTokens) break;
|
|
2546
|
+
entries.push({
|
|
2547
|
+
index: entries.length,
|
|
2548
|
+
sourceMessageId: msg.id,
|
|
2549
|
+
sourceRelation: 'copy',
|
|
2550
|
+
participant: msg.participant,
|
|
2551
|
+
content,
|
|
2552
|
+
});
|
|
2553
|
+
totalTokens += tokens;
|
|
2554
|
+
i++;
|
|
2555
|
+
} else {
|
|
2556
|
+
const ancestor = this.findAncestorAt(msg.id, resolution, chunksByMessageId, summariesById);
|
|
2557
|
+
if (!ancestor) {
|
|
2558
|
+
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
2559
|
+
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
2560
|
+
if (totalTokens + tokens > maxTokens) break;
|
|
2561
|
+
entries.push({
|
|
2562
|
+
index: entries.length,
|
|
2563
|
+
sourceMessageId: msg.id,
|
|
2564
|
+
sourceRelation: 'copy',
|
|
2565
|
+
participant: msg.participant,
|
|
2566
|
+
content,
|
|
2567
|
+
});
|
|
2568
|
+
totalTokens += tokens;
|
|
2569
|
+
i++;
|
|
2570
|
+
continue;
|
|
2571
|
+
}
|
|
2572
|
+
if (emittedAncestors.has(ancestor.id)) {
|
|
2573
|
+
i++;
|
|
2574
|
+
continue;
|
|
2575
|
+
}
|
|
2576
|
+
emittedAncestors.add(ancestor.id);
|
|
2577
|
+
const questionEntry: ContextEntry = {
|
|
2578
|
+
index: entries.length,
|
|
2579
|
+
participant: 'Context Manager',
|
|
2580
|
+
content: [{ type: 'text', text: summaryLabel }],
|
|
2581
|
+
sourceRelation: 'derived',
|
|
2582
|
+
};
|
|
2583
|
+
const answerContent: ContentBlock[] = [{ type: 'text', text: ancestor.content }];
|
|
2584
|
+
const answerEntry: ContextEntry = {
|
|
2585
|
+
index: entries.length + 1,
|
|
2586
|
+
participant: summaryParticipant,
|
|
2587
|
+
content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
|
|
2588
|
+
sourceRelation: 'derived',
|
|
2589
|
+
};
|
|
2590
|
+
const pairTokens = this.estimateTokens(questionEntry.content) + this.estimateTokens(answerEntry.content);
|
|
2591
|
+
if (totalTokens + pairTokens > maxTokens) break;
|
|
2592
|
+
entries.push(questionEntry);
|
|
2593
|
+
entries.push(answerEntry);
|
|
2594
|
+
totalTokens += pairTokens;
|
|
2595
|
+
i++;
|
|
2596
|
+
}
|
|
1230
2597
|
}
|
|
2598
|
+
|
|
2599
|
+
// ----- 6. Emit tail entries newest-first eviction (matches existing behavior) -----
|
|
2600
|
+
this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
2601
|
+
|
|
2602
|
+
// ----- 7. Post-process: merge consecutive raw entries from the same bodyGroup -----
|
|
2603
|
+
// Both head and tail emission paths emit shards as separate ContextEntries.
|
|
2604
|
+
// The middle path already merges consecutive same-bodyGroup raw shards into
|
|
2605
|
+
// one composite entry, but head/tail don't. This pass closes that gap so
|
|
2606
|
+
// a sharded message renders as ONE API message regardless of which region
|
|
2607
|
+
// it falls into (preserves KV cache through region transitions).
|
|
2608
|
+
const merged = this.mergeAdjacentBodyGroupRaw(entries, store);
|
|
2609
|
+
|
|
2610
|
+
this.trimOrphanedToolUse(merged);
|
|
2611
|
+
return merged;
|
|
1231
2612
|
}
|
|
1232
2613
|
|
|
2614
|
+
/**
|
|
2615
|
+
* Walk an entries array; for every run of consecutive entries that
|
|
2616
|
+
* (a) have sourceRelation: 'copy' (raw, not a synthesized recall pair)
|
|
2617
|
+
* (b) have sourceMessageId pointing to messages in the same bodyGroup
|
|
2618
|
+
* merge them into one composite entry whose body is the byte-faithful
|
|
2619
|
+
* concatenation of their text content. Other entries pass through.
|
|
2620
|
+
*
|
|
2621
|
+
* Reindexes the returned array.
|
|
2622
|
+
*/
|
|
2623
|
+
protected mergeAdjacentBodyGroupRaw(
|
|
2624
|
+
entries: ContextEntry[],
|
|
2625
|
+
store: MessageStoreView
|
|
2626
|
+
): ContextEntry[] {
|
|
2627
|
+
if (entries.length === 0) return entries;
|
|
2628
|
+
|
|
2629
|
+
// Look up bodyGroupId by sourceMessageId via the message store.
|
|
2630
|
+
const groupOf = (sourceMessageId?: string): string | undefined => {
|
|
2631
|
+
if (!sourceMessageId) return undefined;
|
|
2632
|
+
const m = store.get(sourceMessageId);
|
|
2633
|
+
return m?.bodyGroupId;
|
|
2634
|
+
};
|
|
2635
|
+
|
|
2636
|
+
const out: ContextEntry[] = [];
|
|
2637
|
+
let i = 0;
|
|
2638
|
+
while (i < entries.length) {
|
|
2639
|
+
const entry = entries[i];
|
|
2640
|
+
const groupId = entry.sourceRelation === 'copy' ? groupOf(entry.sourceMessageId) : undefined;
|
|
2641
|
+
if (!groupId) {
|
|
2642
|
+
out.push({ ...entry, index: out.length });
|
|
2643
|
+
i++;
|
|
2644
|
+
continue;
|
|
2645
|
+
}
|
|
2646
|
+
// Collect run of consecutive raw entries with same bodyGroupId.
|
|
2647
|
+
const run: ContextEntry[] = [entry];
|
|
2648
|
+
let j = i + 1;
|
|
2649
|
+
while (
|
|
2650
|
+
j < entries.length &&
|
|
2651
|
+
entries[j].sourceRelation === 'copy' &&
|
|
2652
|
+
groupOf(entries[j].sourceMessageId) === groupId
|
|
2653
|
+
) {
|
|
2654
|
+
run.push(entries[j]);
|
|
2655
|
+
j++;
|
|
2656
|
+
}
|
|
2657
|
+
if (run.length === 1) {
|
|
2658
|
+
out.push({ ...entry, index: out.length });
|
|
2659
|
+
i++;
|
|
2660
|
+
continue;
|
|
2661
|
+
}
|
|
2662
|
+
// Sort the run by the underlying shardIndex to ensure byte-faithful
|
|
2663
|
+
// ordering. (Head/tail emission keeps chronological order, but defending
|
|
2664
|
+
// against reorderings is cheap.)
|
|
2665
|
+
const sortedRun = [...run].sort((a, b) => {
|
|
2666
|
+
const ma = a.sourceMessageId ? store.get(a.sourceMessageId) : null;
|
|
2667
|
+
const mb = b.sourceMessageId ? store.get(b.sourceMessageId) : null;
|
|
2668
|
+
return (ma?.shardIndex ?? 0) - (mb?.shardIndex ?? 0);
|
|
2669
|
+
});
|
|
2670
|
+
// Build merged text content. Non-text blocks (rare in shards) are
|
|
2671
|
+
// preserved on the first shard's entry only.
|
|
2672
|
+
const mergedTextParts: string[] = [];
|
|
2673
|
+
const nonTextBlocks: ContentBlock[] = [];
|
|
2674
|
+
for (const r of sortedRun) {
|
|
2675
|
+
for (const block of r.content) {
|
|
2676
|
+
if (block.type === 'text') mergedTextParts.push(block.text);
|
|
2677
|
+
else nonTextBlocks.push(block);
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
const mergedContent: ContentBlock[] = [
|
|
2681
|
+
...nonTextBlocks,
|
|
2682
|
+
{ type: 'text', text: mergedTextParts.join('') },
|
|
2683
|
+
];
|
|
2684
|
+
out.push({
|
|
2685
|
+
index: out.length,
|
|
2686
|
+
sourceMessageId: sortedRun[0].sourceMessageId,
|
|
2687
|
+
sourceRelation: 'copy',
|
|
2688
|
+
participant: sortedRun[0].participant,
|
|
2689
|
+
content: mergedContent,
|
|
2690
|
+
});
|
|
2691
|
+
i = j;
|
|
2692
|
+
}
|
|
2693
|
+
return out;
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
/** Get (lazily constructing) the configured picker instance. */
|
|
2697
|
+
protected getAdaptivePicker(): Picker {
|
|
2698
|
+
if (this._adaptivePicker) return this._adaptivePicker;
|
|
2699
|
+
const strategy: FoldingStrategy =
|
|
2700
|
+
this.config.foldingStrategy === 'oldest-first'
|
|
2701
|
+
? new OldestFirstStrategy()
|
|
2702
|
+
: new FlatProfileStrategy();
|
|
2703
|
+
this._adaptivePicker = new Picker(strategy);
|
|
2704
|
+
return this._adaptivePicker;
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
/**
|
|
2708
|
+
* Walk the summary tree to find the L_k ancestor of a message.
|
|
2709
|
+
* Returns null if no ancestor exists at that level (e.g., L_k not yet produced).
|
|
2710
|
+
*
|
|
2711
|
+
* Takes a pre-built summariesById map to avoid O(summaries) lookups per
|
|
2712
|
+
* call — for a chronicle with thousands of summaries and hundreds of
|
|
2713
|
+
* middle messages, the O(n) `find` would dominate compile latency.
|
|
2714
|
+
*/
|
|
2715
|
+
protected findAncestorAt(
|
|
2716
|
+
messageId: MessageId,
|
|
2717
|
+
level: number,
|
|
2718
|
+
chunksByMessageId: ReadonlyMap<MessageId, Chunk>,
|
|
2719
|
+
summariesById?: ReadonlyMap<string, SummaryEntry>,
|
|
2720
|
+
): SummaryEntry | null {
|
|
2721
|
+
if (level <= 0) return null;
|
|
2722
|
+
const chunk = chunksByMessageId.get(messageId);
|
|
2723
|
+
if (!chunk?.summaryId) return null;
|
|
2724
|
+
const lookup = (id: string): SummaryEntry | undefined =>
|
|
2725
|
+
summariesById ? summariesById.get(id) : this.summaries.find((s) => s.id === id);
|
|
2726
|
+
let current: SummaryEntry | undefined = lookup(chunk.summaryId);
|
|
2727
|
+
while (current && current.level < level) {
|
|
2728
|
+
const parentId = getSummaryParentId(current);
|
|
2729
|
+
if (!parentId) return null;
|
|
2730
|
+
current = lookup(parentId);
|
|
2731
|
+
}
|
|
2732
|
+
if (!current || current.level !== level) return null;
|
|
2733
|
+
return current;
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
// ============================================================================
|
|
2737
|
+
// Hierarchical (threshold-driven) path
|
|
2738
|
+
// ============================================================================
|
|
2739
|
+
|
|
1233
2740
|
/**
|
|
1234
2741
|
* Select context entries using hierarchical compression with budget carryover.
|
|
1235
2742
|
* Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
|
|
@@ -1506,23 +3013,113 @@ export class AutobiographicalStrategy implements ResettableStrategy {
|
|
|
1506
3013
|
// ============================================================================
|
|
1507
3014
|
|
|
1508
3015
|
/**
|
|
1509
|
-
* Build the compression instruction for an L1 chunk
|
|
1510
|
-
* Override in subclasses for
|
|
3016
|
+
* Build the compression instruction for an L1 chunk in the hierarchical
|
|
3017
|
+
* path. Override in subclasses for domain-specific prompts (e.g.,
|
|
3018
|
+
* phase-aware prompts in KnowledgeStrategy).
|
|
3019
|
+
*
|
|
3020
|
+
* Default returns the KV-preserving first-person instruction matching
|
|
3021
|
+
* the hermes-autobio spec. The doc/reading-mode variant is exposed via
|
|
3022
|
+
* {@link getReadingChunkInstruction}.
|
|
1511
3023
|
*/
|
|
1512
3024
|
protected getCompressionInstruction(chunk: Chunk, targetTokens: number): string {
|
|
1513
|
-
return
|
|
3025
|
+
return formatInstruction(targetTokens);
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
/**
|
|
3029
|
+
* Build the compression instruction for an L1 chunk that is part of a
|
|
3030
|
+
* substantially larger sharded message (reading mode). Override in
|
|
3031
|
+
* subclasses if domain logic needs to vary the reading-mode prompt.
|
|
3032
|
+
*
|
|
3033
|
+
* Default returns the reading-mode instruction that asks the model to
|
|
3034
|
+
* reflect on what reading was like rather than form a memory "of what
|
|
3035
|
+
* the chunk contained", which prevents voice drift into the content
|
|
3036
|
+
* author's perspective.
|
|
3037
|
+
*/
|
|
3038
|
+
protected getReadingChunkInstruction(
|
|
3039
|
+
chunk: Chunk,
|
|
3040
|
+
totalTokens: number,
|
|
3041
|
+
targetTokens: number,
|
|
3042
|
+
): string {
|
|
3043
|
+
return formatReadingChunkInstruction(totalTokens, targetTokens);
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
/**
|
|
3047
|
+
* If the chunk is part of a substantially larger sharded message (total
|
|
3048
|
+
* bodyGroup tokens ≥ 2× the chunk's own tokens), return reading-context
|
|
3049
|
+
* metadata for the reading instruction. The 2× threshold means the
|
|
3050
|
+
* chunk represents a portion of something significantly larger — the
|
|
3051
|
+
* agent is reading, not conversing.
|
|
3052
|
+
*
|
|
3053
|
+
* Returns null when the chunk is a whole message (no bodyGroup), or
|
|
3054
|
+
* when bodyGroup total is < 2× chunk size (degenerate case — chunk
|
|
3055
|
+
* effectively IS the whole message). In those cases the standard
|
|
3056
|
+
* (non-reading) instruction is appropriate.
|
|
3057
|
+
*/
|
|
3058
|
+
protected detectDocContext(
|
|
3059
|
+
chunk: Chunk,
|
|
3060
|
+
ctx: StrategyContext,
|
|
3061
|
+
): { totalTokens: number; chunkTokens: number } | null {
|
|
3062
|
+
if (chunk.messages.length === 0) return null;
|
|
3063
|
+
const firstGroupId = chunk.messages[0].bodyGroupId;
|
|
3064
|
+
if (!firstGroupId) return null;
|
|
3065
|
+
// All messages in the chunk must share the same bodyGroupId
|
|
3066
|
+
for (const m of chunk.messages) {
|
|
3067
|
+
if (m.bodyGroupId !== firstGroupId) return null;
|
|
3068
|
+
}
|
|
3069
|
+
// Total tokens of the original message (sum of all shards in the bodyGroup).
|
|
3070
|
+
const allMessages = ctx.messageStore.getAll();
|
|
3071
|
+
let totalTokens = 0;
|
|
3072
|
+
for (const m of allMessages) {
|
|
3073
|
+
if (m.bodyGroupId === firstGroupId) {
|
|
3074
|
+
totalTokens += ctx.messageStore.estimateTokens(m);
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
// Tokens in this chunk specifically.
|
|
3078
|
+
let chunkTokens = 0;
|
|
3079
|
+
for (const m of chunk.messages) {
|
|
3080
|
+
chunkTokens += ctx.messageStore.estimateTokens(m);
|
|
3081
|
+
}
|
|
3082
|
+
// Reading-mode threshold: the original message must be substantially
|
|
3083
|
+
// larger than this chunk. 2× means the chunk is at most half of the
|
|
3084
|
+
// whole — clearly a portion of something bigger.
|
|
3085
|
+
if (chunkTokens === 0 || totalTokens < 2 * chunkTokens) return null;
|
|
3086
|
+
return {
|
|
3087
|
+
totalTokens,
|
|
3088
|
+
chunkTokens,
|
|
3089
|
+
};
|
|
1514
3090
|
}
|
|
1515
3091
|
|
|
1516
3092
|
/**
|
|
1517
3093
|
* Build the merge instruction for combining summaries into a higher level.
|
|
1518
3094
|
* Override in subclasses for domain-specific merge prompts.
|
|
3095
|
+
*
|
|
3096
|
+
* Default returns the KV-preserving merge instruction. The reading-mode
|
|
3097
|
+
* variant (used when all leaves share a bodyGroup of substantial size)
|
|
3098
|
+
* is exposed via {@link getReadingMergeInstruction}.
|
|
1519
3099
|
*/
|
|
1520
3100
|
protected getMergeInstruction(
|
|
1521
3101
|
targetLevel: SummaryLevel,
|
|
1522
3102
|
sources: SummaryEntry[],
|
|
1523
3103
|
targetTokens: number
|
|
1524
3104
|
): string {
|
|
1525
|
-
|
|
3105
|
+
const sourceLevelShown = sources.length > 0 ? Math.max(0, sources[0].level - 1) : 0;
|
|
3106
|
+
return formatMergeInstruction(targetLevel, sourceLevelShown, targetTokens);
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
/**
|
|
3110
|
+
* Build the reading-mode merge instruction. Used when all leaf messages
|
|
3111
|
+
* underlying the merge share a bodyGroup of substantial size — the agent
|
|
3112
|
+
* has been reading a doc rather than conversing. Override in subclasses
|
|
3113
|
+
* if domain logic needs to vary the reading-mode merge prompt.
|
|
3114
|
+
*/
|
|
3115
|
+
protected getReadingMergeInstruction(
|
|
3116
|
+
targetLevel: SummaryLevel,
|
|
3117
|
+
sources: SummaryEntry[],
|
|
3118
|
+
totalTokens: number,
|
|
3119
|
+
targetTokens: number,
|
|
3120
|
+
): string {
|
|
3121
|
+
const sourceLevelShown = sources.length > 0 ? Math.max(0, sources[0].level - 1) : 0;
|
|
3122
|
+
return formatReadingMergeInstruction(targetLevel, sourceLevelShown, totalTokens, targetTokens);
|
|
1526
3123
|
}
|
|
1527
3124
|
|
|
1528
3125
|
/**
|