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