@jonathangu/openclawbrain 0.3.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 (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +412 -0
  3. package/bin/openclawbrain.js +15 -0
  4. package/docs/END_STATE.md +244 -0
  5. package/docs/EVIDENCE.md +128 -0
  6. package/docs/RELEASE_CONTRACT.md +91 -0
  7. package/docs/agent-tools.md +106 -0
  8. package/docs/architecture.md +224 -0
  9. package/docs/configuration.md +178 -0
  10. package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/status.json +87 -0
  11. package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/summary.md +16 -0
  12. package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/trace.json +273 -0
  13. package/docs/evidence/2026-03-16/3188b50c4ed30f07dea111e35ce52aabefaced63/brain-teach-session-bound/validation-report.json +652 -0
  14. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/channels-status.txt +31 -0
  15. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/config-snapshot.json +66 -0
  16. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/doctor.json +14 -0
  17. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/gateway-probe.txt +34 -0
  18. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/gateway-status.txt +41 -0
  19. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/logs.txt +428 -0
  20. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/status-all.txt +60 -0
  21. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/status.json +223 -0
  22. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/summary.md +13 -0
  23. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/trace.json +4 -0
  24. package/docs/evidence/2026-03-16/4941429588810da5d6f7ef1509f229f83fa08031/validation-report.json +334 -0
  25. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/channels-status.txt +25 -0
  26. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/config-snapshot.json +91 -0
  27. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/doctor.json +14 -0
  28. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/gateway-probe.txt +36 -0
  29. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/gateway-status.txt +44 -0
  30. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/logs.txt +428 -0
  31. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-doctor.json +10 -0
  32. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-sdk-probe.json +11 -0
  33. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/preflight-setup-only.json +12 -0
  34. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/summary.md +30 -0
  35. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/short-static-classification/validation-report.json +72 -0
  36. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/status-all.txt +63 -0
  37. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/status.json +200 -0
  38. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/summary.md +13 -0
  39. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/trace.json +4 -0
  40. package/docs/evidence/2026-03-16/7f8dbcb27e741abdeefd5656c210639d0acdd440/validation-report.json +311 -0
  41. package/docs/evidence/README.md +16 -0
  42. package/docs/fts5.md +161 -0
  43. package/docs/tui.md +506 -0
  44. package/index.ts +1372 -0
  45. package/openclaw.plugin.json +136 -0
  46. package/package.json +66 -0
  47. package/src/assembler.ts +804 -0
  48. package/src/brain-cli.ts +316 -0
  49. package/src/brain-core/decay.ts +35 -0
  50. package/src/brain-core/episode.ts +82 -0
  51. package/src/brain-core/graph.ts +321 -0
  52. package/src/brain-core/health.ts +116 -0
  53. package/src/brain-core/mutator.ts +281 -0
  54. package/src/brain-core/pack.ts +117 -0
  55. package/src/brain-core/policy.ts +153 -0
  56. package/src/brain-core/replay.ts +1 -0
  57. package/src/brain-core/teacher.ts +105 -0
  58. package/src/brain-core/trace.ts +40 -0
  59. package/src/brain-core/traverse.ts +230 -0
  60. package/src/brain-core/types.ts +405 -0
  61. package/src/brain-core/update.ts +123 -0
  62. package/src/brain-harvest/human.ts +46 -0
  63. package/src/brain-harvest/scanner.ts +98 -0
  64. package/src/brain-harvest/self.ts +147 -0
  65. package/src/brain-runtime/assembler-extension.ts +230 -0
  66. package/src/brain-runtime/evidence-detectors.ts +68 -0
  67. package/src/brain-runtime/graph-io.ts +72 -0
  68. package/src/brain-runtime/harvester-extension.ts +98 -0
  69. package/src/brain-runtime/service.ts +659 -0
  70. package/src/brain-runtime/tools.ts +109 -0
  71. package/src/brain-runtime/worker-state.ts +106 -0
  72. package/src/brain-runtime/worker-supervisor.ts +169 -0
  73. package/src/brain-store/embedding.ts +179 -0
  74. package/src/brain-store/init.ts +347 -0
  75. package/src/brain-store/migrations.ts +188 -0
  76. package/src/brain-store/store.ts +816 -0
  77. package/src/brain-worker/child-runner.ts +321 -0
  78. package/src/brain-worker/jobs.ts +12 -0
  79. package/src/brain-worker/mutation-job.ts +5 -0
  80. package/src/brain-worker/promotion-job.ts +5 -0
  81. package/src/brain-worker/protocol.ts +79 -0
  82. package/src/brain-worker/teacher-job.ts +5 -0
  83. package/src/brain-worker/update-job.ts +5 -0
  84. package/src/brain-worker/worker.ts +422 -0
  85. package/src/compaction.ts +1332 -0
  86. package/src/db/config.ts +265 -0
  87. package/src/db/connection.ts +72 -0
  88. package/src/db/features.ts +42 -0
  89. package/src/db/migration.ts +561 -0
  90. package/src/engine.ts +1995 -0
  91. package/src/expansion-auth.ts +351 -0
  92. package/src/expansion-policy.ts +303 -0
  93. package/src/expansion.ts +383 -0
  94. package/src/integrity.ts +600 -0
  95. package/src/large-files.ts +527 -0
  96. package/src/openclaw-bridge.ts +22 -0
  97. package/src/retrieval.ts +357 -0
  98. package/src/store/conversation-store.ts +748 -0
  99. package/src/store/fts5-sanitize.ts +29 -0
  100. package/src/store/full-text-fallback.ts +74 -0
  101. package/src/store/index.ts +29 -0
  102. package/src/store/summary-store.ts +918 -0
  103. package/src/summarize.ts +847 -0
  104. package/src/tools/common.ts +53 -0
  105. package/src/tools/lcm-conversation-scope.ts +76 -0
  106. package/src/tools/lcm-describe-tool.ts +234 -0
  107. package/src/tools/lcm-expand-query-tool.ts +594 -0
  108. package/src/tools/lcm-expand-tool.delegation.ts +556 -0
  109. package/src/tools/lcm-expand-tool.ts +448 -0
  110. package/src/tools/lcm-expansion-recursion-guard.ts +286 -0
  111. package/src/tools/lcm-grep-tool.ts +200 -0
  112. package/src/transcript-repair.ts +301 -0
  113. package/src/types.ts +149 -0
@@ -0,0 +1,1332 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ConversationStore, CreateMessagePartInput } from "./store/conversation-store.js";
3
+ import type { SummaryStore, SummaryRecord, ContextItemRecord } from "./store/summary-store.js";
4
+ import { extractFileIdsFromContent } from "./large-files.js";
5
+
6
+ // ── Public types ─────────────────────────────────────────────────────────────
7
+
8
+ export interface CompactionDecision {
9
+ shouldCompact: boolean;
10
+ reason: "threshold" | "manual" | "none";
11
+ currentTokens: number;
12
+ threshold: number;
13
+ }
14
+
15
+ export interface CompactionResult {
16
+ actionTaken: boolean;
17
+ /** Tokens before compaction */
18
+ tokensBefore: number;
19
+ /** Tokens after compaction */
20
+ tokensAfter: number;
21
+ /** Summary created (if any) */
22
+ createdSummaryId?: string;
23
+ /** Whether condensation was performed */
24
+ condensed: boolean;
25
+ /** Escalation level used: "normal" | "aggressive" | "fallback" */
26
+ level?: CompactionLevel;
27
+ }
28
+
29
+ export interface CompactionConfig {
30
+ /** Context threshold as fraction of budget (default 0.75) */
31
+ contextThreshold: number;
32
+ /** Number of fresh tail turns to protect (default 8) */
33
+ freshTailCount: number;
34
+ /** Minimum number of depth-0 summaries needed for condensation. */
35
+ leafMinFanout: number;
36
+ /** Minimum number of depth>=1 summaries needed for condensation. */
37
+ condensedMinFanout: number;
38
+ /** Relaxed minimum fanout for hard-trigger sweeps. */
39
+ condensedMinFanoutHard: number;
40
+ /** Incremental depth passes to run after each leaf compaction (default 0). */
41
+ incrementalMaxDepth: number;
42
+ /** Max source tokens to compact per leaf/condensed chunk (default 20000) */
43
+ leafChunkTokens?: number;
44
+ /** Target tokens for leaf summaries (default 600) */
45
+ leafTargetTokens: number;
46
+ /** Target tokens for condensed summaries (default 900) */
47
+ condensedTargetTokens: number;
48
+ /** Maximum compaction rounds (default 10) */
49
+ maxRounds: number;
50
+ /** IANA timezone for timestamps in summaries (default: UTC) */
51
+ timezone?: string;
52
+ }
53
+
54
+ type CompactionLevel = "normal" | "aggressive" | "fallback";
55
+ type CompactionPass = "leaf" | "condensed";
56
+ type CompactionSummarizeOptions = {
57
+ previousSummary?: string;
58
+ isCondensed?: boolean;
59
+ depth?: number;
60
+ };
61
+ type CompactionSummarizeFn = (
62
+ text: string,
63
+ aggressive?: boolean,
64
+ options?: CompactionSummarizeOptions,
65
+ ) => Promise<string>;
66
+ type PassResult = { summaryId: string; level: CompactionLevel };
67
+ type LeafChunkSelection = {
68
+ items: ContextItemRecord[];
69
+ rawTokensOutsideTail: number;
70
+ threshold: number;
71
+ };
72
+ type CondensedChunkSelection = {
73
+ items: ContextItemRecord[];
74
+ summaryTokens: number;
75
+ };
76
+ type CondensedPhaseCandidate = {
77
+ targetDepth: number;
78
+ chunk: CondensedChunkSelection;
79
+ };
80
+
81
+ // ── Helpers ──────────────────────────────────────────────────────────────────
82
+
83
+ /** Estimate token count from character length (~4 chars per token). */
84
+ function estimateTokens(content: string): number {
85
+ return Math.ceil(content.length / 4);
86
+ }
87
+
88
+ /** Format a timestamp as `YYYY-MM-DD HH:mm TZ` for prompt source text. */
89
+ export function formatTimestamp(value: Date, timezone: string = "UTC"): string {
90
+ try {
91
+ const fmt = new Intl.DateTimeFormat("en-CA", {
92
+ timeZone: timezone,
93
+ year: "numeric",
94
+ month: "2-digit",
95
+ day: "2-digit",
96
+ hour: "2-digit",
97
+ minute: "2-digit",
98
+ hour12: false,
99
+ });
100
+ const parts = Object.fromEntries(
101
+ fmt.formatToParts(value).map((p) => [p.type, p.value]),
102
+ );
103
+ const tzAbbr = timezone === "UTC" ? "UTC" : shortTzAbbr(value, timezone);
104
+ return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute} ${tzAbbr}`;
105
+ } catch {
106
+ // Fallback to UTC on invalid timezone
107
+ const year = value.getUTCFullYear();
108
+ const month = String(value.getUTCMonth() + 1).padStart(2, "0");
109
+ const day = String(value.getUTCDate()).padStart(2, "0");
110
+ const hours = String(value.getUTCHours()).padStart(2, "0");
111
+ const minutes = String(value.getUTCMinutes()).padStart(2, "0");
112
+ return `${year}-${month}-${day} ${hours}:${minutes} UTC`;
113
+ }
114
+ }
115
+
116
+ /** Extract short timezone abbreviation (e.g. "PST", "PDT", "EST"). */
117
+ function shortTzAbbr(value: Date, timezone: string): string {
118
+ try {
119
+ const abbr = new Intl.DateTimeFormat("en-US", {
120
+ timeZone: timezone,
121
+ timeZoneName: "short",
122
+ })
123
+ .formatToParts(value)
124
+ .find((p) => p.type === "timeZoneName")?.value;
125
+ return abbr ?? timezone;
126
+ } catch {
127
+ return timezone;
128
+ }
129
+ }
130
+
131
+ /** Generate a deterministic summary ID from content + timestamp. */
132
+ function generateSummaryId(content: string): string {
133
+ return (
134
+ "sum_" +
135
+ createHash("sha256")
136
+ .update(content + Date.now().toString())
137
+ .digest("hex")
138
+ .slice(0, 16)
139
+ );
140
+ }
141
+
142
+ /** Maximum characters for the deterministic fallback truncation (512 tokens * 4 chars). */
143
+ const FALLBACK_MAX_CHARS = 512 * 4;
144
+ const DEFAULT_LEAF_CHUNK_TOKENS = 20_000;
145
+ const CONDENSED_MIN_INPUT_RATIO = 0.1;
146
+
147
+ function dedupeOrderedIds(ids: Iterable<string>): string[] {
148
+ const seen = new Set<string>();
149
+ const ordered: string[] = [];
150
+ for (const id of ids) {
151
+ if (!seen.has(id)) {
152
+ seen.add(id);
153
+ ordered.push(id);
154
+ }
155
+ }
156
+ return ordered;
157
+ }
158
+
159
+ // ── CompactionEngine ─────────────────────────────────────────────────────────
160
+
161
+ export class CompactionEngine {
162
+ constructor(
163
+ private conversationStore: ConversationStore,
164
+ private summaryStore: SummaryStore,
165
+ private config: CompactionConfig,
166
+ ) {}
167
+
168
+ // ── evaluate ─────────────────────────────────────────────────────────────
169
+
170
+ /** Evaluate whether compaction is needed. */
171
+ async evaluate(
172
+ conversationId: number,
173
+ tokenBudget: number,
174
+ observedTokenCount?: number,
175
+ ): Promise<CompactionDecision> {
176
+ const storedTokens = await this.summaryStore.getContextTokenCount(conversationId);
177
+ const liveTokens =
178
+ typeof observedTokenCount === "number" &&
179
+ Number.isFinite(observedTokenCount) &&
180
+ observedTokenCount > 0
181
+ ? Math.floor(observedTokenCount)
182
+ : 0;
183
+ const currentTokens = Math.max(storedTokens, liveTokens);
184
+ const threshold = Math.floor(this.config.contextThreshold * tokenBudget);
185
+
186
+ if (currentTokens > threshold) {
187
+ return {
188
+ shouldCompact: true,
189
+ reason: "threshold",
190
+ currentTokens,
191
+ threshold,
192
+ };
193
+ }
194
+
195
+ return {
196
+ shouldCompact: false,
197
+ reason: "none",
198
+ currentTokens,
199
+ threshold,
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Evaluate whether the raw-message leaf trigger is active.
205
+ *
206
+ * Counts message tokens outside the protected fresh tail and compares against
207
+ * `leafChunkTokens`. This lets callers trigger a soft incremental leaf pass
208
+ * before the full context threshold is breached.
209
+ */
210
+ async evaluateLeafTrigger(conversationId: number): Promise<{
211
+ shouldCompact: boolean;
212
+ rawTokensOutsideTail: number;
213
+ threshold: number;
214
+ }> {
215
+ const rawTokensOutsideTail = await this.countRawTokensOutsideFreshTail(conversationId);
216
+ const threshold = this.resolveLeafChunkTokens();
217
+ return {
218
+ shouldCompact: rawTokensOutsideTail >= threshold,
219
+ rawTokensOutsideTail,
220
+ threshold,
221
+ };
222
+ }
223
+
224
+ // ── compact ──────────────────────────────────────────────────────────────
225
+
226
+ /** Run a full compaction sweep for a conversation. */
227
+ async compact(input: {
228
+ conversationId: number;
229
+ tokenBudget: number;
230
+ /** LLM call function for summarization */
231
+ summarize: CompactionSummarizeFn;
232
+ force?: boolean;
233
+ hardTrigger?: boolean;
234
+ }): Promise<CompactionResult> {
235
+ return this.compactFullSweep(input);
236
+ }
237
+
238
+ /**
239
+ * Run a single leaf pass against the oldest compactable raw chunk.
240
+ *
241
+ * This is the soft-trigger path used for incremental maintenance.
242
+ */
243
+ async compactLeaf(input: {
244
+ conversationId: number;
245
+ tokenBudget: number;
246
+ summarize: CompactionSummarizeFn;
247
+ force?: boolean;
248
+ previousSummaryContent?: string;
249
+ }): Promise<CompactionResult> {
250
+ const { conversationId, tokenBudget, summarize, force } = input;
251
+
252
+ const tokensBefore = await this.summaryStore.getContextTokenCount(conversationId);
253
+ const threshold = Math.floor(this.config.contextThreshold * tokenBudget);
254
+ const leafTrigger = await this.evaluateLeafTrigger(conversationId);
255
+
256
+ if (!force && tokensBefore <= threshold && !leafTrigger.shouldCompact) {
257
+ return {
258
+ actionTaken: false,
259
+ tokensBefore,
260
+ tokensAfter: tokensBefore,
261
+ condensed: false,
262
+ };
263
+ }
264
+
265
+ const leafChunk = await this.selectOldestLeafChunk(conversationId);
266
+ if (leafChunk.items.length === 0) {
267
+ return {
268
+ actionTaken: false,
269
+ tokensBefore,
270
+ tokensAfter: tokensBefore,
271
+ condensed: false,
272
+ };
273
+ }
274
+
275
+ const previousSummaryContent =
276
+ input.previousSummaryContent ??
277
+ (await this.resolvePriorLeafSummaryContext(conversationId, leafChunk.items));
278
+
279
+ const leafResult = await this.leafPass(
280
+ conversationId,
281
+ leafChunk.items,
282
+ summarize,
283
+ previousSummaryContent,
284
+ );
285
+ const tokensAfterLeaf = await this.summaryStore.getContextTokenCount(conversationId);
286
+
287
+ await this.persistCompactionEvents({
288
+ conversationId,
289
+ tokensBefore,
290
+ tokensAfterLeaf,
291
+ tokensAfterFinal: tokensAfterLeaf,
292
+ leafResult: { summaryId: leafResult.summaryId, level: leafResult.level },
293
+ condenseResult: null,
294
+ });
295
+
296
+ let tokensAfter = tokensAfterLeaf;
297
+ let condensed = false;
298
+ let createdSummaryId = leafResult.summaryId;
299
+ let level = leafResult.level;
300
+
301
+ const incrementalMaxDepth = this.resolveIncrementalMaxDepth();
302
+ const condensedMinChunkTokens = this.resolveCondensedMinChunkTokens();
303
+ if (incrementalMaxDepth > 0) {
304
+ for (let targetDepth = 0; targetDepth < incrementalMaxDepth; targetDepth++) {
305
+ const fanout = this.resolveFanoutForDepth(targetDepth, false);
306
+ const chunk = await this.selectOldestChunkAtDepth(conversationId, targetDepth);
307
+ if (chunk.items.length < fanout || chunk.summaryTokens < condensedMinChunkTokens) {
308
+ break;
309
+ }
310
+
311
+ const passTokensBefore = await this.summaryStore.getContextTokenCount(conversationId);
312
+ const condenseResult = await this.condensedPass(
313
+ conversationId,
314
+ chunk.items,
315
+ targetDepth,
316
+ summarize,
317
+ );
318
+ const passTokensAfter = await this.summaryStore.getContextTokenCount(conversationId);
319
+ await this.persistCompactionEvents({
320
+ conversationId,
321
+ tokensBefore: passTokensBefore,
322
+ tokensAfterLeaf: passTokensBefore,
323
+ tokensAfterFinal: passTokensAfter,
324
+ leafResult: null,
325
+ condenseResult,
326
+ });
327
+
328
+ tokensAfter = passTokensAfter;
329
+ condensed = true;
330
+ createdSummaryId = condenseResult.summaryId;
331
+ level = condenseResult.level;
332
+
333
+ if (passTokensAfter >= passTokensBefore) {
334
+ break;
335
+ }
336
+ }
337
+ }
338
+
339
+ return {
340
+ actionTaken: true,
341
+ tokensBefore,
342
+ tokensAfter,
343
+ createdSummaryId,
344
+ condensed,
345
+ level,
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Run a hard-trigger sweep:
351
+ *
352
+ * Phase 1: repeatedly compact raw-message chunks outside the fresh tail.
353
+ * Phase 2: repeatedly condense oldest summary chunks while chunk utilization
354
+ * remains high enough to be worthwhile.
355
+ */
356
+ async compactFullSweep(input: {
357
+ conversationId: number;
358
+ tokenBudget: number;
359
+ summarize: CompactionSummarizeFn;
360
+ force?: boolean;
361
+ hardTrigger?: boolean;
362
+ }): Promise<CompactionResult> {
363
+ const { conversationId, tokenBudget, summarize, force, hardTrigger } = input;
364
+
365
+ const tokensBefore = await this.summaryStore.getContextTokenCount(conversationId);
366
+ const threshold = Math.floor(this.config.contextThreshold * tokenBudget);
367
+ const leafTrigger = await this.evaluateLeafTrigger(conversationId);
368
+
369
+ if (!force && tokensBefore <= threshold && !leafTrigger.shouldCompact) {
370
+ return {
371
+ actionTaken: false,
372
+ tokensBefore,
373
+ tokensAfter: tokensBefore,
374
+ condensed: false,
375
+ };
376
+ }
377
+
378
+ const contextItems = await this.summaryStore.getContextItems(conversationId);
379
+ if (contextItems.length === 0) {
380
+ return {
381
+ actionTaken: false,
382
+ tokensBefore,
383
+ tokensAfter: tokensBefore,
384
+ condensed: false,
385
+ };
386
+ }
387
+
388
+ let actionTaken = false;
389
+ let condensed = false;
390
+ let createdSummaryId: string | undefined;
391
+ let level: CompactionLevel | undefined;
392
+ let previousSummaryContent: string | undefined;
393
+ let previousTokens = tokensBefore;
394
+
395
+ // Phase 1: leaf passes over oldest raw chunks outside the protected tail.
396
+ while (true) {
397
+ const leafChunk = await this.selectOldestLeafChunk(conversationId);
398
+ if (leafChunk.items.length === 0) {
399
+ break;
400
+ }
401
+
402
+ const passTokensBefore = await this.summaryStore.getContextTokenCount(conversationId);
403
+ const leafResult = await this.leafPass(
404
+ conversationId,
405
+ leafChunk.items,
406
+ summarize,
407
+ previousSummaryContent,
408
+ );
409
+ const passTokensAfter = await this.summaryStore.getContextTokenCount(conversationId);
410
+ await this.persistCompactionEvents({
411
+ conversationId,
412
+ tokensBefore: passTokensBefore,
413
+ tokensAfterLeaf: passTokensAfter,
414
+ tokensAfterFinal: passTokensAfter,
415
+ leafResult: { summaryId: leafResult.summaryId, level: leafResult.level },
416
+ condenseResult: null,
417
+ });
418
+
419
+ actionTaken = true;
420
+ createdSummaryId = leafResult.summaryId;
421
+ level = leafResult.level;
422
+ previousSummaryContent = leafResult.content;
423
+
424
+ if (passTokensAfter >= passTokensBefore || passTokensAfter >= previousTokens) {
425
+ break;
426
+ }
427
+ previousTokens = passTokensAfter;
428
+ }
429
+
430
+ // Phase 2: depth-aware condensed passes, always processing shallowest depth first.
431
+ while (true) {
432
+ const candidate = await this.selectShallowestCondensationCandidate({
433
+ conversationId,
434
+ hardTrigger: hardTrigger === true,
435
+ });
436
+ if (!candidate) {
437
+ break;
438
+ }
439
+
440
+ const passTokensBefore = await this.summaryStore.getContextTokenCount(conversationId);
441
+ const condenseResult = await this.condensedPass(
442
+ conversationId,
443
+ candidate.chunk.items,
444
+ candidate.targetDepth,
445
+ summarize,
446
+ );
447
+ const passTokensAfter = await this.summaryStore.getContextTokenCount(conversationId);
448
+ await this.persistCompactionEvents({
449
+ conversationId,
450
+ tokensBefore: passTokensBefore,
451
+ tokensAfterLeaf: passTokensBefore,
452
+ tokensAfterFinal: passTokensAfter,
453
+ leafResult: null,
454
+ condenseResult,
455
+ });
456
+
457
+ actionTaken = true;
458
+ condensed = true;
459
+ createdSummaryId = condenseResult.summaryId;
460
+ level = condenseResult.level;
461
+
462
+ if (passTokensAfter >= passTokensBefore || passTokensAfter >= previousTokens) {
463
+ break;
464
+ }
465
+ previousTokens = passTokensAfter;
466
+ }
467
+
468
+ const tokensAfter = await this.summaryStore.getContextTokenCount(conversationId);
469
+
470
+ return {
471
+ actionTaken,
472
+ tokensBefore,
473
+ tokensAfter,
474
+ createdSummaryId,
475
+ condensed,
476
+ level,
477
+ };
478
+ }
479
+
480
+ // ── compactUntilUnder ────────────────────────────────────────────────────
481
+
482
+ /** Compact until under the requested target, running up to maxRounds. */
483
+ async compactUntilUnder(input: {
484
+ conversationId: number;
485
+ tokenBudget: number;
486
+ targetTokens?: number;
487
+ currentTokens?: number;
488
+ summarize: CompactionSummarizeFn;
489
+ }): Promise<{ success: boolean; rounds: number; finalTokens: number }> {
490
+ const { conversationId, tokenBudget, summarize } = input;
491
+ const targetTokens =
492
+ typeof input.targetTokens === "number" &&
493
+ Number.isFinite(input.targetTokens) &&
494
+ input.targetTokens > 0
495
+ ? Math.floor(input.targetTokens)
496
+ : tokenBudget;
497
+
498
+ const storedTokens = await this.summaryStore.getContextTokenCount(conversationId);
499
+ const liveTokens =
500
+ typeof input.currentTokens === "number" &&
501
+ Number.isFinite(input.currentTokens) &&
502
+ input.currentTokens > 0
503
+ ? Math.floor(input.currentTokens)
504
+ : 0;
505
+ let lastTokens = Math.max(storedTokens, liveTokens);
506
+
507
+ // For forced overflow recovery, callers may pass an observed count that
508
+ // equals the context budget. Treat equality as still needing a compaction
509
+ // attempt so we can create headroom for provider-side framing overhead.
510
+ if (lastTokens < targetTokens) {
511
+ return { success: true, rounds: 0, finalTokens: lastTokens };
512
+ }
513
+
514
+ for (let round = 1; round <= this.config.maxRounds; round++) {
515
+ const result = await this.compact({
516
+ conversationId,
517
+ tokenBudget,
518
+ summarize,
519
+ force: true,
520
+ });
521
+
522
+ if (result.tokensAfter <= targetTokens) {
523
+ return {
524
+ success: true,
525
+ rounds: round,
526
+ finalTokens: result.tokensAfter,
527
+ };
528
+ }
529
+
530
+ // No progress -- bail to avoid infinite loop
531
+ if (!result.actionTaken || result.tokensAfter >= lastTokens) {
532
+ return {
533
+ success: false,
534
+ rounds: round,
535
+ finalTokens: result.tokensAfter,
536
+ };
537
+ }
538
+
539
+ lastTokens = result.tokensAfter;
540
+ }
541
+
542
+ // Exhausted all rounds
543
+ const finalTokens = await this.summaryStore.getContextTokenCount(conversationId);
544
+ return {
545
+ success: finalTokens <= targetTokens,
546
+ rounds: this.config.maxRounds,
547
+ finalTokens,
548
+ };
549
+ }
550
+
551
+ // ── Private helpers ──────────────────────────────────────────────────────
552
+
553
+ /** Normalize configured leaf chunk size to a safe positive integer. */
554
+ private resolveLeafChunkTokens(): number {
555
+ if (
556
+ typeof this.config.leafChunkTokens === "number" &&
557
+ Number.isFinite(this.config.leafChunkTokens) &&
558
+ this.config.leafChunkTokens > 0
559
+ ) {
560
+ return Math.floor(this.config.leafChunkTokens);
561
+ }
562
+ return DEFAULT_LEAF_CHUNK_TOKENS;
563
+ }
564
+
565
+ /** Normalize configured fresh tail count to a safe non-negative integer. */
566
+ private resolveFreshTailCount(): number {
567
+ if (
568
+ typeof this.config.freshTailCount === "number" &&
569
+ Number.isFinite(this.config.freshTailCount) &&
570
+ this.config.freshTailCount > 0
571
+ ) {
572
+ return Math.floor(this.config.freshTailCount);
573
+ }
574
+ return 0;
575
+ }
576
+
577
+ /**
578
+ * Compute the ordinal boundary for protected fresh messages.
579
+ *
580
+ * Messages with ordinal >= returned value are preserved as fresh tail.
581
+ */
582
+ private resolveFreshTailOrdinal(contextItems: ContextItemRecord[]): number {
583
+ const freshTailCount = this.resolveFreshTailCount();
584
+ if (freshTailCount <= 0) {
585
+ return Infinity;
586
+ }
587
+
588
+ const rawMessageItems = contextItems.filter(
589
+ (item) => item.itemType === "message" && item.messageId != null,
590
+ );
591
+ if (rawMessageItems.length === 0) {
592
+ return Infinity;
593
+ }
594
+
595
+ const tailStartIdx = Math.max(0, rawMessageItems.length - freshTailCount);
596
+ return rawMessageItems[tailStartIdx]?.ordinal ?? Infinity;
597
+ }
598
+
599
+ /** Resolve message token count with a content-length fallback. */
600
+ private async getMessageTokenCount(messageId: number): Promise<number> {
601
+ const message = await this.conversationStore.getMessageById(messageId);
602
+ if (!message) {
603
+ return 0;
604
+ }
605
+ if (
606
+ typeof message.tokenCount === "number" &&
607
+ Number.isFinite(message.tokenCount) &&
608
+ message.tokenCount > 0
609
+ ) {
610
+ return message.tokenCount;
611
+ }
612
+ return estimateTokens(message.content);
613
+ }
614
+
615
+ /** Sum raw message tokens outside the protected fresh tail. */
616
+ private async countRawTokensOutsideFreshTail(conversationId: number): Promise<number> {
617
+ const contextItems = await this.summaryStore.getContextItems(conversationId);
618
+ const freshTailOrdinal = this.resolveFreshTailOrdinal(contextItems);
619
+ let rawTokens = 0;
620
+
621
+ for (const item of contextItems) {
622
+ if (item.ordinal >= freshTailOrdinal) {
623
+ break;
624
+ }
625
+ if (item.itemType !== "message" || item.messageId == null) {
626
+ continue;
627
+ }
628
+ rawTokens += await this.getMessageTokenCount(item.messageId);
629
+ }
630
+
631
+ return rawTokens;
632
+ }
633
+
634
+ /**
635
+ * Select the oldest contiguous raw-message chunk outside fresh tail.
636
+ *
637
+ * The selected chunk size is capped by `leafChunkTokens`, but we always pick
638
+ * at least one message when any compactable message exists.
639
+ */
640
+ private async selectOldestLeafChunk(conversationId: number): Promise<LeafChunkSelection> {
641
+ const contextItems = await this.summaryStore.getContextItems(conversationId);
642
+ const freshTailOrdinal = this.resolveFreshTailOrdinal(contextItems);
643
+ const threshold = this.resolveLeafChunkTokens();
644
+
645
+ let rawTokensOutsideTail = 0;
646
+ for (const item of contextItems) {
647
+ if (item.ordinal >= freshTailOrdinal) {
648
+ break;
649
+ }
650
+ if (item.itemType !== "message" || item.messageId == null) {
651
+ continue;
652
+ }
653
+ rawTokensOutsideTail += await this.getMessageTokenCount(item.messageId);
654
+ }
655
+
656
+ const chunk: ContextItemRecord[] = [];
657
+ let chunkTokens = 0;
658
+ let started = false;
659
+ for (const item of contextItems) {
660
+ if (item.ordinal >= freshTailOrdinal) {
661
+ break;
662
+ }
663
+
664
+ if (!started) {
665
+ if (item.itemType !== "message" || item.messageId == null) {
666
+ continue;
667
+ }
668
+ started = true;
669
+ } else if (item.itemType !== "message" || item.messageId == null) {
670
+ break;
671
+ }
672
+
673
+ if (item.messageId == null) {
674
+ continue;
675
+ }
676
+ const messageTokens = await this.getMessageTokenCount(item.messageId);
677
+ if (chunk.length > 0 && chunkTokens + messageTokens > threshold) {
678
+ break;
679
+ }
680
+
681
+ chunk.push(item);
682
+ chunkTokens += messageTokens;
683
+ if (chunkTokens >= threshold) {
684
+ break;
685
+ }
686
+ }
687
+
688
+ return { items: chunk, rawTokensOutsideTail, threshold };
689
+ }
690
+
691
+ /**
692
+ * Resolve recent summary continuity for a leaf pass.
693
+ *
694
+ * Collects up to two most recent summary context items that precede the
695
+ * compacted raw-message chunk and returns their combined content.
696
+ */
697
+ private async resolvePriorLeafSummaryContext(
698
+ conversationId: number,
699
+ messageItems: ContextItemRecord[],
700
+ ): Promise<string | undefined> {
701
+ if (messageItems.length === 0) {
702
+ return undefined;
703
+ }
704
+
705
+ const startOrdinal = Math.min(...messageItems.map((item) => item.ordinal));
706
+ const priorSummaryItems = (await this.summaryStore.getContextItems(conversationId))
707
+ .filter(
708
+ (item) =>
709
+ item.ordinal < startOrdinal &&
710
+ item.itemType === "summary" &&
711
+ typeof item.summaryId === "string",
712
+ )
713
+ .slice(-2);
714
+
715
+ if (priorSummaryItems.length === 0) {
716
+ return undefined;
717
+ }
718
+
719
+ const summaryContents: string[] = [];
720
+ for (const item of priorSummaryItems) {
721
+ if (typeof item.summaryId !== "string") {
722
+ continue;
723
+ }
724
+ const summary = await this.summaryStore.getSummary(item.summaryId);
725
+ const content = summary?.content.trim();
726
+ if (content) {
727
+ summaryContents.push(content);
728
+ }
729
+ }
730
+
731
+ if (summaryContents.length === 0) {
732
+ return undefined;
733
+ }
734
+
735
+ return summaryContents.join("\n\n");
736
+ }
737
+
738
+ /** Resolve summary token count with content-length fallback. */
739
+ private resolveSummaryTokenCount(summary: SummaryRecord): number {
740
+ if (
741
+ typeof summary.tokenCount === "number" &&
742
+ Number.isFinite(summary.tokenCount) &&
743
+ summary.tokenCount > 0
744
+ ) {
745
+ return summary.tokenCount;
746
+ }
747
+ return estimateTokens(summary.content);
748
+ }
749
+
750
+ /** Resolve message token count with content-length fallback. */
751
+ private resolveMessageTokenCount(message: { tokenCount: number; content: string }): number {
752
+ if (
753
+ typeof message.tokenCount === "number" &&
754
+ Number.isFinite(message.tokenCount) &&
755
+ message.tokenCount > 0
756
+ ) {
757
+ return message.tokenCount;
758
+ }
759
+ return estimateTokens(message.content);
760
+ }
761
+
762
+ private resolveLeafMinFanout(): number {
763
+ if (
764
+ typeof this.config.leafMinFanout === "number" &&
765
+ Number.isFinite(this.config.leafMinFanout) &&
766
+ this.config.leafMinFanout > 0
767
+ ) {
768
+ return Math.floor(this.config.leafMinFanout);
769
+ }
770
+ return 8;
771
+ }
772
+
773
+ private resolveCondensedMinFanout(): number {
774
+ if (
775
+ typeof this.config.condensedMinFanout === "number" &&
776
+ Number.isFinite(this.config.condensedMinFanout) &&
777
+ this.config.condensedMinFanout > 0
778
+ ) {
779
+ return Math.floor(this.config.condensedMinFanout);
780
+ }
781
+ return 4;
782
+ }
783
+
784
+ private resolveCondensedMinFanoutHard(): number {
785
+ if (
786
+ typeof this.config.condensedMinFanoutHard === "number" &&
787
+ Number.isFinite(this.config.condensedMinFanoutHard) &&
788
+ this.config.condensedMinFanoutHard > 0
789
+ ) {
790
+ return Math.floor(this.config.condensedMinFanoutHard);
791
+ }
792
+ return 2;
793
+ }
794
+
795
+ private resolveIncrementalMaxDepth(): number {
796
+ if (
797
+ typeof this.config.incrementalMaxDepth === "number" &&
798
+ Number.isFinite(this.config.incrementalMaxDepth)
799
+ ) {
800
+ if (this.config.incrementalMaxDepth < 0) return Infinity;
801
+ if (this.config.incrementalMaxDepth > 0) return Math.floor(this.config.incrementalMaxDepth);
802
+ }
803
+ return 0;
804
+ }
805
+ private resolveFanoutForDepth(targetDepth: number, hardTrigger: boolean): number {
806
+ if (hardTrigger) {
807
+ return this.resolveCondensedMinFanoutHard();
808
+ }
809
+ if (targetDepth === 0) {
810
+ return this.resolveLeafMinFanout();
811
+ }
812
+ return this.resolveCondensedMinFanout();
813
+ }
814
+
815
+ /** Minimum condensed input size before we run another condensed pass. */
816
+ private resolveCondensedMinChunkTokens(): number {
817
+ const chunkTarget = this.resolveLeafChunkTokens();
818
+ const ratioFloor = Math.floor(chunkTarget * CONDENSED_MIN_INPUT_RATIO);
819
+ return Math.max(this.config.condensedTargetTokens, ratioFloor);
820
+ }
821
+
822
+ /**
823
+ * Find the shallowest depth with an eligible same-depth summary chunk.
824
+ */
825
+ private async selectShallowestCondensationCandidate(params: {
826
+ conversationId: number;
827
+ hardTrigger: boolean;
828
+ }): Promise<CondensedPhaseCandidate | null> {
829
+ const { conversationId, hardTrigger } = params;
830
+ const contextItems = await this.summaryStore.getContextItems(conversationId);
831
+ const freshTailOrdinal = this.resolveFreshTailOrdinal(contextItems);
832
+ const minChunkTokens = this.resolveCondensedMinChunkTokens();
833
+ const depthLevels = await this.summaryStore.getDistinctDepthsInContext(conversationId, {
834
+ maxOrdinalExclusive: freshTailOrdinal,
835
+ });
836
+
837
+ for (const targetDepth of depthLevels) {
838
+ const fanout = this.resolveFanoutForDepth(targetDepth, hardTrigger);
839
+ const chunk = await this.selectOldestChunkAtDepth(
840
+ conversationId,
841
+ targetDepth,
842
+ freshTailOrdinal,
843
+ );
844
+ if (chunk.items.length < fanout) {
845
+ continue;
846
+ }
847
+ if (chunk.summaryTokens < minChunkTokens) {
848
+ continue;
849
+ }
850
+ return { targetDepth, chunk };
851
+ }
852
+
853
+ return null;
854
+ }
855
+
856
+ /**
857
+ * Select the oldest contiguous summary chunk at a specific summary depth.
858
+ *
859
+ * Once selection starts, any non-summary item or depth mismatch terminates
860
+ * the chunk to prevent mixed-depth condensation.
861
+ */
862
+ private async selectOldestChunkAtDepth(
863
+ conversationId: number,
864
+ targetDepth: number,
865
+ freshTailOrdinalOverride?: number,
866
+ ): Promise<CondensedChunkSelection> {
867
+ const contextItems = await this.summaryStore.getContextItems(conversationId);
868
+ const freshTailOrdinal =
869
+ typeof freshTailOrdinalOverride === "number"
870
+ ? freshTailOrdinalOverride
871
+ : this.resolveFreshTailOrdinal(contextItems);
872
+ const chunkTokenBudget = this.resolveLeafChunkTokens();
873
+
874
+ const chunk: ContextItemRecord[] = [];
875
+ let summaryTokens = 0;
876
+ for (const item of contextItems) {
877
+ if (item.ordinal >= freshTailOrdinal) {
878
+ break;
879
+ }
880
+ if (item.itemType !== "summary" || item.summaryId == null) {
881
+ if (chunk.length > 0) {
882
+ break;
883
+ }
884
+ continue;
885
+ }
886
+
887
+ const summary = await this.summaryStore.getSummary(item.summaryId);
888
+ if (!summary) {
889
+ if (chunk.length > 0) {
890
+ break;
891
+ }
892
+ continue;
893
+ }
894
+ if (summary.depth !== targetDepth) {
895
+ if (chunk.length > 0) {
896
+ break;
897
+ }
898
+ continue;
899
+ }
900
+ const tokenCount = this.resolveSummaryTokenCount(summary);
901
+
902
+ if (chunk.length > 0 && summaryTokens + tokenCount > chunkTokenBudget) {
903
+ break;
904
+ }
905
+
906
+ chunk.push(item);
907
+ summaryTokens += tokenCount;
908
+ if (summaryTokens >= chunkTokenBudget) {
909
+ break;
910
+ }
911
+ }
912
+
913
+ return { items: chunk, summaryTokens };
914
+ }
915
+
916
+ private async resolvePriorSummaryContextAtDepth(
917
+ conversationId: number,
918
+ summaryItems: ContextItemRecord[],
919
+ targetDepth: number,
920
+ ): Promise<string | undefined> {
921
+ if (summaryItems.length === 0) {
922
+ return undefined;
923
+ }
924
+
925
+ const startOrdinal = Math.min(...summaryItems.map((item) => item.ordinal));
926
+ const priorSummaryItems = (await this.summaryStore.getContextItems(conversationId))
927
+ .filter(
928
+ (item) =>
929
+ item.ordinal < startOrdinal &&
930
+ item.itemType === "summary" &&
931
+ typeof item.summaryId === "string",
932
+ )
933
+ .slice(-4);
934
+ if (priorSummaryItems.length === 0) {
935
+ return undefined;
936
+ }
937
+
938
+ const summaryContents: string[] = [];
939
+ for (const item of priorSummaryItems) {
940
+ if (typeof item.summaryId !== "string") {
941
+ continue;
942
+ }
943
+ const summary = await this.summaryStore.getSummary(item.summaryId);
944
+ if (!summary || summary.depth !== targetDepth) {
945
+ continue;
946
+ }
947
+ const content = summary.content.trim();
948
+ if (content) {
949
+ summaryContents.push(content);
950
+ }
951
+ }
952
+
953
+ if (summaryContents.length === 0) {
954
+ return undefined;
955
+ }
956
+ return summaryContents.slice(-2).join("\n\n");
957
+ }
958
+
959
+ /**
960
+ * Run three-level summarization escalation:
961
+ * normal -> aggressive -> deterministic fallback.
962
+ */
963
+ private async summarizeWithEscalation(params: {
964
+ sourceText: string;
965
+ summarize: CompactionSummarizeFn;
966
+ options?: CompactionSummarizeOptions;
967
+ }): Promise<{ content: string; level: CompactionLevel }> {
968
+ const sourceText = params.sourceText.trim();
969
+ if (!sourceText) {
970
+ return {
971
+ content: "[Truncated from 0 tokens]",
972
+ level: "fallback",
973
+ };
974
+ }
975
+ const inputTokens = Math.max(1, estimateTokens(sourceText));
976
+
977
+ let summaryText = await params.summarize(sourceText, false, params.options);
978
+ let level: CompactionLevel = "normal";
979
+
980
+ if (estimateTokens(summaryText) >= inputTokens) {
981
+ summaryText = await params.summarize(sourceText, true, params.options);
982
+ level = "aggressive";
983
+
984
+ if (estimateTokens(summaryText) >= inputTokens) {
985
+ const truncated =
986
+ sourceText.length > FALLBACK_MAX_CHARS
987
+ ? sourceText.slice(0, FALLBACK_MAX_CHARS)
988
+ : sourceText;
989
+ summaryText = `${truncated}\n[Truncated from ${inputTokens} tokens]`;
990
+ level = "fallback";
991
+ }
992
+ }
993
+
994
+ return { content: summaryText, level };
995
+ }
996
+
997
+ // ── Private: Leaf Pass ───────────────────────────────────────────────────
998
+
999
+ /**
1000
+ * Summarize a chunk of messages into one leaf summary.
1001
+ */
1002
+ private async leafPass(
1003
+ conversationId: number,
1004
+ messageItems: ContextItemRecord[],
1005
+ summarize: CompactionSummarizeFn,
1006
+ previousSummaryContent?: string,
1007
+ ): Promise<{ summaryId: string; level: CompactionLevel; content: string }> {
1008
+ // Fetch full message content for each context item
1009
+ const messageContents: { messageId: number; content: string; createdAt: Date; tokenCount: number }[] =
1010
+ [];
1011
+ for (const item of messageItems) {
1012
+ if (item.messageId == null) {
1013
+ continue;
1014
+ }
1015
+ const msg = await this.conversationStore.getMessageById(item.messageId);
1016
+ if (msg) {
1017
+ messageContents.push({
1018
+ messageId: msg.messageId,
1019
+ content: msg.content,
1020
+ createdAt: msg.createdAt,
1021
+ tokenCount: this.resolveMessageTokenCount(msg),
1022
+ });
1023
+ }
1024
+ }
1025
+
1026
+ const concatenated = messageContents
1027
+ .map((message) => `[${formatTimestamp(message.createdAt, this.config.timezone)}]\n${message.content}`)
1028
+ .join("\n\n");
1029
+ const fileIds = dedupeOrderedIds(
1030
+ messageContents.flatMap((message) => extractFileIdsFromContent(message.content)),
1031
+ );
1032
+ const summary = await this.summarizeWithEscalation({
1033
+ sourceText: concatenated,
1034
+ summarize,
1035
+ options: {
1036
+ previousSummary: previousSummaryContent,
1037
+ isCondensed: false,
1038
+ },
1039
+ });
1040
+
1041
+ // Persist the leaf summary
1042
+ const summaryId = generateSummaryId(summary.content);
1043
+ const tokenCount = estimateTokens(summary.content);
1044
+
1045
+ await this.summaryStore.insertSummary({
1046
+ summaryId,
1047
+ conversationId,
1048
+ kind: "leaf",
1049
+ depth: 0,
1050
+ content: summary.content,
1051
+ tokenCount,
1052
+ fileIds,
1053
+ earliestAt:
1054
+ messageContents.length > 0
1055
+ ? new Date(Math.min(...messageContents.map((message) => message.createdAt.getTime())))
1056
+ : undefined,
1057
+ latestAt:
1058
+ messageContents.length > 0
1059
+ ? new Date(Math.max(...messageContents.map((message) => message.createdAt.getTime())))
1060
+ : undefined,
1061
+ descendantCount: 0,
1062
+ descendantTokenCount: 0,
1063
+ sourceMessageTokenCount: messageContents.reduce(
1064
+ (sum, message) => sum + Math.max(0, Math.floor(message.tokenCount)),
1065
+ 0,
1066
+ ),
1067
+ });
1068
+
1069
+ // Link to source messages
1070
+ const messageIds = messageContents.map((m) => m.messageId);
1071
+ await this.summaryStore.linkSummaryToMessages(summaryId, messageIds);
1072
+
1073
+ // Replace the message range in context with the new summary
1074
+ const ordinals = messageItems.map((ci) => ci.ordinal);
1075
+ const startOrdinal = Math.min(...ordinals);
1076
+ const endOrdinal = Math.max(...ordinals);
1077
+
1078
+ await this.summaryStore.replaceContextRangeWithSummary({
1079
+ conversationId,
1080
+ startOrdinal,
1081
+ endOrdinal,
1082
+ summaryId,
1083
+ });
1084
+
1085
+ return { summaryId, level: summary.level, content: summary.content };
1086
+ }
1087
+
1088
+ // ── Private: Condensed Pass ──────────────────────────────────────────────
1089
+
1090
+ /**
1091
+ * Condense one ratio-sized summary chunk into a single condensed summary.
1092
+ */
1093
+ private async condensedPass(
1094
+ conversationId: number,
1095
+ summaryItems: ContextItemRecord[],
1096
+ targetDepth: number,
1097
+ summarize: CompactionSummarizeFn,
1098
+ ): Promise<PassResult> {
1099
+ // Fetch full summary records
1100
+ const summaryRecords: SummaryRecord[] = [];
1101
+ for (const item of summaryItems) {
1102
+ if (item.summaryId == null) {
1103
+ continue;
1104
+ }
1105
+ const rec = await this.summaryStore.getSummary(item.summaryId);
1106
+ if (rec) {
1107
+ summaryRecords.push(rec);
1108
+ }
1109
+ }
1110
+
1111
+ const concatenated = summaryRecords
1112
+ .map((summary) => {
1113
+ const earliestAt = summary.earliestAt ?? summary.createdAt;
1114
+ const latestAt = summary.latestAt ?? summary.createdAt;
1115
+ const tz = this.config.timezone;
1116
+ const header = `[${formatTimestamp(earliestAt, tz)} - ${formatTimestamp(latestAt, tz)}]`;
1117
+ return `${header}\n${summary.content}`;
1118
+ })
1119
+ .join("\n\n");
1120
+ const fileIds = dedupeOrderedIds(
1121
+ summaryRecords.flatMap((summary) => [
1122
+ ...summary.fileIds,
1123
+ ...extractFileIdsFromContent(summary.content),
1124
+ ]),
1125
+ );
1126
+ const previousSummaryContent =
1127
+ targetDepth === 0
1128
+ ? await this.resolvePriorSummaryContextAtDepth(conversationId, summaryItems, targetDepth)
1129
+ : undefined;
1130
+ const condensed = await this.summarizeWithEscalation({
1131
+ sourceText: concatenated,
1132
+ summarize,
1133
+ options: {
1134
+ previousSummary: previousSummaryContent,
1135
+ isCondensed: true,
1136
+ depth: targetDepth + 1,
1137
+ },
1138
+ });
1139
+
1140
+ // Persist the condensed summary
1141
+ const summaryId = generateSummaryId(condensed.content);
1142
+ const tokenCount = estimateTokens(condensed.content);
1143
+
1144
+ await this.summaryStore.insertSummary({
1145
+ summaryId,
1146
+ conversationId,
1147
+ kind: "condensed",
1148
+ depth: targetDepth + 1,
1149
+ content: condensed.content,
1150
+ tokenCount,
1151
+ fileIds,
1152
+ earliestAt:
1153
+ summaryRecords.length > 0
1154
+ ? new Date(
1155
+ Math.min(
1156
+ ...summaryRecords.map((summary) =>
1157
+ (summary.earliestAt ?? summary.createdAt).getTime(),
1158
+ ),
1159
+ ),
1160
+ )
1161
+ : undefined,
1162
+ latestAt:
1163
+ summaryRecords.length > 0
1164
+ ? new Date(
1165
+ Math.max(
1166
+ ...summaryRecords.map((summary) => (summary.latestAt ?? summary.createdAt).getTime()),
1167
+ ),
1168
+ )
1169
+ : undefined,
1170
+ descendantCount: summaryRecords.reduce((count, summary) => {
1171
+ const childDescendants =
1172
+ typeof summary.descendantCount === "number" && Number.isFinite(summary.descendantCount)
1173
+ ? Math.max(0, Math.floor(summary.descendantCount))
1174
+ : 0;
1175
+ return count + childDescendants + 1;
1176
+ }, 0),
1177
+ descendantTokenCount: summaryRecords.reduce((count, summary) => {
1178
+ const childDescendantTokens =
1179
+ typeof summary.descendantTokenCount === "number" &&
1180
+ Number.isFinite(summary.descendantTokenCount)
1181
+ ? Math.max(0, Math.floor(summary.descendantTokenCount))
1182
+ : 0;
1183
+ return count + Math.max(0, Math.floor(summary.tokenCount)) + childDescendantTokens;
1184
+ }, 0),
1185
+ sourceMessageTokenCount: summaryRecords.reduce((count, summary) => {
1186
+ const sourceTokens =
1187
+ typeof summary.sourceMessageTokenCount === "number" &&
1188
+ Number.isFinite(summary.sourceMessageTokenCount)
1189
+ ? Math.max(0, Math.floor(summary.sourceMessageTokenCount))
1190
+ : 0;
1191
+ return count + sourceTokens;
1192
+ }, 0),
1193
+ });
1194
+
1195
+ // Link to parent summaries
1196
+ const parentSummaryIds = summaryRecords.map((s) => s.summaryId);
1197
+ await this.summaryStore.linkSummaryToParents(summaryId, parentSummaryIds);
1198
+
1199
+ // Replace all summary items in context with the condensed summary
1200
+ const ordinals = summaryItems.map((ci) => ci.ordinal);
1201
+ const startOrdinal = Math.min(...ordinals);
1202
+ const endOrdinal = Math.max(...ordinals);
1203
+
1204
+ await this.summaryStore.replaceContextRangeWithSummary({
1205
+ conversationId,
1206
+ startOrdinal,
1207
+ endOrdinal,
1208
+ summaryId,
1209
+ });
1210
+
1211
+ return { summaryId, level: condensed.level };
1212
+ }
1213
+
1214
+ /**
1215
+ * Persist durable compaction events into canonical history as message parts.
1216
+ *
1217
+ * Event persistence is best-effort: failures are swallowed to avoid
1218
+ * compromising the core compaction path.
1219
+ */
1220
+ private async persistCompactionEvents(input: {
1221
+ conversationId: number;
1222
+ tokensBefore: number;
1223
+ tokensAfterLeaf: number;
1224
+ tokensAfterFinal: number;
1225
+ leafResult: { summaryId: string; level: CompactionLevel } | null;
1226
+ condenseResult: { summaryId: string; level: CompactionLevel } | null;
1227
+ }): Promise<void> {
1228
+ const {
1229
+ conversationId,
1230
+ tokensBefore,
1231
+ tokensAfterLeaf,
1232
+ tokensAfterFinal,
1233
+ leafResult,
1234
+ condenseResult,
1235
+ } = input;
1236
+
1237
+ if (!leafResult && !condenseResult) {
1238
+ return;
1239
+ }
1240
+
1241
+ const conversation = await this.conversationStore.getConversation(conversationId);
1242
+ if (!conversation) {
1243
+ return;
1244
+ }
1245
+
1246
+ const createdSummaryIds = [leafResult?.summaryId, condenseResult?.summaryId].filter(
1247
+ (id): id is string => typeof id === "string" && id.length > 0,
1248
+ );
1249
+ const condensedPassOccurred = condenseResult !== null;
1250
+
1251
+ if (leafResult) {
1252
+ await this.persistCompactionEvent({
1253
+ conversationId,
1254
+ sessionId: conversation.sessionId,
1255
+ pass: "leaf",
1256
+ level: leafResult.level,
1257
+ tokensBefore,
1258
+ tokensAfter: tokensAfterLeaf,
1259
+ createdSummaryId: leafResult.summaryId,
1260
+ createdSummaryIds,
1261
+ condensedPassOccurred,
1262
+ });
1263
+ }
1264
+
1265
+ if (condenseResult) {
1266
+ await this.persistCompactionEvent({
1267
+ conversationId,
1268
+ sessionId: conversation.sessionId,
1269
+ pass: "condensed",
1270
+ level: condenseResult.level,
1271
+ tokensBefore: tokensAfterLeaf,
1272
+ tokensAfter: tokensAfterFinal,
1273
+ createdSummaryId: condenseResult.summaryId,
1274
+ createdSummaryIds,
1275
+ condensedPassOccurred,
1276
+ });
1277
+ }
1278
+ }
1279
+
1280
+ /** Write one compaction event message + part atomically where possible. */
1281
+ private async persistCompactionEvent(input: {
1282
+ conversationId: number;
1283
+ sessionId: string;
1284
+ pass: CompactionPass;
1285
+ level: CompactionLevel;
1286
+ tokensBefore: number;
1287
+ tokensAfter: number;
1288
+ createdSummaryId: string;
1289
+ createdSummaryIds: string[];
1290
+ condensedPassOccurred: boolean;
1291
+ }): Promise<void> {
1292
+ const content = `LCM compaction ${input.pass} pass (${input.level}): ${input.tokensBefore} -> ${input.tokensAfter}`;
1293
+ const metadata = JSON.stringify({
1294
+ conversationId: input.conversationId,
1295
+ pass: input.pass,
1296
+ level: input.level,
1297
+ tokensBefore: input.tokensBefore,
1298
+ tokensAfter: input.tokensAfter,
1299
+ createdSummaryId: input.createdSummaryId,
1300
+ createdSummaryIds: input.createdSummaryIds,
1301
+ condensedPassOccurred: input.condensedPassOccurred,
1302
+ });
1303
+
1304
+ const writeEvent = async (): Promise<void> => {
1305
+ const seq = (await this.conversationStore.getMaxSeq(input.conversationId)) + 1;
1306
+ const eventMessage = await this.conversationStore.createMessage({
1307
+ conversationId: input.conversationId,
1308
+ seq,
1309
+ role: "system",
1310
+ content,
1311
+ tokenCount: estimateTokens(content),
1312
+ });
1313
+
1314
+ const parts: CreateMessagePartInput[] = [
1315
+ {
1316
+ sessionId: input.sessionId,
1317
+ partType: "compaction",
1318
+ ordinal: 0,
1319
+ textContent: content,
1320
+ metadata,
1321
+ },
1322
+ ];
1323
+ await this.conversationStore.createMessageParts(eventMessage.messageId, parts);
1324
+ };
1325
+
1326
+ try {
1327
+ await this.conversationStore.withTransaction(() => writeEvent());
1328
+ } catch {
1329
+ // Compaction should still succeed if event persistence fails.
1330
+ }
1331
+ }
1332
+ }