@animalabs/context-manager 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/context-manager.d.ts +69 -2
- package/dist/src/context-manager.d.ts.map +1 -1
- package/dist/src/context-manager.js +128 -10
- package/dist/src/context-manager.js.map +1 -1
- package/dist/src/strategies/autobiographical.d.ts +217 -3
- package/dist/src/strategies/autobiographical.d.ts.map +1 -1
- package/dist/src/strategies/autobiographical.js +813 -77
- package/dist/src/strategies/autobiographical.js.map +1 -1
- package/dist/src/types/index.d.ts +2 -2
- package/dist/src/types/index.d.ts.map +1 -1
- package/dist/src/types/index.js +1 -1
- package/dist/src/types/index.js.map +1 -1
- package/dist/src/types/strategy.d.ts +208 -0
- package/dist/src/types/strategy.d.ts.map +1 -1
- package/dist/src/types/strategy.js +21 -0
- package/dist/src/types/strategy.js.map +1 -1
- package/dist/test/non-blocking-compile.test.d.ts +22 -0
- package/dist/test/non-blocking-compile.test.d.ts.map +1 -0
- package/dist/test/non-blocking-compile.test.js +114 -0
- package/dist/test/non-blocking-compile.test.js.map +1 -0
- package/dist/test/pins.test.d.ts +10 -0
- package/dist/test/pins.test.d.ts.map +1 -0
- package/dist/test/pins.test.js +164 -0
- package/dist/test/pins.test.js.map +1 -0
- package/dist/test/recall-positioning.test.d.ts +16 -0
- package/dist/test/recall-positioning.test.d.ts.map +1 -0
- package/dist/test/recall-positioning.test.js +181 -0
- package/dist/test/recall-positioning.test.js.map +1 -0
- package/dist/test/recent-window-eviction.test.d.ts +19 -0
- package/dist/test/recent-window-eviction.test.d.ts.map +1 -0
- package/dist/test/recent-window-eviction.test.js +206 -0
- package/dist/test/recent-window-eviction.test.js.map +1 -0
- package/dist/test/search.test.d.ts +10 -0
- package/dist/test/search.test.d.ts.map +1 -0
- package/dist/test/search.test.js +141 -0
- package/dist/test/search.test.js.map +1 -0
- package/dist/test/speculation-cap.test.d.ts +17 -0
- package/dist/test/speculation-cap.test.d.ts.map +1 -0
- package/dist/test/speculation-cap.test.js +157 -0
- package/dist/test/speculation-cap.test.js.map +1 -0
- package/dist/test/strategy-persistence.test.d.ts +15 -0
- package/dist/test/strategy-persistence.test.d.ts.map +1 -0
- package/dist/test/strategy-persistence.test.js +244 -0
- package/dist/test/strategy-persistence.test.js.map +1 -0
- package/dist/test/tool-pruning.test.d.ts +10 -0
- package/dist/test/tool-pruning.test.d.ts.map +1 -0
- package/dist/test/tool-pruning.test.js +140 -0
- package/dist/test/tool-pruning.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/context-manager.ts +142 -10
- package/src/strategies/autobiographical.ts +872 -81
- package/src/types/index.ts +14 -1
- package/src/types/strategy.ts +223 -0
|
@@ -39,6 +39,18 @@ export class AutobiographicalStrategy {
|
|
|
39
39
|
headWindowStartId = null;
|
|
40
40
|
/** Cached result of getHeadWindowStartIndex to avoid repeated linear scans. */
|
|
41
41
|
_cachedHeadStartIndex = null;
|
|
42
|
+
/** Chronicle store for persistent state. Set in `initialize()`. */
|
|
43
|
+
store = null;
|
|
44
|
+
/** Namespace for state-id scoping. Set in `initialize()`. */
|
|
45
|
+
ns = '';
|
|
46
|
+
get summariesStateId() { return `${this.ns}/autobio:summaries`; }
|
|
47
|
+
get counterStateId() { return `${this.ns}/autobio:counter`; }
|
|
48
|
+
get mergeQueueStateId() { return `${this.ns}/autobio:mergeQueue`; }
|
|
49
|
+
get pinsStateId() { return `${this.ns}/autobio:pins`; }
|
|
50
|
+
/** Protected ranges (pins + documents). Loaded from chronicle in initialize. */
|
|
51
|
+
pins = [];
|
|
52
|
+
/** Monotonically increasing counter for pin ids. Persisted as part of the pins snapshot. */
|
|
53
|
+
pinIdCounter = 0;
|
|
42
54
|
constructor(config = {}) {
|
|
43
55
|
this.config = { ...DEFAULT_AUTOBIOGRAPHICAL_CONFIG, ...config };
|
|
44
56
|
// Hierarchical is on by default; set hierarchical: false to use legacy single-level
|
|
@@ -52,8 +64,14 @@ export class AutobiographicalStrategy {
|
|
|
52
64
|
}
|
|
53
65
|
}
|
|
54
66
|
async initialize(ctx) {
|
|
67
|
+
// Bind to the chronicle store + namespace for persistent strategy state.
|
|
68
|
+
this.store = ctx.store;
|
|
69
|
+
this.ns = ctx.namespace;
|
|
70
|
+
this.registerStates();
|
|
71
|
+
this.loadPersistedState();
|
|
55
72
|
// Restore headWindowStartId from last topic transition message
|
|
56
73
|
const messages = ctx.messageStore.getAll();
|
|
74
|
+
this.headWindowStartId = null;
|
|
57
75
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
58
76
|
if (this.isTopicTransitionMessage(messages[i])) {
|
|
59
77
|
this.headWindowStartId = messages[i].id;
|
|
@@ -62,6 +80,282 @@ export class AutobiographicalStrategy {
|
|
|
62
80
|
}
|
|
63
81
|
this.rebuildChunks(ctx.messageStore);
|
|
64
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Register the three Chronicle state slots this strategy uses.
|
|
85
|
+
* Idempotent — chronicle throws if a state is already registered, which we
|
|
86
|
+
* swallow (the existing slot is what we want).
|
|
87
|
+
*/
|
|
88
|
+
registerStates() {
|
|
89
|
+
if (!this.store)
|
|
90
|
+
return;
|
|
91
|
+
try {
|
|
92
|
+
this.store.registerState({
|
|
93
|
+
id: this.summariesStateId,
|
|
94
|
+
strategy: 'append_log',
|
|
95
|
+
deltaSnapshotEvery: 50,
|
|
96
|
+
fullSnapshotEvery: 10,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch { /* already registered */ }
|
|
100
|
+
try {
|
|
101
|
+
this.store.registerState({
|
|
102
|
+
id: this.counterStateId,
|
|
103
|
+
strategy: 'snapshot',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch { /* already registered */ }
|
|
107
|
+
try {
|
|
108
|
+
this.store.registerState({
|
|
109
|
+
id: this.mergeQueueStateId,
|
|
110
|
+
strategy: 'snapshot',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch { /* already registered */ }
|
|
114
|
+
try {
|
|
115
|
+
this.store.registerState({
|
|
116
|
+
id: this.pinsStateId,
|
|
117
|
+
strategy: 'snapshot',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch { /* already registered */ }
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Load summaries, counter, and pending merges from chronicle into the
|
|
124
|
+
* in-memory mirrors. Called on every (re)initialize so branch switches
|
|
125
|
+
* pick up the new branch's state.
|
|
126
|
+
*/
|
|
127
|
+
loadPersistedState() {
|
|
128
|
+
if (!this.store) {
|
|
129
|
+
this.summaries = [];
|
|
130
|
+
this.summaryIdCounter = 0;
|
|
131
|
+
this.mergeQueue = [];
|
|
132
|
+
this.pins = [];
|
|
133
|
+
this.pinIdCounter = 0;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const summaries = this.store.getStateJson(this.summariesStateId);
|
|
137
|
+
this.summaries = Array.isArray(summaries) ? summaries : [];
|
|
138
|
+
const counter = this.store.getStateJson(this.counterStateId);
|
|
139
|
+
this.summaryIdCounter = typeof counter === 'number' ? counter : 0;
|
|
140
|
+
const queue = this.store.getStateJson(this.mergeQueueStateId);
|
|
141
|
+
this.mergeQueue = Array.isArray(queue)
|
|
142
|
+
? queue
|
|
143
|
+
: [];
|
|
144
|
+
const pinsState = this.store.getStateJson(this.pinsStateId);
|
|
145
|
+
if (pinsState && typeof pinsState === 'object' && Array.isArray(pinsState.pins)) {
|
|
146
|
+
const ps = pinsState;
|
|
147
|
+
this.pins = ps.pins;
|
|
148
|
+
this.pinIdCounter = typeof ps.counter === 'number' ? ps.counter : ps.pins.length;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
this.pins = [];
|
|
152
|
+
this.pinIdCounter = 0;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Persist the current pins + counter as a single snapshot. */
|
|
156
|
+
persistPins() {
|
|
157
|
+
this.store?.setStateJson(this.pinsStateId, {
|
|
158
|
+
pins: this.pins,
|
|
159
|
+
counter: this.pinIdCounter,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// ============================================================================
|
|
163
|
+
// Pins / documents (protected ranges)
|
|
164
|
+
// ============================================================================
|
|
165
|
+
/**
|
|
166
|
+
* Pin a range of messages so they aren't compressed and render raw at
|
|
167
|
+
* their original position. Returns the pin id.
|
|
168
|
+
*/
|
|
169
|
+
pinRange(firstMessageId, lastMessageId, opts) {
|
|
170
|
+
const id = `pin-${this.pinIdCounter++}`;
|
|
171
|
+
this.pins.push({
|
|
172
|
+
id,
|
|
173
|
+
firstMessageId,
|
|
174
|
+
lastMessageId,
|
|
175
|
+
kind: 'pin',
|
|
176
|
+
name: opts?.name,
|
|
177
|
+
created: Date.now(),
|
|
178
|
+
});
|
|
179
|
+
this.persistPins();
|
|
180
|
+
return id;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Mark a single message as a "document" — semantically a body of
|
|
184
|
+
* information the agent wants to retain in full. Functionally a
|
|
185
|
+
* single-message pin with `kind: 'document'`.
|
|
186
|
+
*/
|
|
187
|
+
markDocument(messageId, opts) {
|
|
188
|
+
const id = `pin-${this.pinIdCounter++}`;
|
|
189
|
+
this.pins.push({
|
|
190
|
+
id,
|
|
191
|
+
firstMessageId: messageId,
|
|
192
|
+
lastMessageId: messageId,
|
|
193
|
+
kind: 'document',
|
|
194
|
+
name: opts?.name,
|
|
195
|
+
created: Date.now(),
|
|
196
|
+
});
|
|
197
|
+
this.persistPins();
|
|
198
|
+
return id;
|
|
199
|
+
}
|
|
200
|
+
/** Remove a pin or document mark by id. Returns true if removed. */
|
|
201
|
+
unpin(pinId) {
|
|
202
|
+
const before = this.pins.length;
|
|
203
|
+
this.pins = this.pins.filter(p => p.id !== pinId);
|
|
204
|
+
if (this.pins.length < before) {
|
|
205
|
+
this.persistPins();
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
/** Read-only list of all current pins. */
|
|
211
|
+
listPins() {
|
|
212
|
+
return this.pins;
|
|
213
|
+
}
|
|
214
|
+
// ============================================================================
|
|
215
|
+
// Search (gap #7)
|
|
216
|
+
// ============================================================================
|
|
217
|
+
/**
|
|
218
|
+
* Look up a single summary by id. Returns null if not found.
|
|
219
|
+
*/
|
|
220
|
+
getSummary(id) {
|
|
221
|
+
return this.summaries.find(s => s.id === id) ?? null;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Search summaries by substring or regex over their content.
|
|
225
|
+
*
|
|
226
|
+
* Result ordering: matches by descending hit count, then by descending
|
|
227
|
+
* `created` timestamp (newest first within the same hit count).
|
|
228
|
+
*
|
|
229
|
+
* Default behavior: only "live" (unmerged) summaries are searched. Set
|
|
230
|
+
* `includeMerged: true` to also include summaries that have been folded
|
|
231
|
+
* into a higher level.
|
|
232
|
+
*/
|
|
233
|
+
searchSummaries(query) {
|
|
234
|
+
const limit = query.limit ?? 50;
|
|
235
|
+
const includeMerged = query.includeMerged ?? false;
|
|
236
|
+
// Build the matcher
|
|
237
|
+
let matcher = null;
|
|
238
|
+
if (query.regex) {
|
|
239
|
+
const flags = query.regex.flags.includes('g') ? query.regex.flags : query.regex.flags + 'g';
|
|
240
|
+
const re = new RegExp(query.regex.source, flags);
|
|
241
|
+
matcher = (content) => {
|
|
242
|
+
const matches = content.match(re);
|
|
243
|
+
return matches ? matches.length : 0;
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
else if (query.text) {
|
|
247
|
+
const needle = query.text.toLowerCase();
|
|
248
|
+
matcher = (content) => {
|
|
249
|
+
const hay = content.toLowerCase();
|
|
250
|
+
let count = 0;
|
|
251
|
+
let idx = 0;
|
|
252
|
+
while ((idx = hay.indexOf(needle, idx)) !== -1) {
|
|
253
|
+
count++;
|
|
254
|
+
idx += needle.length || 1;
|
|
255
|
+
}
|
|
256
|
+
return count;
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
// No pattern: every summary "matches" once
|
|
261
|
+
matcher = () => 1;
|
|
262
|
+
}
|
|
263
|
+
const levelsFilter = query.levels && query.levels.length > 0 ? new Set(query.levels) : null;
|
|
264
|
+
const results = [];
|
|
265
|
+
for (const s of this.summaries) {
|
|
266
|
+
if (!includeMerged && s.mergedInto)
|
|
267
|
+
continue;
|
|
268
|
+
if (levelsFilter && !levelsFilter.has(s.level))
|
|
269
|
+
continue;
|
|
270
|
+
const matches = matcher(s.content);
|
|
271
|
+
if (matches > 0) {
|
|
272
|
+
results.push({ summary: s, matches });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
results.sort((a, b) => {
|
|
276
|
+
if (b.matches !== a.matches)
|
|
277
|
+
return b.matches - a.matches;
|
|
278
|
+
return b.summary.created - a.summary.created;
|
|
279
|
+
});
|
|
280
|
+
return results.slice(0, limit);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Whether a given message position is inside any protected range.
|
|
284
|
+
* Uses a position map (computed by caller) so callers can avoid
|
|
285
|
+
* repeated per-message lookups in tight loops.
|
|
286
|
+
*/
|
|
287
|
+
isPositionPinned(position, pinPositions) {
|
|
288
|
+
return pinPositions.has(position);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Build a set of message-store positions covered by any pin. O(N pins · K range).
|
|
292
|
+
* Returns positions for which the message exists; orphan pins (deleted
|
|
293
|
+
* messages) are silently skipped.
|
|
294
|
+
*/
|
|
295
|
+
pinnedPositions(messages) {
|
|
296
|
+
if (this.pins.length === 0)
|
|
297
|
+
return new Set();
|
|
298
|
+
const positionOf = new Map();
|
|
299
|
+
for (let i = 0; i < messages.length; i++) {
|
|
300
|
+
positionOf.set(messages[i].id, i);
|
|
301
|
+
}
|
|
302
|
+
const out = new Set();
|
|
303
|
+
for (const pin of this.pins) {
|
|
304
|
+
const first = positionOf.get(pin.firstMessageId);
|
|
305
|
+
const last = positionOf.get(pin.lastMessageId);
|
|
306
|
+
if (first === undefined || last === undefined)
|
|
307
|
+
continue;
|
|
308
|
+
const lo = Math.min(first, last);
|
|
309
|
+
const hi = Math.max(first, last);
|
|
310
|
+
for (let i = lo; i <= hi; i++)
|
|
311
|
+
out.add(i);
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Append a summary to the in-memory list and to the chronicle AppendLog.
|
|
317
|
+
* Single point so subclasses inherit persistence.
|
|
318
|
+
*/
|
|
319
|
+
pushSummary(entry) {
|
|
320
|
+
this.summaries.push(entry);
|
|
321
|
+
this.store?.appendToStateJson(this.summariesStateId, entry);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Mark a summary as merged into a higher-level summary, updating the
|
|
325
|
+
* chronicle copy at the same index. Index is the position in `this.summaries`.
|
|
326
|
+
*/
|
|
327
|
+
setMergedInto(entry, mergedIntoId) {
|
|
328
|
+
entry.mergedInto = mergedIntoId;
|
|
329
|
+
if (!this.store)
|
|
330
|
+
return;
|
|
331
|
+
const index = this.summaries.indexOf(entry);
|
|
332
|
+
if (index < 0)
|
|
333
|
+
return;
|
|
334
|
+
this.store.editStateItem(this.summariesStateId, index, Buffer.from(JSON.stringify(entry)));
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Allocate the next summary-id counter value and persist the new counter.
|
|
338
|
+
*/
|
|
339
|
+
nextSummaryIdCounter() {
|
|
340
|
+
const value = this.summaryIdCounter++;
|
|
341
|
+
this.store?.setStateJson(this.counterStateId, this.summaryIdCounter);
|
|
342
|
+
return value;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Push to the merge queue and persist the new queue snapshot.
|
|
346
|
+
*/
|
|
347
|
+
enqueueMerge(merge) {
|
|
348
|
+
this.mergeQueue.push(merge);
|
|
349
|
+
this.store?.setStateJson(this.mergeQueueStateId, this.mergeQueue);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Pop from the merge queue and persist the new queue snapshot.
|
|
353
|
+
*/
|
|
354
|
+
dequeueMerge() {
|
|
355
|
+
const merge = this.mergeQueue.shift();
|
|
356
|
+
this.store?.setStateJson(this.mergeQueueStateId, this.mergeQueue);
|
|
357
|
+
return merge;
|
|
358
|
+
}
|
|
65
359
|
checkReadiness() {
|
|
66
360
|
if (this.pendingCompression) {
|
|
67
361
|
return {
|
|
@@ -87,13 +381,75 @@ export class AutobiographicalStrategy {
|
|
|
87
381
|
}
|
|
88
382
|
async onNewMessage(message, ctx) {
|
|
89
383
|
this.rebuildChunks(ctx.messageStore);
|
|
90
|
-
// Auto-tick: fire compression in the background
|
|
91
|
-
// the
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
384
|
+
// Auto-tick: fire speculative compression in the background. After
|
|
385
|
+
// each tick completes, if the queue still has work AND we're under
|
|
386
|
+
// the speculation cap AND preflight allows, schedule another tick.
|
|
387
|
+
// This drains the queue ahead of need rather than one-chunk-per-
|
|
388
|
+
// user-turn (reactive). Combined with ContextManager.compile not
|
|
389
|
+
// awaiting pendingCompression, the agent's response and background
|
|
390
|
+
// compression run truly in parallel.
|
|
391
|
+
if (this.config.autoTickOnNewMessage && !this.pendingCompression) {
|
|
392
|
+
this.driveSpeculativeDrain(ctx);
|
|
95
393
|
}
|
|
96
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* Background-drain loop: keeps calling tick() while there's queued work,
|
|
397
|
+
* subject to the speculation cap and preflight hook. Recurses via
|
|
398
|
+
* `queueMicrotask` so one chunk's compression doesn't block the
|
|
399
|
+
* scheduling of the next.
|
|
400
|
+
*
|
|
401
|
+
* Stops if a tick fails to make progress (queue size unchanged) — guards
|
|
402
|
+
* against runaway recursion when tick is a no-op (e.g. no membrane
|
|
403
|
+
* configured, or a subclass override that doesn't process the queue).
|
|
404
|
+
*/
|
|
405
|
+
driveSpeculativeDrain(ctx) {
|
|
406
|
+
if (this.pendingCompression)
|
|
407
|
+
return;
|
|
408
|
+
if (this.compressionQueue.length === 0 && this.mergeQueue.length === 0)
|
|
409
|
+
return;
|
|
410
|
+
if (this.isAtSpeculativeCap())
|
|
411
|
+
return;
|
|
412
|
+
if (!this.shouldCompressPreflight())
|
|
413
|
+
return;
|
|
414
|
+
const beforeChunks = this.compressionQueue.length;
|
|
415
|
+
const beforeMerges = this.mergeQueue.length;
|
|
416
|
+
this.tick(ctx)
|
|
417
|
+
.then(() => {
|
|
418
|
+
const afterChunks = this.compressionQueue.length;
|
|
419
|
+
const afterMerges = this.mergeQueue.length;
|
|
420
|
+
// Made progress if either queue shrank.
|
|
421
|
+
const progressed = afterChunks < beforeChunks || afterMerges < beforeMerges;
|
|
422
|
+
if (!progressed)
|
|
423
|
+
return;
|
|
424
|
+
// Recurse to drain more. queueMicrotask defers until the current
|
|
425
|
+
// task is done, letting other code (the agent's stream consumer)
|
|
426
|
+
// interleave.
|
|
427
|
+
queueMicrotask(() => this.driveSpeculativeDrain(ctx));
|
|
428
|
+
})
|
|
429
|
+
.catch((err) => {
|
|
430
|
+
console.error('AutobiographicalStrategy: speculative-drain error:', err);
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Whether the strategy's pending+queued L1 budget has reached the cap
|
|
435
|
+
* configured by `maxSpeculativeL1s`. If no cap is set, always false.
|
|
436
|
+
*/
|
|
437
|
+
isAtSpeculativeCap() {
|
|
438
|
+
const cap = this.config.maxSpeculativeL1s;
|
|
439
|
+
if (cap === undefined || cap < 0)
|
|
440
|
+
return false;
|
|
441
|
+
const unmergedL1s = this.summaries.filter(s => s.level === 1 && !s.mergedInto).length;
|
|
442
|
+
return unmergedL1s + this.compressionQueue.length > cap;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Preflight hook for whether speculative compression should fire on
|
|
446
|
+
* `onNewMessage`. Returns true by default (current eager behavior).
|
|
447
|
+
* Subclasses can override for predictive scheduling — e.g. only fire
|
|
448
|
+
* when the live tail token count is approaching some threshold.
|
|
449
|
+
*/
|
|
450
|
+
shouldCompressPreflight() {
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
97
453
|
async tick(ctx) {
|
|
98
454
|
if (this.pendingCompression)
|
|
99
455
|
return;
|
|
@@ -119,11 +475,26 @@ export class AutobiographicalStrategy {
|
|
|
119
475
|
return;
|
|
120
476
|
}
|
|
121
477
|
// Priority 2: Execute pending merges (hierarchical only)
|
|
478
|
+
//
|
|
479
|
+
// Peek at the head rather than dequeueing eagerly: dequeueMerge persists
|
|
480
|
+
// the shorter queue *before* the LLM call leaves the building, so a
|
|
481
|
+
// transient failure (429, network drop, timeout, executeMerge throw)
|
|
482
|
+
// would silently lose the merge from disk and the sources would sit at
|
|
483
|
+
// level N-1 with no mergedInto pointers forever. Commit the removal
|
|
484
|
+
// only after the merge succeeds; on failure, the queue keeps its entry
|
|
485
|
+
// and the next tick() retries it.
|
|
122
486
|
if (this.config.hierarchical && this.mergeQueue.length > 0) {
|
|
123
|
-
const merge = this.mergeQueue
|
|
487
|
+
const merge = this.mergeQueue[0];
|
|
124
488
|
this.pendingCompression = this.executeMerge(merge.level, merge.sourceIds, ctx);
|
|
125
489
|
try {
|
|
126
490
|
await this.pendingCompression;
|
|
491
|
+
// Success: drop from head and persist the shorter queue. We
|
|
492
|
+
// re-check that head is still our merge in case some future code
|
|
493
|
+
// path mutates the queue mid-await (today no other site does,
|
|
494
|
+
// but the assertion makes that invariant explicit).
|
|
495
|
+
if (this.mergeQueue[0] === merge) {
|
|
496
|
+
this.dequeueMerge();
|
|
497
|
+
}
|
|
127
498
|
}
|
|
128
499
|
finally {
|
|
129
500
|
this.pendingCompression = null;
|
|
@@ -150,6 +521,39 @@ export class AutobiographicalStrategy {
|
|
|
150
521
|
pendingMerges: this.mergeQueue.length,
|
|
151
522
|
};
|
|
152
523
|
}
|
|
524
|
+
/**
|
|
525
|
+
* Richer per-render stats: requires a message store view to compute the
|
|
526
|
+
* head + tail (recent window) sizes. Returns counts AND token sums per
|
|
527
|
+
* summary level, so observers can see "how much of the agent's context
|
|
528
|
+
* is in raw tail vs folded into L1/L2/L3."
|
|
529
|
+
*
|
|
530
|
+
* Useful for TUI / dashboards. The token sums use the strategy's own
|
|
531
|
+
* token estimates (which match what `select()` uses for budget math).
|
|
532
|
+
*/
|
|
533
|
+
getRenderStats(store) {
|
|
534
|
+
const messages = store.getAll();
|
|
535
|
+
const headStart = this.getHeadWindowStartIndex(store);
|
|
536
|
+
const headEnd = this.getHeadWindowEnd(store);
|
|
537
|
+
const recentStart = this.getRecentWindowStart(store);
|
|
538
|
+
const sumTokens = (slice) => slice.reduce((acc, m) => acc + store.estimateTokens(m), 0);
|
|
539
|
+
const headMsgs = messages.slice(headStart, headEnd);
|
|
540
|
+
const tailMsgs = messages.slice(recentStart);
|
|
541
|
+
const live = (level) => this.summaries.filter(s => s.level === level && !s.mergedInto);
|
|
542
|
+
const sumLevelTokens = (level) => live(level).reduce((acc, s) => acc + s.tokens, 0);
|
|
543
|
+
return {
|
|
544
|
+
head: { messages: headMsgs.length, tokens: sumTokens(headMsgs) },
|
|
545
|
+
tail: { messages: tailMsgs.length, tokens: sumTokens(tailMsgs) },
|
|
546
|
+
summaries: {
|
|
547
|
+
l1: { count: live(1).length, tokens: sumLevelTokens(1) },
|
|
548
|
+
l2: { count: live(2).length, tokens: sumLevelTokens(2) },
|
|
549
|
+
l3: { count: live(3).length, tokens: sumLevelTokens(3) },
|
|
550
|
+
},
|
|
551
|
+
pending: {
|
|
552
|
+
chunks: this.chunks.filter(c => !c.compressed).length,
|
|
553
|
+
merges: this.mergeQueue.length,
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
}
|
|
153
557
|
// ============================================================================
|
|
154
558
|
// Legacy (single-level) path
|
|
155
559
|
// ============================================================================
|
|
@@ -166,7 +570,7 @@ export class AutobiographicalStrategy {
|
|
|
166
570
|
const msg = messages[i];
|
|
167
571
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
168
572
|
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
169
|
-
if (totalTokens + tokens
|
|
573
|
+
if (this.isOverBudget(totalTokens + tokens, maxTokens))
|
|
170
574
|
break;
|
|
171
575
|
entries.push({
|
|
172
576
|
index: entries.length,
|
|
@@ -197,15 +601,18 @@ export class AutobiographicalStrategy {
|
|
|
197
601
|
content: [{ type: 'text', text: contextLabel }],
|
|
198
602
|
sourceRelation: 'derived',
|
|
199
603
|
};
|
|
604
|
+
// Synthesised summary turns must respect maxMessageTokens just like raw
|
|
605
|
+
// copies do — otherwise a runaway diary can starve recent messages.
|
|
606
|
+
const answerContent = [{ type: 'text', text: chunk.diary }];
|
|
200
607
|
const answerEntry = {
|
|
201
608
|
index: entries.length + 1,
|
|
202
609
|
participant: summaryParticipant,
|
|
203
|
-
content:
|
|
610
|
+
content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
|
|
204
611
|
sourceRelation: 'derived',
|
|
205
612
|
};
|
|
206
613
|
const pairTokens = this.estimateTokens(questionEntry.content) +
|
|
207
614
|
this.estimateTokens(answerEntry.content);
|
|
208
|
-
if (totalTokens + pairTokens
|
|
615
|
+
if (this.isOverBudget(totalTokens + pairTokens, maxTokens))
|
|
209
616
|
break;
|
|
210
617
|
entries.push(questionEntry);
|
|
211
618
|
entries.push(answerEntry);
|
|
@@ -216,7 +623,7 @@ export class AutobiographicalStrategy {
|
|
|
216
623
|
for (const msg of chunk.messages) {
|
|
217
624
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
218
625
|
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
219
|
-
if (totalTokens + tokens
|
|
626
|
+
if (this.isOverBudget(totalTokens + tokens, maxTokens))
|
|
220
627
|
break;
|
|
221
628
|
entries.push({
|
|
222
629
|
index: entries.length,
|
|
@@ -240,7 +647,7 @@ export class AutobiographicalStrategy {
|
|
|
240
647
|
const msg = messages[i];
|
|
241
648
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
242
649
|
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
243
|
-
if (totalTokens + tokens
|
|
650
|
+
if (this.isOverBudget(totalTokens + tokens, maxTokens))
|
|
244
651
|
break;
|
|
245
652
|
entries.push({
|
|
246
653
|
index: entries.length,
|
|
@@ -253,12 +660,47 @@ export class AutobiographicalStrategy {
|
|
|
253
660
|
}
|
|
254
661
|
// 3. Recent uncompressed messages (skip those already in head window)
|
|
255
662
|
const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
|
|
256
|
-
|
|
663
|
+
this.emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokens);
|
|
664
|
+
this.trimOrphanedToolUse(entries);
|
|
665
|
+
this.pruneToolEntries(entries);
|
|
666
|
+
return entries;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Emit recent-window messages, evicting OLDEST-first when the budget is tight.
|
|
670
|
+
*
|
|
671
|
+
* The previous loop iterated `recentStart → messages.length` forward and broke
|
|
672
|
+
* on `totalTokens + tokens > maxTokens`. When the head/summary section eats most
|
|
673
|
+
* of the budget, the loop emits the oldest messages of the window and aborts
|
|
674
|
+
* before reaching the newest — exactly the messages an agent needs to act on.
|
|
675
|
+
* This helper picks newest-first within the budget, then emits the kept set in
|
|
676
|
+
* chronological order, dropping a leading orphan tool_result if its tool_use
|
|
677
|
+
* fell into the evicted older portion.
|
|
678
|
+
*/
|
|
679
|
+
emitRecentNewestFirst(entries, store, messages, recentStart, msgCap, maxTokens, totalTokensBefore) {
|
|
680
|
+
if (recentStart >= messages.length)
|
|
681
|
+
return;
|
|
682
|
+
const accepted = [];
|
|
683
|
+
let acceptedTokens = 0;
|
|
684
|
+
for (let i = messages.length - 1; i >= recentStart; i--) {
|
|
257
685
|
const msg = messages[i];
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
686
|
+
const tokens = msgCap > 0
|
|
687
|
+
? Math.min(store.estimateTokens(msg), msgCap + 50)
|
|
688
|
+
: store.estimateTokens(msg);
|
|
689
|
+
if (this.isOverBudget(totalTokensBefore + acceptedTokens + tokens, maxTokens))
|
|
261
690
|
break;
|
|
691
|
+
accepted.push(i);
|
|
692
|
+
acceptedTokens += tokens;
|
|
693
|
+
}
|
|
694
|
+
accepted.reverse();
|
|
695
|
+
// Drop leading orphan tool_result(s): their matching tool_use was evicted.
|
|
696
|
+
while (accepted.length > 0 &&
|
|
697
|
+
this.hasToolResult(messages[accepted[0]]) &&
|
|
698
|
+
!this.hasToolUse(messages[accepted[0]])) {
|
|
699
|
+
accepted.shift();
|
|
700
|
+
}
|
|
701
|
+
for (const i of accepted) {
|
|
702
|
+
const msg = messages[i];
|
|
703
|
+
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
262
704
|
entries.push({
|
|
263
705
|
index: entries.length,
|
|
264
706
|
sourceMessageId: msg.id,
|
|
@@ -266,10 +708,7 @@ export class AutobiographicalStrategy {
|
|
|
266
708
|
participant: msg.participant,
|
|
267
709
|
content,
|
|
268
710
|
});
|
|
269
|
-
totalTokens += tokens;
|
|
270
711
|
}
|
|
271
|
-
this.trimOrphanedToolUse(entries);
|
|
272
|
-
return entries;
|
|
273
712
|
}
|
|
274
713
|
async compressChunkLegacy(chunk, ctx) {
|
|
275
714
|
if (!ctx.membrane) {
|
|
@@ -295,7 +734,6 @@ export class AutobiographicalStrategy {
|
|
|
295
734
|
config: {
|
|
296
735
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
297
736
|
maxTokens: 2000,
|
|
298
|
-
temperature: 0,
|
|
299
737
|
},
|
|
300
738
|
};
|
|
301
739
|
try {
|
|
@@ -416,7 +854,6 @@ export class AutobiographicalStrategy {
|
|
|
416
854
|
config: {
|
|
417
855
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
418
856
|
maxTokens: Math.round(targetTokens * 1.5),
|
|
419
|
-
temperature: 0,
|
|
420
857
|
},
|
|
421
858
|
};
|
|
422
859
|
try {
|
|
@@ -427,7 +864,7 @@ export class AutobiographicalStrategy {
|
|
|
427
864
|
.join('\n');
|
|
428
865
|
const messageIds = chunk.messages.map(m => m.id);
|
|
429
866
|
const entry = {
|
|
430
|
-
id: `L1-${this.
|
|
867
|
+
id: `L1-${this.nextSummaryIdCounter()}`,
|
|
431
868
|
level: 1,
|
|
432
869
|
content: summaryText,
|
|
433
870
|
tokens: Math.ceil(summaryText.length / 4),
|
|
@@ -440,7 +877,7 @@ export class AutobiographicalStrategy {
|
|
|
440
877
|
created: Date.now(),
|
|
441
878
|
phaseType: chunk.phaseType,
|
|
442
879
|
};
|
|
443
|
-
this.
|
|
880
|
+
this.pushSummary(entry);
|
|
444
881
|
chunk.compressed = true;
|
|
445
882
|
chunk.summaryId = entry.id;
|
|
446
883
|
this._compressionCount++;
|
|
@@ -454,23 +891,37 @@ export class AutobiographicalStrategy {
|
|
|
454
891
|
/**
|
|
455
892
|
* Check if unmerged summary counts exceed the merge threshold.
|
|
456
893
|
* Enqueues merge operations if so.
|
|
894
|
+
*
|
|
895
|
+
* Skips L1s/L2s that are already in a pending merge — without this guard,
|
|
896
|
+
* each new summary above threshold re-enqueues a merge for the same
|
|
897
|
+
* already-eligible siblings, producing N near-identical higher-level
|
|
898
|
+
* summaries when the queue eventually drains.
|
|
457
899
|
*/
|
|
458
900
|
checkMergeThreshold() {
|
|
459
901
|
const threshold = this.config.mergeThreshold ?? 6;
|
|
902
|
+
// IDs that are already part of a queued merge — exclude them from
|
|
903
|
+
// eligibility so we don't re-enqueue.
|
|
904
|
+
const queuedL1 = new Set();
|
|
905
|
+
const queuedL2 = new Set();
|
|
906
|
+
for (const m of this.mergeQueue) {
|
|
907
|
+
const set = m.level === 2 ? queuedL1 : queuedL2;
|
|
908
|
+
for (const id of m.sourceIds)
|
|
909
|
+
set.add(id);
|
|
910
|
+
}
|
|
460
911
|
// Check L1 → L2
|
|
461
|
-
const unmergedL1 = this.summaries.filter(s => s.level === 1 && !s.mergedInto);
|
|
912
|
+
const unmergedL1 = this.summaries.filter(s => s.level === 1 && !s.mergedInto && !queuedL1.has(s.id));
|
|
462
913
|
if (unmergedL1.length >= threshold) {
|
|
463
914
|
const toMerge = unmergedL1.slice(0, threshold);
|
|
464
|
-
this.
|
|
915
|
+
this.enqueueMerge({
|
|
465
916
|
level: 2,
|
|
466
917
|
sourceIds: toMerge.map(s => s.id),
|
|
467
918
|
});
|
|
468
919
|
}
|
|
469
920
|
// Check L2 → L3
|
|
470
|
-
const unmergedL2 = this.summaries.filter(s => s.level === 2 && !s.mergedInto);
|
|
921
|
+
const unmergedL2 = this.summaries.filter(s => s.level === 2 && !s.mergedInto && !queuedL2.has(s.id));
|
|
471
922
|
if (unmergedL2.length >= threshold) {
|
|
472
923
|
const toMerge = unmergedL2.slice(0, threshold);
|
|
473
|
-
this.
|
|
924
|
+
this.enqueueMerge({
|
|
474
925
|
level: 3,
|
|
475
926
|
sourceIds: toMerge.map(s => s.id),
|
|
476
927
|
});
|
|
@@ -491,6 +942,14 @@ export class AutobiographicalStrategy {
|
|
|
491
942
|
console.warn('executeMerge: some source summaries not found, skipping');
|
|
492
943
|
return;
|
|
493
944
|
}
|
|
945
|
+
// Defensive: if every source is already mergedInto something, this is a
|
|
946
|
+
// stale queue entry (could happen if multiple merges for the same
|
|
947
|
+
// sourceIds were enqueued before the dedup fix in checkMergeThreshold).
|
|
948
|
+
// Skip rather than produce a redundant near-identical higher-level entry.
|
|
949
|
+
if (sources.every(s => s.mergedInto)) {
|
|
950
|
+
console.warn(`executeMerge: all sources already merged into ${sources[0].mergedInto}, skipping (stale queue entry)`);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
494
953
|
const targetTokens = this.config.summaryTargetTokens ?? 2000;
|
|
495
954
|
const participant = this.config.summaryParticipant ?? 'Claude';
|
|
496
955
|
// Build message array
|
|
@@ -530,7 +989,6 @@ export class AutobiographicalStrategy {
|
|
|
530
989
|
config: {
|
|
531
990
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
532
991
|
maxTokens: Math.round(targetTokens * 1.5),
|
|
533
|
-
temperature: 0,
|
|
534
992
|
},
|
|
535
993
|
};
|
|
536
994
|
try {
|
|
@@ -546,7 +1004,7 @@ export class AutobiographicalStrategy {
|
|
|
546
1004
|
};
|
|
547
1005
|
const sourceLevel = (targetLevel - 1);
|
|
548
1006
|
const newEntry = {
|
|
549
|
-
id: `L${targetLevel}-${this.
|
|
1007
|
+
id: `L${targetLevel}-${this.nextSummaryIdCounter()}`,
|
|
550
1008
|
level: targetLevel,
|
|
551
1009
|
content: mergedText,
|
|
552
1010
|
tokens: Math.ceil(mergedText.length / 4),
|
|
@@ -555,10 +1013,14 @@ export class AutobiographicalStrategy {
|
|
|
555
1013
|
sourceRange,
|
|
556
1014
|
created: Date.now(),
|
|
557
1015
|
};
|
|
558
|
-
|
|
559
|
-
//
|
|
1016
|
+
// Append the new merged entry first, then mark sources. Persist each
|
|
1017
|
+
// mergedInto edit individually so chronicle reflects the same shape as
|
|
1018
|
+
// the in-memory mirror. (If the process crashes mid-loop, restart sees
|
|
1019
|
+
// the new entry plus a partial set of marked sources; un-marked sources
|
|
1020
|
+
// would re-trigger a merge — accept the rare duplicate over data loss.)
|
|
1021
|
+
this.pushSummary(newEntry);
|
|
560
1022
|
for (const source of sources) {
|
|
561
|
-
source
|
|
1023
|
+
this.setMergedInto(source, newEntry.id);
|
|
562
1024
|
}
|
|
563
1025
|
// Check if this merge triggers a further merge
|
|
564
1026
|
this.checkMergeThreshold();
|
|
@@ -585,7 +1047,7 @@ export class AutobiographicalStrategy {
|
|
|
585
1047
|
const msg = messages[i];
|
|
586
1048
|
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
587
1049
|
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
588
|
-
if (totalTokens + tokens
|
|
1050
|
+
if (this.isOverBudget(totalTokens + tokens, maxTokens))
|
|
589
1051
|
break;
|
|
590
1052
|
entries.push({
|
|
591
1053
|
index: entries.length,
|
|
@@ -620,7 +1082,7 @@ export class AutobiographicalStrategy {
|
|
|
620
1082
|
for (const s of shownL3) {
|
|
621
1083
|
if (l3Used + s.tokens > l3Budget)
|
|
622
1084
|
break;
|
|
623
|
-
if (totalTokens + totalSummaryTokens + s.tokens
|
|
1085
|
+
if (this.isOverBudget(totalTokens + totalSummaryTokens + s.tokens, maxTokens))
|
|
624
1086
|
break;
|
|
625
1087
|
selectedSummaries.push(s);
|
|
626
1088
|
l3Used += s.tokens;
|
|
@@ -633,7 +1095,7 @@ export class AutobiographicalStrategy {
|
|
|
633
1095
|
for (const s of shownL2) {
|
|
634
1096
|
if (l2Used + s.tokens > l2Effective)
|
|
635
1097
|
break;
|
|
636
|
-
if (totalTokens + totalSummaryTokens + s.tokens
|
|
1098
|
+
if (this.isOverBudget(totalTokens + totalSummaryTokens + s.tokens, maxTokens))
|
|
637
1099
|
break;
|
|
638
1100
|
selectedSummaries.push(s);
|
|
639
1101
|
l2Used += s.tokens;
|
|
@@ -646,46 +1108,182 @@ export class AutobiographicalStrategy {
|
|
|
646
1108
|
const { selected: l1Selected, tokensUsed: l1Used } = this.selectL1Summaries(shownL1, l1Effective, l1Remaining);
|
|
647
1109
|
selectedSummaries.push(...l1Selected);
|
|
648
1110
|
totalSummaryTokens += l1Used;
|
|
649
|
-
// Emit summaries
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
content: [{ type: 'text', text: combinedText }],
|
|
663
|
-
sourceRelation: 'derived',
|
|
664
|
-
};
|
|
665
|
-
const pairTokens = this.estimateTokens(questionEntry.content) +
|
|
666
|
-
this.estimateTokens(answerEntry.content);
|
|
667
|
-
entries.push(questionEntry);
|
|
668
|
-
entries.push(answerEntry);
|
|
669
|
-
totalTokens += pairTokens;
|
|
1111
|
+
// Emit summaries + pinned messages between head and recent windows.
|
|
1112
|
+
//
|
|
1113
|
+
// Default (positionedRecallPairs=true): one Q/A pair per summary,
|
|
1114
|
+
// interleaved with raw pinned messages, all sorted chronologically by
|
|
1115
|
+
// source-range / message position. Each memory appears in its temporal
|
|
1116
|
+
// place rather than as a wall of unrelated recollections.
|
|
1117
|
+
//
|
|
1118
|
+
// Legacy (positionedRecallPairs=false): summaries concatenated into one
|
|
1119
|
+
// Q/A pair between head and tail; pinned messages still emit raw, in
|
|
1120
|
+
// their chronological positions, after the combined recall pair.
|
|
1121
|
+
const positionOf = new Map();
|
|
1122
|
+
for (let i = 0; i < messages.length; i++) {
|
|
1123
|
+
positionOf.set(messages[i].id, i);
|
|
670
1124
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
if (
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
1125
|
+
const pinnedPositionsSet = this.pinnedPositions(messages);
|
|
1126
|
+
// Pinned messages between head and recent (head/recent pinned ones
|
|
1127
|
+
// already emit raw via Phase 0 / Phase 4).
|
|
1128
|
+
const pinnedInMiddle = [];
|
|
1129
|
+
const pinnedIdsInMiddle = new Set();
|
|
1130
|
+
for (let i = headEnd; i < recentStart; i++) {
|
|
1131
|
+
if (pinnedPositionsSet.has(i)) {
|
|
1132
|
+
pinnedInMiddle.push({ msg: messages[i], position: i });
|
|
1133
|
+
pinnedIdsInMiddle.add(messages[i].id);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
// Uncompressed-chunk fallback: messages in the middle region whose
|
|
1137
|
+
// chunk hasn't been summarized yet. Without this, a message that
|
|
1138
|
+
// rolled out of the recent window into a queued-but-not-yet-compressed
|
|
1139
|
+
// chunk would vanish from rendered context — there'd be no summary to
|
|
1140
|
+
// emit (compression hasn't run) and Phase 4 only walks recentStart
|
|
1141
|
+
// onwards. Mirrors selectLegacy's "Uncompressed: emit raw" behavior
|
|
1142
|
+
// around line 738, but here we interleave chronologically with
|
|
1143
|
+
// summaries and pins via the unified items list below.
|
|
1144
|
+
//
|
|
1145
|
+
// This matters because compile() was made non-blocking in commit
|
|
1146
|
+
// `3e42e98` (drops the prior `await readiness.pendingWork`); without
|
|
1147
|
+
// this fallback, the trade was silent data-loss for messages caught
|
|
1148
|
+
// in the queued-but-not-yet-compressed window. Now compile()'s
|
|
1149
|
+
// freshness contract is: summaries may lag the very latest L1, but
|
|
1150
|
+
// no message ever disappears.
|
|
1151
|
+
const uncompressedInMiddle = [];
|
|
1152
|
+
for (const chunk of this.chunks) {
|
|
1153
|
+
if (chunk.compressed)
|
|
1154
|
+
continue;
|
|
1155
|
+
for (const msg of chunk.messages) {
|
|
1156
|
+
const pos = positionOf.get(msg.id);
|
|
1157
|
+
if (pos === undefined)
|
|
1158
|
+
continue;
|
|
1159
|
+
if (pos < headEnd || pos >= recentStart)
|
|
1160
|
+
continue;
|
|
1161
|
+
if (pinnedIdsInMiddle.has(msg.id))
|
|
1162
|
+
continue;
|
|
1163
|
+
uncompressedInMiddle.push({ msg, position: pos });
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
// Merged list of raw messages to emit in the middle region —
|
|
1167
|
+
// either a pin or a message whose chunk hasn't compressed yet.
|
|
1168
|
+
// Both render identically (raw, at their chronological position).
|
|
1169
|
+
const middleRaw = [
|
|
1170
|
+
...pinnedInMiddle,
|
|
1171
|
+
...uncompressedInMiddle,
|
|
1172
|
+
];
|
|
1173
|
+
if (selectedSummaries.length > 0 || middleRaw.length > 0) {
|
|
1174
|
+
const summaryParticipant = this.config.summaryParticipant ?? 'Claude';
|
|
1175
|
+
if (this.config.positionedRecallPairs !== false) {
|
|
1176
|
+
const items = [];
|
|
1177
|
+
for (const s of selectedSummaries) {
|
|
1178
|
+
const pos = positionOf.get(s.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
|
|
1179
|
+
items.push({ kind: 'summary', position: pos, summary: s });
|
|
1180
|
+
}
|
|
1181
|
+
for (const p of middleRaw) {
|
|
1182
|
+
items.push({ kind: 'pin', position: p.position, msg: p.msg });
|
|
1183
|
+
}
|
|
1184
|
+
items.sort((a, b) => a.position - b.position);
|
|
1185
|
+
for (const item of items) {
|
|
1186
|
+
if (item.kind === 'summary') {
|
|
1187
|
+
const summary = item.summary;
|
|
1188
|
+
const headerText = this.buildRecallHeader(summary);
|
|
1189
|
+
const questionEntry = {
|
|
1190
|
+
index: entries.length,
|
|
1191
|
+
participant: 'Context Manager',
|
|
1192
|
+
content: [{ type: 'text', text: headerText }],
|
|
1193
|
+
sourceRelation: 'derived',
|
|
1194
|
+
};
|
|
1195
|
+
const answerContent = [{ type: 'text', text: summary.content }];
|
|
1196
|
+
const answerEntry = {
|
|
1197
|
+
index: entries.length + 1,
|
|
1198
|
+
participant: summaryParticipant,
|
|
1199
|
+
content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
|
|
1200
|
+
sourceRelation: 'derived',
|
|
1201
|
+
};
|
|
1202
|
+
const pairTokens = this.estimateTokens(questionEntry.content) +
|
|
1203
|
+
this.estimateTokens(answerEntry.content);
|
|
1204
|
+
if (this.isOverBudget(totalTokens + pairTokens, maxTokens))
|
|
1205
|
+
break;
|
|
1206
|
+
entries.push(questionEntry);
|
|
1207
|
+
entries.push(answerEntry);
|
|
1208
|
+
totalTokens += pairTokens;
|
|
1209
|
+
}
|
|
1210
|
+
else {
|
|
1211
|
+
const msg = item.msg;
|
|
1212
|
+
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1213
|
+
const tokens = msgCap > 0
|
|
1214
|
+
? Math.min(store.estimateTokens(msg), msgCap + 50)
|
|
1215
|
+
: store.estimateTokens(msg);
|
|
1216
|
+
if (this.isOverBudget(totalTokens + tokens, maxTokens))
|
|
1217
|
+
break;
|
|
1218
|
+
entries.push({
|
|
1219
|
+
index: entries.length,
|
|
1220
|
+
sourceMessageId: msg.id,
|
|
1221
|
+
sourceRelation: 'copy',
|
|
1222
|
+
participant: msg.participant,
|
|
1223
|
+
content,
|
|
1224
|
+
});
|
|
1225
|
+
totalTokens += tokens;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
else {
|
|
1230
|
+
// Legacy combined-pair mode for summaries; pins still emit raw at
|
|
1231
|
+
// their positions after the combined pair.
|
|
1232
|
+
if (selectedSummaries.length > 0) {
|
|
1233
|
+
const contextLabel = this.config.summaryContextLabel ?? 'What do you remember from earlier?';
|
|
1234
|
+
const combinedText = selectedSummaries.map(s => s.content).join('\n\n---\n\n');
|
|
1235
|
+
const questionEntry = {
|
|
1236
|
+
index: entries.length,
|
|
1237
|
+
participant: 'Context Manager',
|
|
1238
|
+
content: [{ type: 'text', text: contextLabel }],
|
|
1239
|
+
sourceRelation: 'derived',
|
|
1240
|
+
};
|
|
1241
|
+
// Synthesised summary turns must respect maxMessageTokens. With L1+L2+L3
|
|
1242
|
+
// budgets defaulting to 30k each, an unconstrained concatenation can push
|
|
1243
|
+
// a single assistant turn past 90k tokens, eating the inference budget
|
|
1244
|
+
// and starving recent messages (postmortem 2026-05-04, bug B).
|
|
1245
|
+
const answerContent = [{ type: 'text', text: combinedText }];
|
|
1246
|
+
const answerEntry = {
|
|
1247
|
+
index: entries.length + 1,
|
|
1248
|
+
participant: summaryParticipant,
|
|
1249
|
+
content: msgCap > 0 ? this.truncateContent(answerContent, msgCap) : answerContent,
|
|
1250
|
+
sourceRelation: 'derived',
|
|
1251
|
+
};
|
|
1252
|
+
const pairTokens = this.estimateTokens(questionEntry.content) +
|
|
1253
|
+
this.estimateTokens(answerEntry.content);
|
|
1254
|
+
entries.push(questionEntry);
|
|
1255
|
+
entries.push(answerEntry);
|
|
1256
|
+
totalTokens += pairTokens;
|
|
1257
|
+
}
|
|
1258
|
+
// Sort by position so uncompressed-middle messages and pins both
|
|
1259
|
+
// appear in their chronological place after the combined recall pair.
|
|
1260
|
+
const middleRawSorted = [...middleRaw].sort((a, b) => a.position - b.position);
|
|
1261
|
+
for (const { msg } of middleRawSorted) {
|
|
1262
|
+
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
1263
|
+
const tokens = msgCap > 0
|
|
1264
|
+
? Math.min(store.estimateTokens(msg), msgCap + 50)
|
|
1265
|
+
: store.estimateTokens(msg);
|
|
1266
|
+
if (this.isOverBudget(totalTokens + tokens, maxTokens))
|
|
1267
|
+
break;
|
|
1268
|
+
entries.push({
|
|
1269
|
+
index: entries.length,
|
|
1270
|
+
sourceMessageId: msg.id,
|
|
1271
|
+
sourceRelation: 'copy',
|
|
1272
|
+
participant: msg.participant,
|
|
1273
|
+
content,
|
|
1274
|
+
});
|
|
1275
|
+
totalTokens += tokens;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
687
1278
|
}
|
|
1279
|
+
// Phase 4: Recent uncompressed messages (skip head window overlap).
|
|
1280
|
+
// Newest-first eviction so that when summaries/head consume most of the
|
|
1281
|
+
// budget, the latest messages (the ones the agent actually needs to act
|
|
1282
|
+
// on) are preserved and the oldest recent-window messages are dropped.
|
|
1283
|
+
const effectiveRecentStart = Math.max(recentStart, headEnd);
|
|
1284
|
+
this.emitRecentNewestFirst(entries, store, messages, effectiveRecentStart, msgCap, maxTokens, totalTokens);
|
|
688
1285
|
this.trimOrphanedToolUse(entries);
|
|
1286
|
+
this.pruneToolEntries(entries);
|
|
689
1287
|
return entries;
|
|
690
1288
|
}
|
|
691
1289
|
// ============================================================================
|
|
@@ -715,13 +1313,56 @@ export class AutobiographicalStrategy {
|
|
|
715
1313
|
for (const s of shownL1) {
|
|
716
1314
|
if (used + s.tokens > budget)
|
|
717
1315
|
break;
|
|
718
|
-
if (used + s.tokens
|
|
1316
|
+
if (this.isOverBudget(used + s.tokens, maxTokens))
|
|
719
1317
|
break;
|
|
720
1318
|
selected.push(s);
|
|
721
1319
|
used += s.tokens;
|
|
722
1320
|
}
|
|
723
1321
|
return { selected, tokensUsed: used };
|
|
724
1322
|
}
|
|
1323
|
+
/**
|
|
1324
|
+
* True if `projectedTotal` exceeds `max` AND the strategy is configured
|
|
1325
|
+
* to enforce budget. When `enforceBudget: false`, always returns false
|
|
1326
|
+
* — the rendering pipeline emits the full ideal context regardless of
|
|
1327
|
+
* budget overage. Caller's API will reject if it exceeds the model's
|
|
1328
|
+
* context window; the philosophy is "surface overage, don't hide it."
|
|
1329
|
+
*/
|
|
1330
|
+
isOverBudget(projectedTotal, max) {
|
|
1331
|
+
if (this.config.enforceBudget === false)
|
|
1332
|
+
return false;
|
|
1333
|
+
return projectedTotal > max;
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Sort selected summaries by source-range start position, so per-pair
|
|
1337
|
+
* recall emission appears in chronological order. Falls back to the
|
|
1338
|
+
* created timestamp for summaries whose source-range first message is
|
|
1339
|
+
* no longer in the store.
|
|
1340
|
+
*/
|
|
1341
|
+
sortSummariesChronologically(summaries, messages) {
|
|
1342
|
+
const positionOf = new Map();
|
|
1343
|
+
for (let i = 0; i < messages.length; i++) {
|
|
1344
|
+
positionOf.set(messages[i].id, i);
|
|
1345
|
+
}
|
|
1346
|
+
return [...summaries].sort((a, b) => {
|
|
1347
|
+
const posA = positionOf.get(a.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
|
|
1348
|
+
const posB = positionOf.get(b.sourceRange.first) ?? Number.MAX_SAFE_INTEGER;
|
|
1349
|
+
if (posA !== posB)
|
|
1350
|
+
return posA - posB;
|
|
1351
|
+
return a.created - b.created;
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Render the per-pair recall header from the configured template.
|
|
1356
|
+
* Substitutions: {id} {level} {first} {last}.
|
|
1357
|
+
*/
|
|
1358
|
+
buildRecallHeader(summary) {
|
|
1359
|
+
const template = this.config.recallHeaderTemplate ?? '[Recall {id}]';
|
|
1360
|
+
return template
|
|
1361
|
+
.replace(/\{id\}/g, summary.id)
|
|
1362
|
+
.replace(/\{level\}/g, String(summary.level))
|
|
1363
|
+
.replace(/\{first\}/g, summary.sourceRange.first)
|
|
1364
|
+
.replace(/\{last\}/g, summary.sourceRange.last);
|
|
1365
|
+
}
|
|
725
1366
|
// ============================================================================
|
|
726
1367
|
// Head window reset / topic transition
|
|
727
1368
|
// ============================================================================
|
|
@@ -781,7 +1422,6 @@ export class AutobiographicalStrategy {
|
|
|
781
1422
|
config: {
|
|
782
1423
|
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
783
1424
|
maxTokens: 1500,
|
|
784
|
-
temperature: 0,
|
|
785
1425
|
},
|
|
786
1426
|
};
|
|
787
1427
|
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
@@ -810,16 +1450,26 @@ export class AutobiographicalStrategy {
|
|
|
810
1450
|
// Shared utilities
|
|
811
1451
|
// ============================================================================
|
|
812
1452
|
/**
|
|
813
|
-
* Get messages in the compressible zone: outside both head window and
|
|
814
|
-
*
|
|
1453
|
+
* Get messages in the compressible zone: outside both head window and
|
|
1454
|
+
* recent window AND not inside any pinned range. Returns messages from
|
|
1455
|
+
* [0, headStart) ∪ [headEnd, recentStart) minus any positions covered
|
|
1456
|
+
* by a pin or document mark.
|
|
815
1457
|
*/
|
|
816
1458
|
getCompressibleMessages(store) {
|
|
817
1459
|
const messages = store.getAll();
|
|
818
1460
|
const headStart = this.getHeadWindowStartIndex(store);
|
|
819
1461
|
const headEnd = this.getHeadWindowEnd(store);
|
|
820
1462
|
const recentStart = this.getRecentWindowStart(store);
|
|
821
|
-
|
|
822
|
-
|
|
1463
|
+
const pinned = this.pinnedPositions(messages);
|
|
1464
|
+
const out = [];
|
|
1465
|
+
for (let i = 0; i < recentStart; i++) {
|
|
1466
|
+
if (i >= headStart && i < headEnd)
|
|
1467
|
+
continue;
|
|
1468
|
+
if (pinned.has(i))
|
|
1469
|
+
continue;
|
|
1470
|
+
out.push(messages[i]);
|
|
1471
|
+
}
|
|
1472
|
+
return out;
|
|
823
1473
|
}
|
|
824
1474
|
/**
|
|
825
1475
|
* Rebuild chunk boundaries based on current messages.
|
|
@@ -984,6 +1634,92 @@ export class AutobiographicalStrategy {
|
|
|
984
1634
|
}
|
|
985
1635
|
}
|
|
986
1636
|
}
|
|
1637
|
+
/**
|
|
1638
|
+
* Prune tool_use / tool_result blocks in-place:
|
|
1639
|
+
* 1. Truncate `tool_use.input` blocks whose serialized JSON exceeds
|
|
1640
|
+
* `toolUseInputMaxTokens`.
|
|
1641
|
+
* 2. For each tool name, keep only the last N `tool_result` blocks
|
|
1642
|
+
* per `toolResultMaxLastN`; older ones get their `content` replaced
|
|
1643
|
+
* with a brief marker referencing the tool name and how many newer
|
|
1644
|
+
* results exist below.
|
|
1645
|
+
*
|
|
1646
|
+
* Both passes are no-ops when the corresponding config is unset/0.
|
|
1647
|
+
* Pruning runs AFTER selection and orphan-trimming, so it doesn't
|
|
1648
|
+
* affect chunk formation or the recall/pin layout.
|
|
1649
|
+
*/
|
|
1650
|
+
pruneToolEntries(entries) {
|
|
1651
|
+
// Pass 1: build toolUseId → toolName map and apply input truncation
|
|
1652
|
+
const toolUseInputCap = this.config.toolUseInputMaxTokens ?? 0;
|
|
1653
|
+
const toolUseIdToName = new Map();
|
|
1654
|
+
for (const entry of entries) {
|
|
1655
|
+
for (let i = 0; i < entry.content.length; i++) {
|
|
1656
|
+
const block = entry.content[i];
|
|
1657
|
+
if (block.type !== 'tool_use')
|
|
1658
|
+
continue;
|
|
1659
|
+
toolUseIdToName.set(block.id, block.name);
|
|
1660
|
+
if (toolUseInputCap > 0) {
|
|
1661
|
+
const inputJson = JSON.stringify(block.input);
|
|
1662
|
+
const inputTokens = Math.ceil(inputJson.length / 4);
|
|
1663
|
+
if (inputTokens > toolUseInputCap) {
|
|
1664
|
+
const keys = Object.keys(block.input).slice(0, 5);
|
|
1665
|
+
entry.content[i] = {
|
|
1666
|
+
...block,
|
|
1667
|
+
input: {
|
|
1668
|
+
_truncated: true,
|
|
1669
|
+
_originalTokens: inputTokens,
|
|
1670
|
+
_keys: keys,
|
|
1671
|
+
},
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
// Pass 2: collect tool_result occurrences per tool name, in order
|
|
1678
|
+
const occurrencesByTool = new Map();
|
|
1679
|
+
for (const entry of entries) {
|
|
1680
|
+
for (let i = 0; i < entry.content.length; i++) {
|
|
1681
|
+
const block = entry.content[i];
|
|
1682
|
+
if (block.type !== 'tool_result')
|
|
1683
|
+
continue;
|
|
1684
|
+
const toolName = toolUseIdToName.get(block.toolUseId);
|
|
1685
|
+
if (!toolName)
|
|
1686
|
+
continue;
|
|
1687
|
+
let arr = occurrencesByTool.get(toolName);
|
|
1688
|
+
if (!arr) {
|
|
1689
|
+
arr = [];
|
|
1690
|
+
occurrencesByTool.set(toolName, arr);
|
|
1691
|
+
}
|
|
1692
|
+
arr.push({ entry, blockIndex: i });
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
// Pass 3: apply per-tool max-last-N
|
|
1696
|
+
const cfg = this.config.toolResultMaxLastN;
|
|
1697
|
+
if (cfg === undefined)
|
|
1698
|
+
return;
|
|
1699
|
+
for (const [toolName, occs] of occurrencesByTool) {
|
|
1700
|
+
let limit;
|
|
1701
|
+
if (typeof cfg === 'number')
|
|
1702
|
+
limit = cfg;
|
|
1703
|
+
else if (typeof cfg === 'object')
|
|
1704
|
+
limit = cfg[toolName];
|
|
1705
|
+
if (limit === undefined || limit < 0)
|
|
1706
|
+
continue;
|
|
1707
|
+
const excessCount = occs.length - limit;
|
|
1708
|
+
if (excessCount <= 0)
|
|
1709
|
+
continue;
|
|
1710
|
+
for (let i = 0; i < excessCount; i++) {
|
|
1711
|
+
const { entry, blockIndex } = occs[i];
|
|
1712
|
+
const orig = entry.content[blockIndex];
|
|
1713
|
+
if (orig.type !== 'tool_result')
|
|
1714
|
+
continue;
|
|
1715
|
+
const fresherCount = occs.length - i - 1;
|
|
1716
|
+
entry.content[blockIndex] = {
|
|
1717
|
+
...orig,
|
|
1718
|
+
content: `[Result truncated — tool '${toolName}' has ${fresherCount} more recent result${fresherCount === 1 ? '' : 's'} below]`,
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
987
1723
|
isChunkOldEnough(chunk) {
|
|
988
1724
|
return true;
|
|
989
1725
|
}
|