@animalabs/context-manager 0.1.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/blob-manager.d.ts +37 -0
- package/dist/src/blob-manager.d.ts.map +1 -0
- package/dist/src/blob-manager.js +128 -0
- package/dist/src/blob-manager.js.map +1 -0
- package/dist/src/context-log.d.ts +99 -0
- package/dist/src/context-log.d.ts.map +1 -0
- package/dist/src/context-log.js +277 -0
- package/dist/src/context-log.js.map +1 -0
- package/dist/src/context-manager.d.ts +245 -0
- package/dist/src/context-manager.d.ts.map +1 -0
- package/dist/src/context-manager.js +553 -0
- package/dist/src/context-manager.js.map +1 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +12 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/message-store.d.ts +135 -0
- package/dist/src/message-store.d.ts.map +1 -0
- package/dist/src/message-store.js +372 -0
- package/dist/src/message-store.js.map +1 -0
- package/dist/src/strategies/autobiographical.d.ts +199 -0
- package/dist/src/strategies/autobiographical.d.ts.map +1 -0
- package/dist/src/strategies/autobiographical.js +1122 -0
- package/dist/src/strategies/autobiographical.js.map +1 -0
- package/dist/src/strategies/index.d.ts +3 -0
- package/dist/src/strategies/index.d.ts.map +1 -0
- package/dist/src/strategies/index.js +3 -0
- package/dist/src/strategies/index.js.map +1 -0
- package/dist/src/strategies/knowledge.d.ts +46 -0
- package/dist/src/strategies/knowledge.d.ts.map +1 -0
- package/dist/src/strategies/knowledge.js +270 -0
- package/dist/src/strategies/knowledge.js.map +1 -0
- package/dist/src/strategies/passthrough.d.ts +17 -0
- package/dist/src/strategies/passthrough.d.ts.map +1 -0
- package/dist/src/strategies/passthrough.js +69 -0
- package/dist/src/strategies/passthrough.js.map +1 -0
- package/dist/src/types/context.d.ts +108 -0
- package/dist/src/types/context.d.ts.map +1 -0
- package/dist/src/types/context.js +2 -0
- package/dist/src/types/context.js.map +1 -0
- package/dist/src/types/index.d.ts +5 -0
- package/dist/src/types/index.d.ts.map +1 -0
- package/dist/src/types/index.js +2 -0
- package/dist/src/types/index.js.map +1 -0
- package/dist/src/types/message.d.ts +129 -0
- package/dist/src/types/message.d.ts.map +1 -0
- package/dist/src/types/message.js +2 -0
- package/dist/src/types/message.js.map +1 -0
- package/dist/src/types/strategy.d.ts +233 -0
- package/dist/src/types/strategy.d.ts.map +1 -0
- package/dist/src/types/strategy.js +32 -0
- package/dist/src/types/strategy.js.map +1 -0
- package/dist/test/autobiographical.test.d.ts +2 -0
- package/dist/test/autobiographical.test.d.ts.map +1 -0
- package/dist/test/autobiographical.test.js +46 -0
- package/dist/test/autobiographical.test.js.map +1 -0
- package/dist/test/head-window-reset.test.d.ts +17 -0
- package/dist/test/head-window-reset.test.d.ts.map +1 -0
- package/dist/test/head-window-reset.test.js +342 -0
- package/dist/test/head-window-reset.test.js.map +1 -0
- package/dist/test/integration.test.d.ts +2 -0
- package/dist/test/integration.test.d.ts.map +1 -0
- package/dist/test/integration.test.js +1341 -0
- package/dist/test/integration.test.js.map +1 -0
- package/dist/test/knowledge.test.d.ts +2 -0
- package/dist/test/knowledge.test.d.ts.map +1 -0
- package/dist/test/knowledge.test.js +617 -0
- package/dist/test/knowledge.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +48 -0
- package/src/blob-manager.ts +155 -0
- package/src/context-log.ts +342 -0
- package/src/context-manager.ts +726 -0
- package/src/index.ts +50 -0
- package/src/message-store.ts +479 -0
- package/src/strategies/autobiographical.ts +1355 -0
- package/src/strategies/index.ts +2 -0
- package/src/strategies/knowledge.ts +336 -0
- package/src/strategies/passthrough.ts +98 -0
- package/src/types/context.ts +119 -0
- package/src/types/index.ts +42 -0
- package/src/types/message.ts +140 -0
- package/src/types/strategy.ts +282 -0
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
import { NativeFormatter } from '@animalabs/membrane';
|
|
2
|
+
import { DEFAULT_AUTOBIOGRAPHICAL_CONFIG } from '../types/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Surrogate-safe string slice. Avoids cutting between a UTF-16 surrogate pair
|
|
5
|
+
* which would produce invalid JSON ("no low surrogate in string" API errors).
|
|
6
|
+
*/
|
|
7
|
+
function safeSlice(str, start, end) {
|
|
8
|
+
if (end >= str.length)
|
|
9
|
+
return str.slice(start);
|
|
10
|
+
const code = str.charCodeAt(end);
|
|
11
|
+
if (code >= 0xDC00 && code <= 0xDFFF) {
|
|
12
|
+
return str.slice(start, end - 1);
|
|
13
|
+
}
|
|
14
|
+
return str.slice(start, end);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Autobiographical chunking strategy.
|
|
18
|
+
* Compresses old conversation chunks into summaries in the model's own words.
|
|
19
|
+
* Recent context stays untouched.
|
|
20
|
+
*
|
|
21
|
+
* When `hierarchical` is enabled, uses a 3-level compression pyramid:
|
|
22
|
+
* L1 (raw→summary) → L2 (merge N L1s) → L3 (merge N L2s)
|
|
23
|
+
* with anti-redundancy filtering and budget carryover.
|
|
24
|
+
*/
|
|
25
|
+
export class AutobiographicalStrategy {
|
|
26
|
+
name = 'autobiographical';
|
|
27
|
+
get maxMessageTokens() { return this.config.maxMessageTokens; }
|
|
28
|
+
config;
|
|
29
|
+
chunks = [];
|
|
30
|
+
pendingCompression = null;
|
|
31
|
+
compressionQueue = [];
|
|
32
|
+
_compressionCount = 0;
|
|
33
|
+
// Hierarchical state
|
|
34
|
+
summaries = [];
|
|
35
|
+
summaryIdCounter = 0;
|
|
36
|
+
mergeQueue = [];
|
|
37
|
+
nativeFormatter = new NativeFormatter();
|
|
38
|
+
/** Message ID from which the head window starts. null = start from message 0. */
|
|
39
|
+
headWindowStartId = null;
|
|
40
|
+
/** Cached result of getHeadWindowStartIndex to avoid repeated linear scans. */
|
|
41
|
+
_cachedHeadStartIndex = null;
|
|
42
|
+
constructor(config = {}) {
|
|
43
|
+
this.config = { ...DEFAULT_AUTOBIOGRAPHICAL_CONFIG, ...config };
|
|
44
|
+
// Hierarchical is on by default; set hierarchical: false to use legacy single-level
|
|
45
|
+
this.config.hierarchical ??= true;
|
|
46
|
+
if (this.config.hierarchical) {
|
|
47
|
+
this.config.mergeThreshold ??= 6;
|
|
48
|
+
this.config.summaryTargetTokens ??= 2000;
|
|
49
|
+
this.config.l3BudgetTokens ??= 30000;
|
|
50
|
+
this.config.l2BudgetTokens ??= 30000;
|
|
51
|
+
this.config.l1BudgetTokens ??= 30000;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async initialize(ctx) {
|
|
55
|
+
// Restore headWindowStartId from last topic transition message
|
|
56
|
+
const messages = ctx.messageStore.getAll();
|
|
57
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
58
|
+
if (this.isTopicTransitionMessage(messages[i])) {
|
|
59
|
+
this.headWindowStartId = messages[i].id;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
this.rebuildChunks(ctx.messageStore);
|
|
64
|
+
}
|
|
65
|
+
checkReadiness() {
|
|
66
|
+
if (this.pendingCompression) {
|
|
67
|
+
return {
|
|
68
|
+
ready: false,
|
|
69
|
+
pendingWork: this.pendingCompression,
|
|
70
|
+
description: `Compressing chunk ${this.compressionQueue[0] ?? '?'}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const needsCompression = this.chunks.some((c) => !c.compressed && this.isChunkOldEnough(c));
|
|
74
|
+
const needsMerge = this.config.hierarchical && this.mergeQueue.length > 0;
|
|
75
|
+
if ((needsCompression && this.compressionQueue.length > 0) || needsMerge) {
|
|
76
|
+
const parts = [];
|
|
77
|
+
if (this.compressionQueue.length > 0)
|
|
78
|
+
parts.push(`${this.compressionQueue.length} chunks`);
|
|
79
|
+
if (needsMerge)
|
|
80
|
+
parts.push(`${this.mergeQueue.length} merges`);
|
|
81
|
+
return {
|
|
82
|
+
ready: false,
|
|
83
|
+
description: `${parts.join(' + ')} pending`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return { ready: true };
|
|
87
|
+
}
|
|
88
|
+
async onNewMessage(message, ctx) {
|
|
89
|
+
this.rebuildChunks(ctx.messageStore);
|
|
90
|
+
// Auto-tick: fire compression in the background so it runs without
|
|
91
|
+
// the framework explicitly calling tick(). compile() will await
|
|
92
|
+
// pendingCompression via checkReadiness().
|
|
93
|
+
if (this.config.autoTickOnNewMessage && this.compressionQueue.length > 0 && !this.pendingCompression) {
|
|
94
|
+
this.tick(ctx).catch((err) => console.error('AutobiographicalStrategy: auto-tick error:', err));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async tick(ctx) {
|
|
98
|
+
if (this.pendingCompression)
|
|
99
|
+
return;
|
|
100
|
+
if (!ctx.membrane) {
|
|
101
|
+
console.warn('AutobiographicalStrategy: No membrane instance for compression');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Priority 1: Compress raw chunks → L1
|
|
105
|
+
if (this.compressionQueue.length > 0) {
|
|
106
|
+
const chunkIndex = this.compressionQueue.shift();
|
|
107
|
+
const chunk = this.chunks[chunkIndex];
|
|
108
|
+
if (!chunk || chunk.compressed)
|
|
109
|
+
return;
|
|
110
|
+
this.pendingCompression = this.config.hierarchical
|
|
111
|
+
? this.compressChunkHierarchical(chunk, ctx)
|
|
112
|
+
: this.compressChunkLegacy(chunk, ctx);
|
|
113
|
+
try {
|
|
114
|
+
await this.pendingCompression;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
this.pendingCompression = null;
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// Priority 2: Execute pending merges (hierarchical only)
|
|
122
|
+
if (this.config.hierarchical && this.mergeQueue.length > 0) {
|
|
123
|
+
const merge = this.mergeQueue.shift();
|
|
124
|
+
this.pendingCompression = this.executeMerge(merge.level, merge.sourceIds, ctx);
|
|
125
|
+
try {
|
|
126
|
+
await this.pendingCompression;
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
this.pendingCompression = null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
select(store, log, budget) {
|
|
134
|
+
this.rebuildChunks(store);
|
|
135
|
+
return this.config.hierarchical
|
|
136
|
+
? this.selectHierarchical(store, budget)
|
|
137
|
+
: this.selectLegacy(store, log, budget);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Get summary statistics for observability.
|
|
141
|
+
*/
|
|
142
|
+
getStats() {
|
|
143
|
+
return {
|
|
144
|
+
chunksTotal: this.chunks.length,
|
|
145
|
+
chunksCompressed: this.chunks.filter(c => c.compressed).length,
|
|
146
|
+
compressionCount: this._compressionCount,
|
|
147
|
+
l1: this.summaries.filter(s => s.level === 1 && !s.mergedInto).length,
|
|
148
|
+
l2: this.summaries.filter(s => s.level === 2 && !s.mergedInto).length,
|
|
149
|
+
l3: this.summaries.filter(s => s.level === 3 && !s.mergedInto).length,
|
|
150
|
+
pendingMerges: this.mergeQueue.length,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// ============================================================================
|
|
154
|
+
// Legacy (single-level) path
|
|
155
|
+
// ============================================================================
|
|
156
|
+
selectLegacy(store, _log, budget) {
|
|
157
|
+
const entries = [];
|
|
158
|
+
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
|
159
|
+
let totalTokens = 0;
|
|
160
|
+
const messages = store.getAll();
|
|
161
|
+
const msgCap = this.config.maxMessageTokens;
|
|
162
|
+
// 1. Head window: preserved verbatim as raw copies
|
|
163
|
+
const headStart = this.getHeadWindowStartIndex(store);
|
|
164
|
+
const headEnd = this.getHeadWindowEnd(store);
|
|
165
|
+
for (let i = headStart; i < headEnd && i < messages.length; i++) {
|
|
166
|
+
const msg = messages[i];
|
|
167
|
+
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
168
|
+
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
169
|
+
if (totalTokens + tokens > maxTokens)
|
|
170
|
+
break;
|
|
171
|
+
entries.push({
|
|
172
|
+
index: entries.length,
|
|
173
|
+
sourceMessageId: msg.id,
|
|
174
|
+
sourceRelation: 'copy',
|
|
175
|
+
participant: msg.participant,
|
|
176
|
+
content,
|
|
177
|
+
});
|
|
178
|
+
totalTokens += tokens;
|
|
179
|
+
}
|
|
180
|
+
// 2. Middle zone: compressed chunks as diary pairs, uncompressed as raw messages.
|
|
181
|
+
const rawRecentStart = this.getRecentWindowStart(store);
|
|
182
|
+
// Track which message IDs are covered by chunks
|
|
183
|
+
const coveredByChunks = new Set();
|
|
184
|
+
for (const chunk of this.chunks) {
|
|
185
|
+
for (const m of chunk.messages)
|
|
186
|
+
coveredByChunks.add(m.id);
|
|
187
|
+
if (chunk.compressed && chunk.diary) {
|
|
188
|
+
const contextLabel = this.config.summaryContextLabel ?? 'Here is a summary of earlier conversation context:';
|
|
189
|
+
const summaryParticipant = this.config.summaryParticipant ?? 'Summary';
|
|
190
|
+
const questionEntry = {
|
|
191
|
+
index: entries.length,
|
|
192
|
+
participant: 'Context Manager',
|
|
193
|
+
content: [{ type: 'text', text: contextLabel }],
|
|
194
|
+
sourceRelation: 'derived',
|
|
195
|
+
};
|
|
196
|
+
const answerEntry = {
|
|
197
|
+
index: entries.length + 1,
|
|
198
|
+
participant: summaryParticipant,
|
|
199
|
+
content: [{ type: 'text', text: chunk.diary }],
|
|
200
|
+
sourceRelation: 'derived',
|
|
201
|
+
};
|
|
202
|
+
const pairTokens = this.estimateTokens(questionEntry.content) +
|
|
203
|
+
this.estimateTokens(answerEntry.content);
|
|
204
|
+
if (totalTokens + pairTokens > maxTokens)
|
|
205
|
+
break;
|
|
206
|
+
entries.push(questionEntry);
|
|
207
|
+
entries.push(answerEntry);
|
|
208
|
+
totalTokens += pairTokens;
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
// Uncompressed: emit raw messages so they aren't lost
|
|
212
|
+
for (const msg of chunk.messages) {
|
|
213
|
+
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
214
|
+
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
215
|
+
if (totalTokens + tokens > maxTokens)
|
|
216
|
+
break;
|
|
217
|
+
entries.push({
|
|
218
|
+
index: entries.length,
|
|
219
|
+
sourceMessageId: msg.id,
|
|
220
|
+
sourceRelation: 'copy',
|
|
221
|
+
participant: msg.participant,
|
|
222
|
+
content,
|
|
223
|
+
});
|
|
224
|
+
totalTokens += tokens;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Emit gap messages in the compressible zone not covered by any chunk.
|
|
229
|
+
// Compressible zone: [0, headStart) ∪ [headEnd, rawRecentStart)
|
|
230
|
+
for (let i = 0; i < rawRecentStart && i < messages.length; i++) {
|
|
231
|
+
// Skip head window messages (already emitted verbatim above)
|
|
232
|
+
if (i >= headStart && i < headEnd)
|
|
233
|
+
continue;
|
|
234
|
+
if (coveredByChunks.has(messages[i].id))
|
|
235
|
+
continue;
|
|
236
|
+
const msg = messages[i];
|
|
237
|
+
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
238
|
+
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
239
|
+
if (totalTokens + tokens > maxTokens)
|
|
240
|
+
break;
|
|
241
|
+
entries.push({
|
|
242
|
+
index: entries.length,
|
|
243
|
+
sourceMessageId: msg.id,
|
|
244
|
+
sourceRelation: 'copy',
|
|
245
|
+
participant: msg.participant,
|
|
246
|
+
content,
|
|
247
|
+
});
|
|
248
|
+
totalTokens += tokens;
|
|
249
|
+
}
|
|
250
|
+
// 3. Recent uncompressed messages (skip those already in head window)
|
|
251
|
+
const recentStart = Math.max(this.getRecentWindowStart(store), headEnd);
|
|
252
|
+
for (let i = recentStart; i < messages.length; i++) {
|
|
253
|
+
const msg = messages[i];
|
|
254
|
+
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
255
|
+
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
256
|
+
if (totalTokens + tokens > maxTokens)
|
|
257
|
+
break;
|
|
258
|
+
entries.push({
|
|
259
|
+
index: entries.length,
|
|
260
|
+
sourceMessageId: msg.id,
|
|
261
|
+
sourceRelation: 'copy',
|
|
262
|
+
participant: msg.participant,
|
|
263
|
+
content,
|
|
264
|
+
});
|
|
265
|
+
totalTokens += tokens;
|
|
266
|
+
}
|
|
267
|
+
this.trimOrphanedToolUse(entries);
|
|
268
|
+
return entries;
|
|
269
|
+
}
|
|
270
|
+
async compressChunkLegacy(chunk, ctx) {
|
|
271
|
+
if (!ctx.membrane) {
|
|
272
|
+
throw new Error('No membrane instance for compression');
|
|
273
|
+
}
|
|
274
|
+
const priorContext = this.buildPriorContextLegacy(chunk, ctx);
|
|
275
|
+
const chunkContent = this.formatChunkForCompression(chunk);
|
|
276
|
+
const prompt = this.config.diaryUserPrompt ?? this.config.summaryUserPrompt;
|
|
277
|
+
const systemPrompt = this.config.diarySystemPrompt ?? this.config.summarySystemPrompt;
|
|
278
|
+
const messages = [
|
|
279
|
+
...priorContext,
|
|
280
|
+
{
|
|
281
|
+
participant: 'Context Manager',
|
|
282
|
+
content: [{ type: 'text', text: prompt.replace('{content}', chunkContent) }],
|
|
283
|
+
},
|
|
284
|
+
];
|
|
285
|
+
const request = {
|
|
286
|
+
messages: messages.map((m) => ({
|
|
287
|
+
participant: m.participant,
|
|
288
|
+
content: m.content,
|
|
289
|
+
})),
|
|
290
|
+
system: systemPrompt,
|
|
291
|
+
config: {
|
|
292
|
+
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
293
|
+
maxTokens: 2000,
|
|
294
|
+
temperature: 0,
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
try {
|
|
298
|
+
const response = await ctx.membrane.complete(request);
|
|
299
|
+
const diaryText = response.content
|
|
300
|
+
.filter((b) => b.type === 'text')
|
|
301
|
+
.map((b) => b.text)
|
|
302
|
+
.join('\n');
|
|
303
|
+
chunk.compressed = true;
|
|
304
|
+
chunk.diary = diaryText;
|
|
305
|
+
this._compressionCount++;
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
console.error('Failed to compress chunk:', error);
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
buildPriorContextLegacy(chunk, ctx) {
|
|
313
|
+
const context = [];
|
|
314
|
+
for (const prevChunk of this.chunks) {
|
|
315
|
+
if (prevChunk.index >= chunk.index)
|
|
316
|
+
break;
|
|
317
|
+
if (!prevChunk.compressed || !prevChunk.diary)
|
|
318
|
+
continue;
|
|
319
|
+
context.push({
|
|
320
|
+
participant: 'Context Manager',
|
|
321
|
+
content: [{ type: 'text', text: this.config.summaryContextLabel ?? 'Summary of earlier context:' }],
|
|
322
|
+
});
|
|
323
|
+
context.push({
|
|
324
|
+
participant: this.config.summaryParticipant ?? 'Summary',
|
|
325
|
+
content: [{ type: 'text', text: prevChunk.diary }],
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
// Find the actual position of this chunk's first message in the full array
|
|
329
|
+
const messages = ctx.messageStore.getAll();
|
|
330
|
+
const firstMsgId = chunk.messages[0]?.id;
|
|
331
|
+
const chunkAbsStart = firstMsgId
|
|
332
|
+
? messages.findIndex(m => m.id === firstMsgId)
|
|
333
|
+
: -1;
|
|
334
|
+
if (chunkAbsStart > 0) {
|
|
335
|
+
const precedingStart = Math.max(0, chunkAbsStart - 50);
|
|
336
|
+
let tokens = 0;
|
|
337
|
+
for (let i = chunkAbsStart - 1; i >= precedingStart && tokens < 15000; i--) {
|
|
338
|
+
const msg = messages[i];
|
|
339
|
+
if (!msg)
|
|
340
|
+
break;
|
|
341
|
+
tokens += ctx.messageStore.estimateTokens(msg);
|
|
342
|
+
context.unshift({
|
|
343
|
+
participant: msg.participant,
|
|
344
|
+
content: msg.content,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return context;
|
|
349
|
+
}
|
|
350
|
+
// ============================================================================
|
|
351
|
+
// Hierarchical (L1/L2/L3) path
|
|
352
|
+
// ============================================================================
|
|
353
|
+
/**
|
|
354
|
+
* Anti-redundancy filter: get summaries to show, excluding those whose
|
|
355
|
+
* children are all already visible at a lower level.
|
|
356
|
+
*
|
|
357
|
+
* Matches moltbot's gradient exclusion algorithm (worker.ts:293-447).
|
|
358
|
+
*/
|
|
359
|
+
getAntiRedundantSummaries(excludeMessageIds) {
|
|
360
|
+
// Step 1: All unmerged L1s, excluding those whose sourceIds overlap with exclusion set
|
|
361
|
+
let candidateL1 = this.summaries.filter(s => s.level === 1 && !s.mergedInto);
|
|
362
|
+
if (excludeMessageIds && excludeMessageIds.size > 0) {
|
|
363
|
+
candidateL1 = candidateL1.filter(s => !s.sourceIds.some(id => excludeMessageIds.has(id)));
|
|
364
|
+
}
|
|
365
|
+
const shownL1 = candidateL1;
|
|
366
|
+
const shownL1Ids = new Set(shownL1.map(s => s.id));
|
|
367
|
+
// Step 2: Unmerged L2s, excluding those whose ALL source L1s are shown
|
|
368
|
+
const candidateL2 = this.summaries.filter(s => s.level === 2 && !s.mergedInto);
|
|
369
|
+
const shownL2 = candidateL2.filter(s => !s.sourceIds.every(l1Id => shownL1Ids.has(l1Id)));
|
|
370
|
+
const shownL2Ids = new Set(shownL2.map(s => s.id));
|
|
371
|
+
// Step 3: Unmerged L3s, excluding those whose ALL source L2s are shown
|
|
372
|
+
const candidateL3 = this.summaries.filter(s => s.level === 3 && !s.mergedInto);
|
|
373
|
+
const shownL3 = candidateL3.filter(s => !s.sourceIds.every(l2Id => shownL2Ids.has(l2Id)));
|
|
374
|
+
return { shownL1, shownL2, shownL3 };
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Compress a raw message chunk into an L1 summary using self-voice framing.
|
|
378
|
+
* No system prompt — framing via message structure only.
|
|
379
|
+
*/
|
|
380
|
+
async compressChunkHierarchical(chunk, ctx) {
|
|
381
|
+
if (!ctx.membrane) {
|
|
382
|
+
throw new Error('No membrane instance for compression');
|
|
383
|
+
}
|
|
384
|
+
const chunkMessageIds = new Set(chunk.messages.map(m => m.id));
|
|
385
|
+
const { shownL3, shownL2, shownL1 } = this.getAntiRedundantSummaries(chunkMessageIds);
|
|
386
|
+
const targetTokens = this.config.summaryTargetTokens ?? 2000;
|
|
387
|
+
const chunkContent = this.formatChunkForCompression(chunk);
|
|
388
|
+
// Build message array: prior summaries as assistant, then instruction
|
|
389
|
+
const llmMessages = [];
|
|
390
|
+
// Prior summaries as agent's own recollections (L3 → L2 → L1 gradient)
|
|
391
|
+
const allPriorSummaries = [...shownL3, ...shownL2, ...shownL1];
|
|
392
|
+
for (const s of allPriorSummaries) {
|
|
393
|
+
llmMessages.push({
|
|
394
|
+
participant: this.config.summaryParticipant ?? 'Claude',
|
|
395
|
+
content: [{ type: 'text', text: s.content }],
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
// Context Manager instruction with chunk content
|
|
399
|
+
const instruction = this.getCompressionInstruction(chunk, targetTokens);
|
|
400
|
+
llmMessages.push({
|
|
401
|
+
participant: 'Context Manager',
|
|
402
|
+
content: [{
|
|
403
|
+
type: 'text',
|
|
404
|
+
text: `[Context Manager] We are ready to form a long-term memory. Here is the conversation to remember:\n\n${chunkContent}\n\n${instruction}`,
|
|
405
|
+
}],
|
|
406
|
+
});
|
|
407
|
+
// Collapse consecutive same-participant messages for API compliance
|
|
408
|
+
const collapsed = this.collapseConsecutiveMessages(llmMessages);
|
|
409
|
+
const request = {
|
|
410
|
+
messages: collapsed.map(m => ({ participant: m.participant, content: m.content })),
|
|
411
|
+
system: 'You are forming autobiographical memories of a conversation.',
|
|
412
|
+
config: {
|
|
413
|
+
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
414
|
+
maxTokens: Math.round(targetTokens * 1.5),
|
|
415
|
+
temperature: 0,
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
try {
|
|
419
|
+
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
420
|
+
const summaryText = response.content
|
|
421
|
+
.filter((b) => b.type === 'text')
|
|
422
|
+
.map(b => b.text)
|
|
423
|
+
.join('\n');
|
|
424
|
+
const messageIds = chunk.messages.map(m => m.id);
|
|
425
|
+
const entry = {
|
|
426
|
+
id: `L1-${this.summaryIdCounter++}`,
|
|
427
|
+
level: 1,
|
|
428
|
+
content: summaryText,
|
|
429
|
+
tokens: Math.ceil(summaryText.length / 4),
|
|
430
|
+
sourceLevel: 0,
|
|
431
|
+
sourceIds: messageIds,
|
|
432
|
+
sourceRange: {
|
|
433
|
+
first: messageIds[0],
|
|
434
|
+
last: messageIds[messageIds.length - 1],
|
|
435
|
+
},
|
|
436
|
+
created: Date.now(),
|
|
437
|
+
phaseType: chunk.phaseType,
|
|
438
|
+
};
|
|
439
|
+
this.summaries.push(entry);
|
|
440
|
+
chunk.compressed = true;
|
|
441
|
+
chunk.summaryId = entry.id;
|
|
442
|
+
this._compressionCount++;
|
|
443
|
+
this.checkMergeThreshold();
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
console.error('Failed to compress chunk (hierarchical):', error);
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Check if unmerged summary counts exceed the merge threshold.
|
|
452
|
+
* Enqueues merge operations if so.
|
|
453
|
+
*/
|
|
454
|
+
checkMergeThreshold() {
|
|
455
|
+
const threshold = this.config.mergeThreshold ?? 6;
|
|
456
|
+
// Check L1 → L2
|
|
457
|
+
const unmergedL1 = this.summaries.filter(s => s.level === 1 && !s.mergedInto);
|
|
458
|
+
if (unmergedL1.length >= threshold) {
|
|
459
|
+
const toMerge = unmergedL1.slice(0, threshold);
|
|
460
|
+
this.mergeQueue.push({
|
|
461
|
+
level: 2,
|
|
462
|
+
sourceIds: toMerge.map(s => s.id),
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
// Check L2 → L3
|
|
466
|
+
const unmergedL2 = this.summaries.filter(s => s.level === 2 && !s.mergedInto);
|
|
467
|
+
if (unmergedL2.length >= threshold) {
|
|
468
|
+
const toMerge = unmergedL2.slice(0, threshold);
|
|
469
|
+
this.mergeQueue.push({
|
|
470
|
+
level: 3,
|
|
471
|
+
sourceIds: toMerge.map(s => s.id),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Merge N summaries at one level into a single summary at the next level.
|
|
477
|
+
* Uses self-voice consolidation prompt.
|
|
478
|
+
*/
|
|
479
|
+
async executeMerge(targetLevel, sourceIds, ctx) {
|
|
480
|
+
if (!ctx.membrane) {
|
|
481
|
+
throw new Error('No membrane instance for merge');
|
|
482
|
+
}
|
|
483
|
+
const sources = sourceIds
|
|
484
|
+
.map(id => this.summaries.find(s => s.id === id))
|
|
485
|
+
.filter((s) => s != null);
|
|
486
|
+
if (sources.length !== sourceIds.length) {
|
|
487
|
+
console.warn('executeMerge: some source summaries not found, skipping');
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const targetTokens = this.config.summaryTargetTokens ?? 2000;
|
|
491
|
+
const participant = this.config.summaryParticipant ?? 'Claude';
|
|
492
|
+
// Build message array
|
|
493
|
+
const llmMessages = [];
|
|
494
|
+
// Higher-level context (anti-redundant)
|
|
495
|
+
if (targetLevel === 2) {
|
|
496
|
+
// For L2 merge: show L3 summaries as context
|
|
497
|
+
const shownL3 = this.summaries.filter(s => s.level === 3 && !s.mergedInto);
|
|
498
|
+
for (const s of shownL3) {
|
|
499
|
+
llmMessages.push({
|
|
500
|
+
participant,
|
|
501
|
+
content: [{ type: 'text', text: s.content }],
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// For L3 merge: no higher context exists
|
|
506
|
+
// The source summaries as agent's own memories
|
|
507
|
+
for (const source of sources) {
|
|
508
|
+
llmMessages.push({
|
|
509
|
+
participant,
|
|
510
|
+
content: [{ type: 'text', text: source.content }],
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
// Consolidation instruction
|
|
514
|
+
const mergeInstruction = this.getMergeInstruction(targetLevel, sources, targetTokens);
|
|
515
|
+
llmMessages.push({
|
|
516
|
+
participant: 'Context Manager',
|
|
517
|
+
content: [{
|
|
518
|
+
type: 'text',
|
|
519
|
+
text: `[Context Manager] ${mergeInstruction}`,
|
|
520
|
+
}],
|
|
521
|
+
});
|
|
522
|
+
const collapsed = this.collapseConsecutiveMessages(llmMessages);
|
|
523
|
+
const request = {
|
|
524
|
+
messages: collapsed.map(m => ({ participant: m.participant, content: m.content })),
|
|
525
|
+
system: 'You are forming autobiographical memories of a conversation.',
|
|
526
|
+
config: {
|
|
527
|
+
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
528
|
+
maxTokens: Math.round(targetTokens * 1.5),
|
|
529
|
+
temperature: 0,
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
try {
|
|
533
|
+
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
534
|
+
const mergedText = response.content
|
|
535
|
+
.filter((b) => b.type === 'text')
|
|
536
|
+
.map(b => b.text)
|
|
537
|
+
.join('\n');
|
|
538
|
+
// Compute source range from constituent summaries
|
|
539
|
+
const sourceRange = {
|
|
540
|
+
first: sources[0].sourceRange.first,
|
|
541
|
+
last: sources[sources.length - 1].sourceRange.last,
|
|
542
|
+
};
|
|
543
|
+
const sourceLevel = (targetLevel - 1);
|
|
544
|
+
const newEntry = {
|
|
545
|
+
id: `L${targetLevel}-${this.summaryIdCounter++}`,
|
|
546
|
+
level: targetLevel,
|
|
547
|
+
content: mergedText,
|
|
548
|
+
tokens: Math.ceil(mergedText.length / 4),
|
|
549
|
+
sourceLevel,
|
|
550
|
+
sourceIds,
|
|
551
|
+
sourceRange,
|
|
552
|
+
created: Date.now(),
|
|
553
|
+
};
|
|
554
|
+
this.summaries.push(newEntry);
|
|
555
|
+
// Mark sources as merged
|
|
556
|
+
for (const source of sources) {
|
|
557
|
+
source.mergedInto = newEntry.id;
|
|
558
|
+
}
|
|
559
|
+
// Check if this merge triggers a further merge
|
|
560
|
+
this.checkMergeThreshold();
|
|
561
|
+
}
|
|
562
|
+
catch (error) {
|
|
563
|
+
console.error(`Failed to merge summaries into L${targetLevel}:`, error);
|
|
564
|
+
throw error;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Select context entries using hierarchical compression with budget carryover.
|
|
569
|
+
* Matches moltbot's budget waterfall: L3 → L2 → L1 with unused budget flowing down.
|
|
570
|
+
*/
|
|
571
|
+
selectHierarchical(store, budget) {
|
|
572
|
+
const entries = [];
|
|
573
|
+
const maxTokens = budget.maxTokens - budget.reserveForResponse;
|
|
574
|
+
const messages = store.getAll();
|
|
575
|
+
const msgCap = this.config.maxMessageTokens;
|
|
576
|
+
let totalTokens = 0;
|
|
577
|
+
// Phase 0: Head window — preserved verbatim (from headStart, not necessarily 0)
|
|
578
|
+
const headStart = this.getHeadWindowStartIndex(store);
|
|
579
|
+
const headEnd = this.getHeadWindowEnd(store);
|
|
580
|
+
for (let i = headStart; i < headEnd && i < messages.length; i++) {
|
|
581
|
+
const msg = messages[i];
|
|
582
|
+
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
583
|
+
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
584
|
+
if (totalTokens + tokens > maxTokens)
|
|
585
|
+
break;
|
|
586
|
+
entries.push({
|
|
587
|
+
index: entries.length,
|
|
588
|
+
sourceMessageId: msg.id,
|
|
589
|
+
sourceRelation: 'copy',
|
|
590
|
+
participant: msg.participant,
|
|
591
|
+
content,
|
|
592
|
+
});
|
|
593
|
+
totalTokens += tokens;
|
|
594
|
+
}
|
|
595
|
+
// Compute recent window exclusion set (also exclude head window messages)
|
|
596
|
+
const recentStart = this.getRecentWindowStart(store);
|
|
597
|
+
const excludeIds = new Set();
|
|
598
|
+
for (let i = headStart; i < headEnd; i++)
|
|
599
|
+
excludeIds.add(messages[i].id);
|
|
600
|
+
for (let i = recentStart; i < messages.length; i++)
|
|
601
|
+
excludeIds.add(messages[i].id);
|
|
602
|
+
// Get anti-redundant summaries
|
|
603
|
+
const { shownL3, shownL2, shownL1 } = this.getAntiRedundantSummaries(excludeIds);
|
|
604
|
+
// Budget carryover: L3 → L2 → L1
|
|
605
|
+
const l3Budget = this.config.l3BudgetTokens ?? 30000;
|
|
606
|
+
const l2Budget = this.config.l2BudgetTokens ?? 30000;
|
|
607
|
+
const l1Budget = this.config.l1BudgetTokens ?? 30000;
|
|
608
|
+
const selectedSummaries = [];
|
|
609
|
+
let totalSummaryTokens = 0;
|
|
610
|
+
// Phase 1: L3 within L3 budget
|
|
611
|
+
let l3Used = 0;
|
|
612
|
+
for (const s of shownL3) {
|
|
613
|
+
if (l3Used + s.tokens > l3Budget)
|
|
614
|
+
break;
|
|
615
|
+
if (totalTokens + totalSummaryTokens + s.tokens > maxTokens)
|
|
616
|
+
break;
|
|
617
|
+
selectedSummaries.push(s);
|
|
618
|
+
l3Used += s.tokens;
|
|
619
|
+
totalSummaryTokens += s.tokens;
|
|
620
|
+
}
|
|
621
|
+
const l3Carryover = l3Budget - l3Used;
|
|
622
|
+
// Phase 2: L2 within (L2 budget + carryover)
|
|
623
|
+
let l2Used = 0;
|
|
624
|
+
const l2Effective = l2Budget + l3Carryover;
|
|
625
|
+
for (const s of shownL2) {
|
|
626
|
+
if (l2Used + s.tokens > l2Effective)
|
|
627
|
+
break;
|
|
628
|
+
if (totalTokens + totalSummaryTokens + s.tokens > maxTokens)
|
|
629
|
+
break;
|
|
630
|
+
selectedSummaries.push(s);
|
|
631
|
+
l2Used += s.tokens;
|
|
632
|
+
totalSummaryTokens += s.tokens;
|
|
633
|
+
}
|
|
634
|
+
const l2Carryover = l2Effective - l2Used;
|
|
635
|
+
// Phase 3: L1 within (L1 budget + carryover)
|
|
636
|
+
const l1Effective = l1Budget + l2Carryover;
|
|
637
|
+
const l1Remaining = maxTokens - totalTokens - totalSummaryTokens;
|
|
638
|
+
const { selected: l1Selected, tokensUsed: l1Used } = this.selectL1Summaries(shownL1, l1Effective, l1Remaining);
|
|
639
|
+
selectedSummaries.push(...l1Selected);
|
|
640
|
+
totalSummaryTokens += l1Used;
|
|
641
|
+
// Emit summaries as a single Q&A pair
|
|
642
|
+
if (selectedSummaries.length > 0) {
|
|
643
|
+
const contextLabel = this.config.summaryContextLabel ?? 'What do you remember from earlier?';
|
|
644
|
+
const combinedText = selectedSummaries.map(s => s.content).join('\n\n---\n\n');
|
|
645
|
+
const questionEntry = {
|
|
646
|
+
index: entries.length,
|
|
647
|
+
participant: 'Context Manager',
|
|
648
|
+
content: [{ type: 'text', text: contextLabel }],
|
|
649
|
+
sourceRelation: 'derived',
|
|
650
|
+
};
|
|
651
|
+
const answerEntry = {
|
|
652
|
+
index: entries.length + 1,
|
|
653
|
+
participant: this.config.summaryParticipant ?? 'Claude',
|
|
654
|
+
content: [{ type: 'text', text: combinedText }],
|
|
655
|
+
sourceRelation: 'derived',
|
|
656
|
+
};
|
|
657
|
+
const pairTokens = this.estimateTokens(questionEntry.content) +
|
|
658
|
+
this.estimateTokens(answerEntry.content);
|
|
659
|
+
entries.push(questionEntry);
|
|
660
|
+
entries.push(answerEntry);
|
|
661
|
+
totalTokens += pairTokens;
|
|
662
|
+
}
|
|
663
|
+
// Phase 4: Recent uncompressed messages (skip head window overlap)
|
|
664
|
+
const effectiveRecentStart = Math.max(recentStart, headEnd);
|
|
665
|
+
for (let i = effectiveRecentStart; i < messages.length; i++) {
|
|
666
|
+
const msg = messages[i];
|
|
667
|
+
const content = msgCap > 0 ? this.truncateContent(msg.content, msgCap) : msg.content;
|
|
668
|
+
const tokens = msgCap > 0 ? Math.min(store.estimateTokens(msg), msgCap + 50) : store.estimateTokens(msg);
|
|
669
|
+
if (totalTokens + tokens > maxTokens)
|
|
670
|
+
break;
|
|
671
|
+
entries.push({
|
|
672
|
+
index: entries.length,
|
|
673
|
+
sourceMessageId: msg.id,
|
|
674
|
+
sourceRelation: 'copy',
|
|
675
|
+
participant: msg.participant,
|
|
676
|
+
content,
|
|
677
|
+
});
|
|
678
|
+
totalTokens += tokens;
|
|
679
|
+
}
|
|
680
|
+
this.trimOrphanedToolUse(entries);
|
|
681
|
+
return entries;
|
|
682
|
+
}
|
|
683
|
+
// ============================================================================
|
|
684
|
+
// Overridable hooks (for subclass customization)
|
|
685
|
+
// ============================================================================
|
|
686
|
+
/**
|
|
687
|
+
* Build the compression instruction for an L1 chunk.
|
|
688
|
+
* Override in subclasses for phase-aware prompts.
|
|
689
|
+
*/
|
|
690
|
+
getCompressionInstruction(chunk, targetTokens) {
|
|
691
|
+
return `Starting from my last message, please describe everything that has happened. Aim for about ${targetTokens} tokens. Describe it as you would to yourself, as if you are remembering what has happened.`;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Build the merge instruction for combining summaries into a higher level.
|
|
695
|
+
* Override in subclasses for domain-specific merge prompts.
|
|
696
|
+
*/
|
|
697
|
+
getMergeInstruction(targetLevel, sources, targetTokens) {
|
|
698
|
+
return `Please consolidate the memories since my last message into a single cohesive memory. Aim for about ${targetTokens} tokens. Write as you would to yourself — this is your autobiography, capturing the arc of what happened.`;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Select L1 summaries within a budget. Returns selected summaries and tokens used.
|
|
702
|
+
* Override in subclasses for asymmetric budget allocation (e.g., cap research, prioritize synthesis).
|
|
703
|
+
*/
|
|
704
|
+
selectL1Summaries(shownL1, budget, maxTokens) {
|
|
705
|
+
const selected = [];
|
|
706
|
+
let used = 0;
|
|
707
|
+
for (const s of shownL1) {
|
|
708
|
+
if (used + s.tokens > budget)
|
|
709
|
+
break;
|
|
710
|
+
if (used + s.tokens > maxTokens)
|
|
711
|
+
break;
|
|
712
|
+
selected.push(s);
|
|
713
|
+
used += s.tokens;
|
|
714
|
+
}
|
|
715
|
+
return { selected, tokensUsed: used };
|
|
716
|
+
}
|
|
717
|
+
// ============================================================================
|
|
718
|
+
// Head window reset / topic transition
|
|
719
|
+
// ============================================================================
|
|
720
|
+
/**
|
|
721
|
+
* Reset the head window to start from a new message ID.
|
|
722
|
+
* Old head window messages become compressible on the next chunk rebuild.
|
|
723
|
+
*/
|
|
724
|
+
resetHeadWindow(newStartId) {
|
|
725
|
+
this.headWindowStartId = newStartId;
|
|
726
|
+
this._cachedHeadStartIndex = null;
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Generate a transition summary from the current head window + top summaries.
|
|
730
|
+
* Used when `/newtopic` is called without explicit context.
|
|
731
|
+
*/
|
|
732
|
+
async generateTransitionSummary(ctx) {
|
|
733
|
+
if (!ctx.membrane) {
|
|
734
|
+
throw new Error('No membrane instance for transition summary generation');
|
|
735
|
+
}
|
|
736
|
+
const messages = ctx.messageStore.getAll();
|
|
737
|
+
const headStart = this.getHeadWindowStartIndex(ctx.messageStore);
|
|
738
|
+
const headEnd = this.getHeadWindowEnd(ctx.messageStore);
|
|
739
|
+
const headMessages = messages.slice(headStart, headEnd);
|
|
740
|
+
// Format head content, truncated to ~2000 tokens (~8000 chars)
|
|
741
|
+
const MAX_HEAD_CHARS = 8000;
|
|
742
|
+
let headContent = '';
|
|
743
|
+
for (const m of headMessages) {
|
|
744
|
+
const entry = `${m.participant}: ${this.extractText(m.content)}`;
|
|
745
|
+
if (headContent.length + entry.length > MAX_HEAD_CHARS) {
|
|
746
|
+
headContent += '\n\n[...truncated...]';
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
headContent += (headContent ? '\n\n' : '') + entry;
|
|
750
|
+
}
|
|
751
|
+
// Gather top summaries for broader context
|
|
752
|
+
const topSummaries = this.summaries
|
|
753
|
+
.filter(s => s.level >= 2)
|
|
754
|
+
.slice(-3)
|
|
755
|
+
.map(s => s.content)
|
|
756
|
+
.join('\n\n---\n\n');
|
|
757
|
+
const instruction = [
|
|
758
|
+
'Summarize the prior conversation context in 2-3 paragraphs, focusing on:',
|
|
759
|
+
'- What was the original objective and what was accomplished',
|
|
760
|
+
'- Key findings, decisions, and unresolved questions',
|
|
761
|
+
'- Any cross-references or context that may be relevant going forward',
|
|
762
|
+
'',
|
|
763
|
+
'Prior context:',
|
|
764
|
+
'',
|
|
765
|
+
headContent,
|
|
766
|
+
topSummaries ? `\nHigher-level summaries:\n${topSummaries}` : '',
|
|
767
|
+
'',
|
|
768
|
+
'Write a concise transition summary.',
|
|
769
|
+
].join('\n');
|
|
770
|
+
const request = {
|
|
771
|
+
messages: [{ participant: 'Context Manager', content: [{ type: 'text', text: instruction }] }],
|
|
772
|
+
system: 'You are forming a transition summary between conversation topics. Write concisely.',
|
|
773
|
+
config: {
|
|
774
|
+
model: this.config.compressionModel ?? 'claude-sonnet-4-20250514',
|
|
775
|
+
maxTokens: 1500,
|
|
776
|
+
temperature: 0,
|
|
777
|
+
},
|
|
778
|
+
};
|
|
779
|
+
const response = await ctx.membrane.complete(request, { formatter: this.nativeFormatter });
|
|
780
|
+
return response.content
|
|
781
|
+
.filter((b) => b.type === 'text')
|
|
782
|
+
.map(b => b.text)
|
|
783
|
+
.join('\n');
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Check if a message is a topic transition marker.
|
|
787
|
+
*/
|
|
788
|
+
isTopicTransitionMessage(message) {
|
|
789
|
+
return message.participant === 'Context Manager' &&
|
|
790
|
+
message.content.some(b => b.type === 'text' && b.text.startsWith('[Topic Transition]'));
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Extract plain text from content blocks.
|
|
794
|
+
*/
|
|
795
|
+
extractText(content) {
|
|
796
|
+
return content
|
|
797
|
+
.filter((b) => b.type === 'text')
|
|
798
|
+
.map(b => b.text)
|
|
799
|
+
.join('\n');
|
|
800
|
+
}
|
|
801
|
+
// ============================================================================
|
|
802
|
+
// Shared utilities
|
|
803
|
+
// ============================================================================
|
|
804
|
+
/**
|
|
805
|
+
* Get messages in the compressible zone: outside both head window and recent window.
|
|
806
|
+
* Returns messages from [0, headStart) ∪ [headEnd, recentStart).
|
|
807
|
+
*/
|
|
808
|
+
getCompressibleMessages(store) {
|
|
809
|
+
const messages = store.getAll();
|
|
810
|
+
const headStart = this.getHeadWindowStartIndex(store);
|
|
811
|
+
const headEnd = this.getHeadWindowEnd(store);
|
|
812
|
+
const recentStart = this.getRecentWindowStart(store);
|
|
813
|
+
return messages.slice(0, recentStart)
|
|
814
|
+
.filter((_, i) => i < headStart || i >= headEnd);
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Rebuild chunk boundaries based on current messages.
|
|
818
|
+
*/
|
|
819
|
+
rebuildChunks(store) {
|
|
820
|
+
const messagesToChunk = this.getCompressibleMessages(store);
|
|
821
|
+
// Preserve existing compressed chunks (legacy) and summary linkage (hierarchical)
|
|
822
|
+
const existingCompressed = new Map();
|
|
823
|
+
for (const chunk of this.chunks) {
|
|
824
|
+
if (chunk.compressed) {
|
|
825
|
+
const key = this.chunkKey(chunk);
|
|
826
|
+
existingCompressed.set(key, chunk);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
// Rebuild chunks
|
|
830
|
+
this.chunks = [];
|
|
831
|
+
this.compressionQueue = [];
|
|
832
|
+
let currentChunk = [];
|
|
833
|
+
let currentTokens = 0;
|
|
834
|
+
// Track start position in the filtered array for chunk boundary metadata
|
|
835
|
+
let chunkFilteredStart = 0;
|
|
836
|
+
for (let i = 0; i < messagesToChunk.length; i++) {
|
|
837
|
+
const msg = messagesToChunk[i];
|
|
838
|
+
let msgTokens = store.estimateTokens(msg);
|
|
839
|
+
if (this.config.attachmentsIgnoreSize) {
|
|
840
|
+
msgTokens = this.estimateTextOnlyTokens(msg);
|
|
841
|
+
}
|
|
842
|
+
currentChunk.push(msg);
|
|
843
|
+
currentTokens += msgTokens;
|
|
844
|
+
const shouldClose = currentTokens >= this.config.targetChunkTokens &&
|
|
845
|
+
currentChunk.length >= 4;
|
|
846
|
+
if (shouldClose) {
|
|
847
|
+
const chunk = this.createChunk(this.chunks.length, chunkFilteredStart, i + 1, currentChunk, currentTokens, existingCompressed);
|
|
848
|
+
this.chunks.push(chunk);
|
|
849
|
+
if (!chunk.compressed) {
|
|
850
|
+
this.compressionQueue.push(chunk.index);
|
|
851
|
+
}
|
|
852
|
+
currentChunk = [];
|
|
853
|
+
currentTokens = 0;
|
|
854
|
+
chunkFilteredStart = i + 1;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (currentChunk.length >= 4) {
|
|
858
|
+
const chunk = this.createChunk(this.chunks.length, chunkFilteredStart, messagesToChunk.length, currentChunk, currentTokens, existingCompressed);
|
|
859
|
+
this.chunks.push(chunk);
|
|
860
|
+
if (!chunk.compressed) {
|
|
861
|
+
this.compressionQueue.push(chunk.index);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
createChunk(index, startIndex, endIndex, messages, tokens, existingCompressed) {
|
|
866
|
+
const chunk = {
|
|
867
|
+
index,
|
|
868
|
+
startIndex,
|
|
869
|
+
endIndex,
|
|
870
|
+
messages: [...messages],
|
|
871
|
+
tokens,
|
|
872
|
+
compressed: false,
|
|
873
|
+
};
|
|
874
|
+
const key = this.chunkKey(chunk);
|
|
875
|
+
const existing = existingCompressed.get(key);
|
|
876
|
+
if (existing) {
|
|
877
|
+
chunk.compressed = true;
|
|
878
|
+
chunk.diary = existing.diary;
|
|
879
|
+
chunk.summaryId = existing.summaryId;
|
|
880
|
+
}
|
|
881
|
+
// In hierarchical mode, also check if a summary exists for this chunk
|
|
882
|
+
if (this.config.hierarchical && !chunk.compressed) {
|
|
883
|
+
const summary = this.summaries.find(s => s.level === 1 && s.sourceIds.join(':') === key);
|
|
884
|
+
if (summary) {
|
|
885
|
+
chunk.compressed = true;
|
|
886
|
+
chunk.summaryId = summary.id;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return chunk;
|
|
890
|
+
}
|
|
891
|
+
chunkKey(chunk) {
|
|
892
|
+
return chunk.messages.map((m) => m.id).join(':');
|
|
893
|
+
}
|
|
894
|
+
getRecentWindowStart(store) {
|
|
895
|
+
const messages = store.getAll();
|
|
896
|
+
let tokens = 0;
|
|
897
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
898
|
+
tokens += store.estimateTokens(messages[i]);
|
|
899
|
+
if (tokens > this.config.recentWindowTokens) {
|
|
900
|
+
let boundary = i + 1;
|
|
901
|
+
// Don't split a tool_use/tool_result pair: if the message at the boundary
|
|
902
|
+
// is a tool_result, include the preceding tool_use with it (retreat by 1).
|
|
903
|
+
if (boundary > 0 && boundary < messages.length && this.hasToolResult(messages[boundary])) {
|
|
904
|
+
boundary--;
|
|
905
|
+
}
|
|
906
|
+
return boundary;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return 0;
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Index of the first message in the head window.
|
|
913
|
+
* When headWindowStartId is set, the head window starts from that message
|
|
914
|
+
* instead of message 0 — old messages before it become compressible.
|
|
915
|
+
*/
|
|
916
|
+
getHeadWindowStartIndex(store) {
|
|
917
|
+
if (!this.headWindowStartId)
|
|
918
|
+
return 0;
|
|
919
|
+
const messages = store.getAll();
|
|
920
|
+
// Cache to avoid repeated O(n) scans within the same select/rebuild pass
|
|
921
|
+
if (this._cachedHeadStartIndex
|
|
922
|
+
&& this._cachedHeadStartIndex.id === this.headWindowStartId
|
|
923
|
+
&& this._cachedHeadStartIndex.msgCount === messages.length) {
|
|
924
|
+
return this._cachedHeadStartIndex.result;
|
|
925
|
+
}
|
|
926
|
+
const idx = messages.findIndex(m => m.id === this.headWindowStartId);
|
|
927
|
+
const result = idx >= 0 ? idx : 0;
|
|
928
|
+
this._cachedHeadStartIndex = { id: this.headWindowStartId, msgCount: messages.length, result };
|
|
929
|
+
return result;
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Index of the first message AFTER the head window.
|
|
933
|
+
* Messages [headStart, headEnd) are preserved verbatim.
|
|
934
|
+
*/
|
|
935
|
+
getHeadWindowEnd(store) {
|
|
936
|
+
if (this.config.headWindowTokens <= 0)
|
|
937
|
+
return 0;
|
|
938
|
+
const messages = store.getAll();
|
|
939
|
+
const startIdx = this.getHeadWindowStartIndex(store);
|
|
940
|
+
let tokens = 0;
|
|
941
|
+
for (let i = startIdx; i < messages.length; i++) {
|
|
942
|
+
tokens += store.estimateTokens(messages[i]);
|
|
943
|
+
if (tokens > this.config.headWindowTokens) {
|
|
944
|
+
let boundary = i;
|
|
945
|
+
// Don't split a tool_use/tool_result pair: if the boundary message's
|
|
946
|
+
// predecessor has tool_use, pull back by one so the pair stays together.
|
|
947
|
+
if (boundary > startIdx && this.hasToolUse(messages[boundary - 1])) {
|
|
948
|
+
boundary--;
|
|
949
|
+
}
|
|
950
|
+
return boundary;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return messages.length;
|
|
954
|
+
}
|
|
955
|
+
hasToolUse(message) {
|
|
956
|
+
return message.content.some(block => block.type === 'tool_use');
|
|
957
|
+
}
|
|
958
|
+
hasToolResult(message) {
|
|
959
|
+
return message.content.some(block => block.type === 'tool_result');
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Remove trailing entries that contain tool_use without a following tool_result.
|
|
963
|
+
* This prevents orphaned tool_use blocks when a budget break cuts between
|
|
964
|
+
* a tool_use message and its tool_result response.
|
|
965
|
+
*/
|
|
966
|
+
trimOrphanedToolUse(entries) {
|
|
967
|
+
while (entries.length > 0) {
|
|
968
|
+
const last = entries[entries.length - 1];
|
|
969
|
+
const hasUse = last.content.some(b => b.type === 'tool_use');
|
|
970
|
+
const hasResult = last.content.some(b => b.type === 'tool_result');
|
|
971
|
+
if (hasUse && !hasResult) {
|
|
972
|
+
entries.pop();
|
|
973
|
+
}
|
|
974
|
+
else {
|
|
975
|
+
break;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
isChunkOldEnough(chunk) {
|
|
980
|
+
return true;
|
|
981
|
+
}
|
|
982
|
+
formatChunkForCompression(chunk) {
|
|
983
|
+
const lines = ['<earlier_in_conversation>'];
|
|
984
|
+
for (const msg of chunk.messages) {
|
|
985
|
+
lines.push(`# ${msg.participant.toUpperCase()}`);
|
|
986
|
+
for (const block of msg.content) {
|
|
987
|
+
if (block.type === 'text') {
|
|
988
|
+
lines.push(block.text);
|
|
989
|
+
}
|
|
990
|
+
else if (block.type === 'tool_use') {
|
|
991
|
+
lines.push(`[Tool: ${block.name}]`);
|
|
992
|
+
}
|
|
993
|
+
else if (block.type === 'tool_result') {
|
|
994
|
+
lines.push(`[Tool Result]`);
|
|
995
|
+
}
|
|
996
|
+
else if (block.type === 'image') {
|
|
997
|
+
lines.push(`[Image]`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
lines.push('');
|
|
1001
|
+
}
|
|
1002
|
+
lines.push('</earlier_in_conversation>');
|
|
1003
|
+
return lines.join('\n');
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Collapse consecutive messages from the same participant into single messages.
|
|
1007
|
+
* Required because Claude API rejects consecutive same-role messages.
|
|
1008
|
+
*/
|
|
1009
|
+
collapseConsecutiveMessages(messages) {
|
|
1010
|
+
if (messages.length === 0)
|
|
1011
|
+
return [];
|
|
1012
|
+
const result = [
|
|
1013
|
+
{ participant: messages[0].participant, content: [...messages[0].content] },
|
|
1014
|
+
];
|
|
1015
|
+
for (let i = 1; i < messages.length; i++) {
|
|
1016
|
+
const last = result[result.length - 1];
|
|
1017
|
+
if (messages[i].participant === last.participant) {
|
|
1018
|
+
// Merge: add separator then content
|
|
1019
|
+
last.content.push({ type: 'text', text: '\n\n---\n\n' });
|
|
1020
|
+
last.content.push(...messages[i].content);
|
|
1021
|
+
}
|
|
1022
|
+
else {
|
|
1023
|
+
result.push({ participant: messages[i].participant, content: [...messages[i].content] });
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
return result;
|
|
1027
|
+
}
|
|
1028
|
+
estimateTextOnlyTokens(msg) {
|
|
1029
|
+
let tokens = 0;
|
|
1030
|
+
for (const block of msg.content) {
|
|
1031
|
+
if (block.type === 'text') {
|
|
1032
|
+
tokens += Math.ceil(block.text.length / 4);
|
|
1033
|
+
}
|
|
1034
|
+
else if (block.type === 'thinking') {
|
|
1035
|
+
tokens += Math.ceil(block.thinking.length / 4);
|
|
1036
|
+
}
|
|
1037
|
+
else if (block.type === 'tool_use') {
|
|
1038
|
+
tokens += Math.ceil(JSON.stringify(block.input).length / 4) + 20;
|
|
1039
|
+
}
|
|
1040
|
+
else if (block.type === 'tool_result') {
|
|
1041
|
+
if (typeof block.content === 'string') {
|
|
1042
|
+
tokens += Math.ceil(block.content.length / 4);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
return tokens;
|
|
1047
|
+
}
|
|
1048
|
+
estimateTokens(content) {
|
|
1049
|
+
let tokens = 0;
|
|
1050
|
+
for (const block of content) {
|
|
1051
|
+
if (block.type === 'text') {
|
|
1052
|
+
tokens += Math.ceil(block.text.length / 4);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
return tokens;
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Truncate a message's content blocks to fit within maxMessageTokens.
|
|
1059
|
+
*/
|
|
1060
|
+
truncateContent(content, maxTokens) {
|
|
1061
|
+
if (maxTokens <= 0)
|
|
1062
|
+
return content;
|
|
1063
|
+
const est = this.estimateTextOnlyTokens({ content });
|
|
1064
|
+
if (est <= maxTokens)
|
|
1065
|
+
return content;
|
|
1066
|
+
const maxChars = maxTokens * 4;
|
|
1067
|
+
const result = [];
|
|
1068
|
+
let remaining = maxChars;
|
|
1069
|
+
for (const block of content) {
|
|
1070
|
+
if (block.type === 'text') {
|
|
1071
|
+
if (remaining <= 0)
|
|
1072
|
+
continue;
|
|
1073
|
+
if (block.text.length <= remaining) {
|
|
1074
|
+
result.push(block);
|
|
1075
|
+
remaining -= block.text.length;
|
|
1076
|
+
}
|
|
1077
|
+
else {
|
|
1078
|
+
result.push({
|
|
1079
|
+
type: 'text',
|
|
1080
|
+
text: safeSlice(block.text, 0, remaining) + '\n\n[truncated — original was ' +
|
|
1081
|
+
Math.ceil(block.text.length / 4) + ' tokens]',
|
|
1082
|
+
});
|
|
1083
|
+
remaining = 0;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
else if (block.type === 'tool_result') {
|
|
1087
|
+
// tool_result blocks MUST always be included — the Anthropic API requires
|
|
1088
|
+
// every tool_use to have a matching tool_result. Dropping one causes a 400.
|
|
1089
|
+
if (typeof block.content === 'string') {
|
|
1090
|
+
const text = block.content;
|
|
1091
|
+
if (remaining <= 0) {
|
|
1092
|
+
// Budget exhausted — include with minimal content to preserve pairing
|
|
1093
|
+
result.push({
|
|
1094
|
+
...block,
|
|
1095
|
+
content: '[content omitted — context budget exceeded]',
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
else if (text.length > remaining) {
|
|
1099
|
+
result.push({
|
|
1100
|
+
...block,
|
|
1101
|
+
content: safeSlice(text, 0, remaining) + '\n\n[truncated — original was ' +
|
|
1102
|
+
Math.ceil(text.length / 4) + ' tokens]',
|
|
1103
|
+
});
|
|
1104
|
+
remaining = 0;
|
|
1105
|
+
}
|
|
1106
|
+
else {
|
|
1107
|
+
result.push(block);
|
|
1108
|
+
remaining -= text.length;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
else {
|
|
1112
|
+
result.push(block);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
else {
|
|
1116
|
+
result.push(block);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return result;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
//# sourceMappingURL=autobiographical.js.map
|