@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,918 @@
1
+ import type { DatabaseSync } from "node:sqlite";
2
+ import { sanitizeFts5Query } from "./fts5-sanitize.js";
3
+ import { buildLikeSearchPlan, createFallbackSnippet } from "./full-text-fallback.js";
4
+
5
+ export type SummaryKind = "leaf" | "condensed";
6
+ export type ContextItemType = "message" | "summary";
7
+
8
+ export type CreateSummaryInput = {
9
+ summaryId: string;
10
+ conversationId: number;
11
+ kind: SummaryKind;
12
+ depth?: number;
13
+ content: string;
14
+ tokenCount: number;
15
+ fileIds?: string[];
16
+ earliestAt?: Date;
17
+ latestAt?: Date;
18
+ descendantCount?: number;
19
+ descendantTokenCount?: number;
20
+ sourceMessageTokenCount?: number;
21
+ };
22
+
23
+ export type SummaryRecord = {
24
+ summaryId: string;
25
+ conversationId: number;
26
+ kind: SummaryKind;
27
+ depth: number;
28
+ content: string;
29
+ tokenCount: number;
30
+ fileIds: string[];
31
+ earliestAt: Date | null;
32
+ latestAt: Date | null;
33
+ descendantCount: number;
34
+ descendantTokenCount: number;
35
+ sourceMessageTokenCount: number;
36
+ createdAt: Date;
37
+ };
38
+
39
+ export type SummarySubtreeNodeRecord = SummaryRecord & {
40
+ depthFromRoot: number;
41
+ parentSummaryId: string | null;
42
+ path: string;
43
+ childCount: number;
44
+ };
45
+
46
+ export type ContextItemRecord = {
47
+ conversationId: number;
48
+ ordinal: number;
49
+ itemType: ContextItemType;
50
+ messageId: number | null;
51
+ summaryId: string | null;
52
+ createdAt: Date;
53
+ };
54
+
55
+ export type SummarySearchInput = {
56
+ conversationId?: number;
57
+ query: string;
58
+ mode: "regex" | "full_text";
59
+ since?: Date;
60
+ before?: Date;
61
+ limit?: number;
62
+ };
63
+
64
+ export type SummarySearchResult = {
65
+ summaryId: string;
66
+ conversationId: number;
67
+ kind: SummaryKind;
68
+ snippet: string;
69
+ createdAt: Date;
70
+ rank?: number;
71
+ };
72
+
73
+ export type CreateLargeFileInput = {
74
+ fileId: string;
75
+ conversationId: number;
76
+ fileName?: string;
77
+ mimeType?: string;
78
+ byteSize?: number;
79
+ storageUri: string;
80
+ explorationSummary?: string;
81
+ };
82
+
83
+ export type LargeFileRecord = {
84
+ fileId: string;
85
+ conversationId: number;
86
+ fileName: string | null;
87
+ mimeType: string | null;
88
+ byteSize: number | null;
89
+ storageUri: string;
90
+ explorationSummary: string | null;
91
+ createdAt: Date;
92
+ };
93
+
94
+ // ── DB row shapes (snake_case) ────────────────────────────────────────────────
95
+
96
+ interface SummaryRow {
97
+ summary_id: string;
98
+ conversation_id: number;
99
+ kind: SummaryKind;
100
+ depth: number;
101
+ content: string;
102
+ token_count: number;
103
+ file_ids: string;
104
+ earliest_at: string | null;
105
+ latest_at: string | null;
106
+ descendant_count: number | null;
107
+ descendant_token_count: number | null;
108
+ source_message_token_count: number | null;
109
+ created_at: string;
110
+ }
111
+
112
+ interface SummarySubtreeRow extends SummaryRow {
113
+ depth_from_root: number;
114
+ parent_summary_id: string | null;
115
+ path: string;
116
+ child_count: number | null;
117
+ }
118
+
119
+ interface ContextItemRow {
120
+ conversation_id: number;
121
+ ordinal: number;
122
+ item_type: ContextItemType;
123
+ message_id: number | null;
124
+ summary_id: string | null;
125
+ created_at: string;
126
+ }
127
+
128
+ interface SummarySearchRow {
129
+ summary_id: string;
130
+ conversation_id: number;
131
+ kind: SummaryKind;
132
+ snippet: string;
133
+ rank: number;
134
+ created_at: string;
135
+ }
136
+
137
+ interface MaxOrdinalRow {
138
+ max_ordinal: number;
139
+ }
140
+
141
+ interface DistinctDepthRow {
142
+ depth: number;
143
+ }
144
+
145
+ interface TokenSumRow {
146
+ total: number;
147
+ }
148
+
149
+ interface MessageIdRow {
150
+ message_id: number;
151
+ }
152
+
153
+ interface LargeFileRow {
154
+ file_id: string;
155
+ conversation_id: number;
156
+ file_name: string | null;
157
+ mime_type: string | null;
158
+ byte_size: number | null;
159
+ storage_uri: string;
160
+ exploration_summary: string | null;
161
+ created_at: string;
162
+ }
163
+
164
+ // ── Row mappers ───────────────────────────────────────────────────────────────
165
+
166
+ function toSummaryRecord(row: SummaryRow): SummaryRecord {
167
+ let fileIds: string[] = [];
168
+ try {
169
+ fileIds = JSON.parse(row.file_ids);
170
+ } catch {
171
+ // ignore malformed JSON
172
+ }
173
+ return {
174
+ summaryId: row.summary_id,
175
+ conversationId: row.conversation_id,
176
+ kind: row.kind,
177
+ depth: row.depth,
178
+ content: row.content,
179
+ tokenCount: row.token_count,
180
+ fileIds,
181
+ earliestAt: row.earliest_at ? new Date(row.earliest_at) : null,
182
+ latestAt: row.latest_at ? new Date(row.latest_at) : null,
183
+ descendantCount:
184
+ typeof row.descendant_count === "number" &&
185
+ Number.isFinite(row.descendant_count) &&
186
+ row.descendant_count >= 0
187
+ ? Math.floor(row.descendant_count)
188
+ : 0,
189
+ descendantTokenCount:
190
+ typeof row.descendant_token_count === "number" &&
191
+ Number.isFinite(row.descendant_token_count) &&
192
+ row.descendant_token_count >= 0
193
+ ? Math.floor(row.descendant_token_count)
194
+ : 0,
195
+ sourceMessageTokenCount:
196
+ typeof row.source_message_token_count === "number" &&
197
+ Number.isFinite(row.source_message_token_count) &&
198
+ row.source_message_token_count >= 0
199
+ ? Math.floor(row.source_message_token_count)
200
+ : 0,
201
+ createdAt: new Date(row.created_at),
202
+ };
203
+ }
204
+
205
+ function toContextItemRecord(row: ContextItemRow): ContextItemRecord {
206
+ return {
207
+ conversationId: row.conversation_id,
208
+ ordinal: row.ordinal,
209
+ itemType: row.item_type,
210
+ messageId: row.message_id,
211
+ summaryId: row.summary_id,
212
+ createdAt: new Date(row.created_at),
213
+ };
214
+ }
215
+
216
+ function toSearchResult(row: SummarySearchRow): SummarySearchResult {
217
+ return {
218
+ summaryId: row.summary_id,
219
+ conversationId: row.conversation_id,
220
+ kind: row.kind,
221
+ snippet: row.snippet,
222
+ createdAt: new Date(row.created_at),
223
+ rank: row.rank,
224
+ };
225
+ }
226
+
227
+ function toLargeFileRecord(row: LargeFileRow): LargeFileRecord {
228
+ return {
229
+ fileId: row.file_id,
230
+ conversationId: row.conversation_id,
231
+ fileName: row.file_name,
232
+ mimeType: row.mime_type,
233
+ byteSize: row.byte_size,
234
+ storageUri: row.storage_uri,
235
+ explorationSummary: row.exploration_summary,
236
+ createdAt: new Date(row.created_at),
237
+ };
238
+ }
239
+
240
+ // ── SummaryStore ──────────────────────────────────────────────────────────────
241
+
242
+ export class SummaryStore {
243
+ private readonly fts5Available: boolean;
244
+
245
+ constructor(
246
+ private db: DatabaseSync,
247
+ options?: { fts5Available?: boolean },
248
+ ) {
249
+ this.fts5Available = options?.fts5Available ?? true;
250
+ }
251
+
252
+ // ── Summary CRUD ──────────────────────────────────────────────────────────
253
+
254
+ async insertSummary(input: CreateSummaryInput): Promise<SummaryRecord> {
255
+ const fileIds = JSON.stringify(input.fileIds ?? []);
256
+ const earliestAt = input.earliestAt instanceof Date ? input.earliestAt.toISOString() : null;
257
+ const latestAt = input.latestAt instanceof Date ? input.latestAt.toISOString() : null;
258
+ const descendantCount =
259
+ typeof input.descendantCount === "number" &&
260
+ Number.isFinite(input.descendantCount) &&
261
+ input.descendantCount >= 0
262
+ ? Math.floor(input.descendantCount)
263
+ : 0;
264
+ const descendantTokenCount =
265
+ typeof input.descendantTokenCount === "number" &&
266
+ Number.isFinite(input.descendantTokenCount) &&
267
+ input.descendantTokenCount >= 0
268
+ ? Math.floor(input.descendantTokenCount)
269
+ : 0;
270
+ const sourceMessageTokenCount =
271
+ typeof input.sourceMessageTokenCount === "number" &&
272
+ Number.isFinite(input.sourceMessageTokenCount) &&
273
+ input.sourceMessageTokenCount >= 0
274
+ ? Math.floor(input.sourceMessageTokenCount)
275
+ : 0;
276
+ const depth =
277
+ typeof input.depth === "number" && Number.isFinite(input.depth) && input.depth >= 0
278
+ ? Math.floor(input.depth)
279
+ : input.kind === "leaf"
280
+ ? 0
281
+ : 1;
282
+
283
+ this.db
284
+ .prepare(
285
+ `INSERT INTO summaries (
286
+ summary_id,
287
+ conversation_id,
288
+ kind,
289
+ depth,
290
+ content,
291
+ token_count,
292
+ file_ids,
293
+ earliest_at,
294
+ latest_at,
295
+ descendant_count,
296
+ descendant_token_count,
297
+ source_message_token_count
298
+ )
299
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
300
+ )
301
+ .run(
302
+ input.summaryId,
303
+ input.conversationId,
304
+ input.kind,
305
+ depth,
306
+ input.content,
307
+ input.tokenCount,
308
+ fileIds,
309
+ earliestAt,
310
+ latestAt,
311
+ descendantCount,
312
+ descendantTokenCount,
313
+ sourceMessageTokenCount,
314
+ );
315
+
316
+ const row = this.db
317
+ .prepare(
318
+ `SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
319
+ earliest_at, latest_at, descendant_count, created_at
320
+ , descendant_token_count, source_message_token_count
321
+ FROM summaries WHERE summary_id = ?`,
322
+ )
323
+ .get(input.summaryId) as unknown as SummaryRow;
324
+
325
+ // Index in FTS5 as best-effort; compaction flow must continue even if
326
+ // FTS indexing fails for any reason.
327
+ if (!this.fts5Available) {
328
+ return toSummaryRecord(row);
329
+ }
330
+
331
+ try {
332
+ this.db
333
+ .prepare(`INSERT INTO summaries_fts(summary_id, content) VALUES (?, ?)`)
334
+ .run(input.summaryId, input.content);
335
+ } catch {
336
+ // FTS indexing failed — search won't find this summary but
337
+ // compaction and assembly will still work correctly.
338
+ }
339
+
340
+ return toSummaryRecord(row);
341
+ }
342
+
343
+ async getSummary(summaryId: string): Promise<SummaryRecord | null> {
344
+ const row = this.db
345
+ .prepare(
346
+ `SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
347
+ earliest_at, latest_at, descendant_count, created_at
348
+ , descendant_token_count, source_message_token_count
349
+ FROM summaries WHERE summary_id = ?`,
350
+ )
351
+ .get(summaryId) as unknown as SummaryRow | undefined;
352
+ return row ? toSummaryRecord(row) : null;
353
+ }
354
+
355
+ async getSummariesByConversation(conversationId: number): Promise<SummaryRecord[]> {
356
+ const rows = this.db
357
+ .prepare(
358
+ `SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
359
+ earliest_at, latest_at, descendant_count, created_at
360
+ , descendant_token_count, source_message_token_count
361
+ FROM summaries
362
+ WHERE conversation_id = ?
363
+ ORDER BY created_at`,
364
+ )
365
+ .all(conversationId) as unknown as SummaryRow[];
366
+ return rows.map(toSummaryRecord);
367
+ }
368
+
369
+ // ── Lineage ───────────────────────────────────────────────────────────────
370
+
371
+ async linkSummaryToMessages(summaryId: string, messageIds: number[]): Promise<void> {
372
+ if (messageIds.length === 0) {
373
+ return;
374
+ }
375
+
376
+ const stmt = this.db.prepare(
377
+ `INSERT INTO summary_messages (summary_id, message_id, ordinal)
378
+ VALUES (?, ?, ?)
379
+ ON CONFLICT (summary_id, message_id) DO NOTHING`,
380
+ );
381
+
382
+ for (let idx = 0; idx < messageIds.length; idx++) {
383
+ stmt.run(summaryId, messageIds[idx], idx);
384
+ }
385
+ }
386
+
387
+ async linkSummaryToParents(summaryId: string, parentSummaryIds: string[]): Promise<void> {
388
+ if (parentSummaryIds.length === 0) {
389
+ return;
390
+ }
391
+
392
+ const stmt = this.db.prepare(
393
+ `INSERT INTO summary_parents (summary_id, parent_summary_id, ordinal)
394
+ VALUES (?, ?, ?)
395
+ ON CONFLICT (summary_id, parent_summary_id) DO NOTHING`,
396
+ );
397
+
398
+ for (let idx = 0; idx < parentSummaryIds.length; idx++) {
399
+ stmt.run(summaryId, parentSummaryIds[idx], idx);
400
+ }
401
+ }
402
+
403
+ async getSummaryMessages(summaryId: string): Promise<number[]> {
404
+ const rows = this.db
405
+ .prepare(
406
+ `SELECT message_id FROM summary_messages
407
+ WHERE summary_id = ?
408
+ ORDER BY ordinal`,
409
+ )
410
+ .all(summaryId) as unknown as MessageIdRow[];
411
+ return rows.map((r) => r.message_id);
412
+ }
413
+
414
+ async getSummaryChildren(parentSummaryId: string): Promise<SummaryRecord[]> {
415
+ const rows = this.db
416
+ .prepare(
417
+ `SELECT s.summary_id, s.conversation_id, s.kind, s.depth, s.content, s.token_count,
418
+ s.file_ids, s.earliest_at, s.latest_at, s.descendant_count, s.created_at
419
+ , s.descendant_token_count, s.source_message_token_count
420
+ FROM summaries s
421
+ JOIN summary_parents sp ON sp.summary_id = s.summary_id
422
+ WHERE sp.parent_summary_id = ?
423
+ ORDER BY sp.ordinal`,
424
+ )
425
+ .all(parentSummaryId) as unknown as SummaryRow[];
426
+ return rows.map(toSummaryRecord);
427
+ }
428
+
429
+ async getSummaryParents(summaryId: string): Promise<SummaryRecord[]> {
430
+ const rows = this.db
431
+ .prepare(
432
+ `SELECT s.summary_id, s.conversation_id, s.kind, s.depth, s.content, s.token_count,
433
+ s.file_ids, s.earliest_at, s.latest_at, s.descendant_count, s.created_at
434
+ , s.descendant_token_count, s.source_message_token_count
435
+ FROM summaries s
436
+ JOIN summary_parents sp ON sp.parent_summary_id = s.summary_id
437
+ WHERE sp.summary_id = ?
438
+ ORDER BY sp.ordinal`,
439
+ )
440
+ .all(summaryId) as unknown as SummaryRow[];
441
+ return rows.map(toSummaryRecord);
442
+ }
443
+
444
+ async getSummarySubtree(summaryId: string): Promise<SummarySubtreeNodeRecord[]> {
445
+ const rows = this.db
446
+ .prepare(
447
+ `WITH RECURSIVE subtree(summary_id, parent_summary_id, depth_from_root, path) AS (
448
+ SELECT ?, NULL, 0, ''
449
+ UNION ALL
450
+ SELECT
451
+ sp.summary_id,
452
+ sp.parent_summary_id,
453
+ subtree.depth_from_root + 1,
454
+ CASE
455
+ WHEN subtree.path = '' THEN printf('%04d', sp.ordinal)
456
+ ELSE subtree.path || '.' || printf('%04d', sp.ordinal)
457
+ END
458
+ FROM summary_parents sp
459
+ JOIN subtree ON sp.parent_summary_id = subtree.summary_id
460
+ )
461
+ SELECT
462
+ s.summary_id,
463
+ s.conversation_id,
464
+ s.kind,
465
+ s.depth,
466
+ s.content,
467
+ s.token_count,
468
+ s.file_ids,
469
+ s.earliest_at,
470
+ s.latest_at,
471
+ s.descendant_count,
472
+ s.descendant_token_count,
473
+ s.source_message_token_count,
474
+ s.created_at,
475
+ subtree.depth_from_root,
476
+ subtree.parent_summary_id,
477
+ subtree.path,
478
+ (
479
+ SELECT COUNT(*) FROM summary_parents sp2
480
+ WHERE sp2.parent_summary_id = s.summary_id
481
+ ) AS child_count
482
+ FROM subtree
483
+ JOIN summaries s ON s.summary_id = subtree.summary_id
484
+ ORDER BY subtree.depth_from_root ASC, subtree.path ASC, s.created_at ASC`,
485
+ )
486
+ .all(summaryId) as unknown as SummarySubtreeRow[];
487
+
488
+ const seen = new Set<string>();
489
+ const output: SummarySubtreeNodeRecord[] = [];
490
+ for (const row of rows) {
491
+ if (seen.has(row.summary_id)) {
492
+ continue;
493
+ }
494
+ seen.add(row.summary_id);
495
+ output.push({
496
+ ...toSummaryRecord(row),
497
+ depthFromRoot: Math.max(0, Math.floor(row.depth_from_root ?? 0)),
498
+ parentSummaryId: row.parent_summary_id ?? null,
499
+ path: typeof row.path === "string" ? row.path : "",
500
+ childCount:
501
+ typeof row.child_count === "number" && Number.isFinite(row.child_count)
502
+ ? Math.max(0, Math.floor(row.child_count))
503
+ : 0,
504
+ });
505
+ }
506
+ return output;
507
+ }
508
+
509
+ // ── Context items ─────────────────────────────────────────────────────────
510
+
511
+ async getContextItems(conversationId: number): Promise<ContextItemRecord[]> {
512
+ const rows = this.db
513
+ .prepare(
514
+ `SELECT conversation_id, ordinal, item_type, message_id, summary_id, created_at
515
+ FROM context_items
516
+ WHERE conversation_id = ?
517
+ ORDER BY ordinal`,
518
+ )
519
+ .all(conversationId) as unknown as ContextItemRow[];
520
+ return rows.map(toContextItemRecord);
521
+ }
522
+
523
+ async getDistinctDepthsInContext(
524
+ conversationId: number,
525
+ options?: { maxOrdinalExclusive?: number },
526
+ ): Promise<number[]> {
527
+ const maxOrdinalExclusive = options?.maxOrdinalExclusive;
528
+ const useOrdinalBound =
529
+ typeof maxOrdinalExclusive === "number" &&
530
+ Number.isFinite(maxOrdinalExclusive) &&
531
+ maxOrdinalExclusive !== Infinity;
532
+
533
+ const sql = useOrdinalBound
534
+ ? `SELECT DISTINCT s.depth
535
+ FROM context_items ci
536
+ JOIN summaries s ON s.summary_id = ci.summary_id
537
+ WHERE ci.conversation_id = ?
538
+ AND ci.item_type = 'summary'
539
+ AND ci.ordinal < ?
540
+ ORDER BY s.depth ASC`
541
+ : `SELECT DISTINCT s.depth
542
+ FROM context_items ci
543
+ JOIN summaries s ON s.summary_id = ci.summary_id
544
+ WHERE ci.conversation_id = ?
545
+ AND ci.item_type = 'summary'
546
+ ORDER BY s.depth ASC`;
547
+
548
+ const rows = useOrdinalBound
549
+ ? (this.db
550
+ .prepare(sql)
551
+ .all(conversationId, Math.floor(maxOrdinalExclusive)) as unknown as DistinctDepthRow[])
552
+ : (this.db.prepare(sql).all(conversationId) as unknown as DistinctDepthRow[]);
553
+
554
+ return rows.map((row) => row.depth);
555
+ }
556
+
557
+ async appendContextMessage(conversationId: number, messageId: number): Promise<void> {
558
+ const row = this.db
559
+ .prepare(
560
+ `SELECT COALESCE(MAX(ordinal), -1) AS max_ordinal
561
+ FROM context_items WHERE conversation_id = ?`,
562
+ )
563
+ .get(conversationId) as unknown as MaxOrdinalRow;
564
+
565
+ this.db
566
+ .prepare(
567
+ `INSERT INTO context_items (conversation_id, ordinal, item_type, message_id)
568
+ VALUES (?, ?, 'message', ?)`,
569
+ )
570
+ .run(conversationId, row.max_ordinal + 1, messageId);
571
+ }
572
+
573
+ async appendContextMessages(conversationId: number, messageIds: number[]): Promise<void> {
574
+ if (messageIds.length === 0) {
575
+ return;
576
+ }
577
+
578
+ const row = this.db
579
+ .prepare(
580
+ `SELECT COALESCE(MAX(ordinal), -1) AS max_ordinal
581
+ FROM context_items WHERE conversation_id = ?`,
582
+ )
583
+ .get(conversationId) as unknown as MaxOrdinalRow;
584
+ const baseOrdinal = row.max_ordinal + 1;
585
+
586
+ const stmt = this.db.prepare(
587
+ `INSERT INTO context_items (conversation_id, ordinal, item_type, message_id)
588
+ VALUES (?, ?, 'message', ?)`,
589
+ );
590
+ for (let idx = 0; idx < messageIds.length; idx++) {
591
+ stmt.run(conversationId, baseOrdinal + idx, messageIds[idx]);
592
+ }
593
+ }
594
+
595
+ async appendContextSummary(conversationId: number, summaryId: string): Promise<void> {
596
+ const row = this.db
597
+ .prepare(
598
+ `SELECT COALESCE(MAX(ordinal), -1) AS max_ordinal
599
+ FROM context_items WHERE conversation_id = ?`,
600
+ )
601
+ .get(conversationId) as unknown as MaxOrdinalRow;
602
+
603
+ this.db
604
+ .prepare(
605
+ `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id)
606
+ VALUES (?, ?, 'summary', ?)`,
607
+ )
608
+ .run(conversationId, row.max_ordinal + 1, summaryId);
609
+ }
610
+
611
+ async replaceContextRangeWithSummary(input: {
612
+ conversationId: number;
613
+ startOrdinal: number;
614
+ endOrdinal: number;
615
+ summaryId: string;
616
+ }): Promise<void> {
617
+ const { conversationId, startOrdinal, endOrdinal, summaryId } = input;
618
+
619
+ this.db.exec("BEGIN");
620
+ try {
621
+ // 1. Delete context items in the range [startOrdinal, endOrdinal]
622
+ this.db
623
+ .prepare(
624
+ `DELETE FROM context_items
625
+ WHERE conversation_id = ?
626
+ AND ordinal >= ?
627
+ AND ordinal <= ?`,
628
+ )
629
+ .run(conversationId, startOrdinal, endOrdinal);
630
+
631
+ // 2. Insert the replacement summary item at startOrdinal
632
+ this.db
633
+ .prepare(
634
+ `INSERT INTO context_items (conversation_id, ordinal, item_type, summary_id)
635
+ VALUES (?, ?, 'summary', ?)`,
636
+ )
637
+ .run(conversationId, startOrdinal, summaryId);
638
+
639
+ // 3. Resequence all ordinals to maintain contiguity (no gaps).
640
+ // Fetch current items, then update ordinals in order.
641
+ const items = this.db
642
+ .prepare(
643
+ `SELECT ordinal FROM context_items
644
+ WHERE conversation_id = ?
645
+ ORDER BY ordinal`,
646
+ )
647
+ .all(conversationId) as unknown as { ordinal: number }[];
648
+
649
+ const updateStmt = this.db.prepare(
650
+ `UPDATE context_items
651
+ SET ordinal = ?
652
+ WHERE conversation_id = ? AND ordinal = ?`,
653
+ );
654
+
655
+ // Use negative temp ordinals first to avoid unique constraint conflicts
656
+ for (let i = 0; i < items.length; i++) {
657
+ updateStmt.run(-(i + 1), conversationId, items[i].ordinal);
658
+ }
659
+ for (let i = 0; i < items.length; i++) {
660
+ updateStmt.run(i, conversationId, -(i + 1));
661
+ }
662
+
663
+ this.db.exec("COMMIT");
664
+ } catch (err) {
665
+ this.db.exec("ROLLBACK");
666
+ throw err;
667
+ }
668
+ }
669
+
670
+ async getContextTokenCount(conversationId: number): Promise<number> {
671
+ const row = this.db
672
+ .prepare(
673
+ `SELECT COALESCE(SUM(token_count), 0) AS total
674
+ FROM (
675
+ SELECT m.token_count
676
+ FROM context_items ci
677
+ JOIN messages m ON m.message_id = ci.message_id
678
+ WHERE ci.conversation_id = ?
679
+ AND ci.item_type = 'message'
680
+
681
+ UNION ALL
682
+
683
+ SELECT s.token_count
684
+ FROM context_items ci
685
+ JOIN summaries s ON s.summary_id = ci.summary_id
686
+ WHERE ci.conversation_id = ?
687
+ AND ci.item_type = 'summary'
688
+ ) sub`,
689
+ )
690
+ .get(conversationId, conversationId) as unknown as TokenSumRow;
691
+ return row?.total ?? 0;
692
+ }
693
+
694
+ // ── Search ────────────────────────────────────────────────────────────────
695
+
696
+ async searchSummaries(input: SummarySearchInput): Promise<SummarySearchResult[]> {
697
+ const limit = input.limit ?? 50;
698
+
699
+ if (input.mode === "full_text") {
700
+ if (this.fts5Available) {
701
+ try {
702
+ return this.searchFullText(
703
+ input.query,
704
+ limit,
705
+ input.conversationId,
706
+ input.since,
707
+ input.before,
708
+ );
709
+ } catch {
710
+ return this.searchLike(
711
+ input.query,
712
+ limit,
713
+ input.conversationId,
714
+ input.since,
715
+ input.before,
716
+ );
717
+ }
718
+ }
719
+ return this.searchLike(input.query, limit, input.conversationId, input.since, input.before);
720
+ }
721
+ return this.searchRegex(input.query, limit, input.conversationId, input.since, input.before);
722
+ }
723
+
724
+ private searchFullText(
725
+ query: string,
726
+ limit: number,
727
+ conversationId?: number,
728
+ since?: Date,
729
+ before?: Date,
730
+ ): SummarySearchResult[] {
731
+ const where: string[] = ["summaries_fts MATCH ?"];
732
+ const args: Array<string | number> = [sanitizeFts5Query(query)];
733
+ if (conversationId != null) {
734
+ where.push("s.conversation_id = ?");
735
+ args.push(conversationId);
736
+ }
737
+ if (since) {
738
+ where.push("julianday(s.created_at) >= julianday(?)");
739
+ args.push(since.toISOString());
740
+ }
741
+ if (before) {
742
+ where.push("julianday(s.created_at) < julianday(?)");
743
+ args.push(before.toISOString());
744
+ }
745
+ args.push(limit);
746
+
747
+ const sql = `SELECT
748
+ summaries_fts.summary_id,
749
+ s.conversation_id,
750
+ s.kind,
751
+ snippet(summaries_fts, 1, '', '', '...', 32) AS snippet,
752
+ rank,
753
+ s.created_at
754
+ FROM summaries_fts
755
+ JOIN summaries s ON s.summary_id = summaries_fts.summary_id
756
+ WHERE ${where.join(" AND ")}
757
+ ORDER BY s.created_at DESC
758
+ LIMIT ?`;
759
+ const rows = this.db.prepare(sql).all(...args) as unknown as SummarySearchRow[];
760
+ return rows.map(toSearchResult);
761
+ }
762
+
763
+ private searchLike(
764
+ query: string,
765
+ limit: number,
766
+ conversationId?: number,
767
+ since?: Date,
768
+ before?: Date,
769
+ ): SummarySearchResult[] {
770
+ const plan = buildLikeSearchPlan("content", query);
771
+ if (plan.terms.length === 0) {
772
+ return [];
773
+ }
774
+
775
+ const where: string[] = [...plan.where];
776
+ const args: Array<string | number> = [...plan.args];
777
+ if (conversationId != null) {
778
+ where.push("conversation_id = ?");
779
+ args.push(conversationId);
780
+ }
781
+ if (since) {
782
+ where.push("julianday(created_at) >= julianday(?)");
783
+ args.push(since.toISOString());
784
+ }
785
+ if (before) {
786
+ where.push("julianday(created_at) < julianday(?)");
787
+ args.push(before.toISOString());
788
+ }
789
+ args.push(limit);
790
+
791
+ const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
792
+ const rows = this.db
793
+ .prepare(
794
+ `SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
795
+ earliest_at, latest_at, descendant_count, descendant_token_count,
796
+ source_message_token_count, created_at
797
+ FROM summaries
798
+ ${whereClause}
799
+ ORDER BY created_at DESC
800
+ LIMIT ?`,
801
+ )
802
+ .all(...args) as unknown as SummaryRow[];
803
+
804
+ return rows.map((row) => ({
805
+ summaryId: row.summary_id,
806
+ conversationId: row.conversation_id,
807
+ kind: row.kind,
808
+ snippet: createFallbackSnippet(row.content, plan.terms),
809
+ createdAt: new Date(row.created_at),
810
+ rank: 0,
811
+ }));
812
+ }
813
+
814
+ private searchRegex(
815
+ pattern: string,
816
+ limit: number,
817
+ conversationId?: number,
818
+ since?: Date,
819
+ before?: Date,
820
+ ): SummarySearchResult[] {
821
+ const re = new RegExp(pattern);
822
+
823
+ const where: string[] = [];
824
+ const args: Array<string | number> = [];
825
+ if (conversationId != null) {
826
+ where.push("conversation_id = ?");
827
+ args.push(conversationId);
828
+ }
829
+ if (since) {
830
+ where.push("julianday(created_at) >= julianday(?)");
831
+ args.push(since.toISOString());
832
+ }
833
+ if (before) {
834
+ where.push("julianday(created_at) < julianday(?)");
835
+ args.push(before.toISOString());
836
+ }
837
+ const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
838
+ const rows = this.db
839
+ .prepare(
840
+ `SELECT summary_id, conversation_id, kind, depth, content, token_count, file_ids,
841
+ earliest_at, latest_at, descendant_count, descendant_token_count,
842
+ source_message_token_count, created_at
843
+ FROM summaries
844
+ ${whereClause}
845
+ ORDER BY created_at DESC`,
846
+ )
847
+ .all(...args) as unknown as SummaryRow[];
848
+
849
+ const results: SummarySearchResult[] = [];
850
+ for (const row of rows) {
851
+ if (results.length >= limit) {
852
+ break;
853
+ }
854
+ const match = re.exec(row.content);
855
+ if (match) {
856
+ results.push({
857
+ summaryId: row.summary_id,
858
+ conversationId: row.conversation_id,
859
+ kind: row.kind,
860
+ snippet: match[0],
861
+ createdAt: new Date(row.created_at),
862
+ rank: 0,
863
+ });
864
+ }
865
+ }
866
+ return results;
867
+ }
868
+
869
+ // ── Large files ───────────────────────────────────────────────────────────
870
+
871
+ async insertLargeFile(input: CreateLargeFileInput): Promise<LargeFileRecord> {
872
+ this.db
873
+ .prepare(
874
+ `INSERT INTO large_files (file_id, conversation_id, file_name, mime_type, byte_size, storage_uri, exploration_summary)
875
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
876
+ )
877
+ .run(
878
+ input.fileId,
879
+ input.conversationId,
880
+ input.fileName ?? null,
881
+ input.mimeType ?? null,
882
+ input.byteSize ?? null,
883
+ input.storageUri,
884
+ input.explorationSummary ?? null,
885
+ );
886
+
887
+ const row = this.db
888
+ .prepare(
889
+ `SELECT file_id, conversation_id, file_name, mime_type, byte_size, storage_uri, exploration_summary, created_at
890
+ FROM large_files WHERE file_id = ?`,
891
+ )
892
+ .get(input.fileId) as unknown as LargeFileRow;
893
+
894
+ return toLargeFileRecord(row);
895
+ }
896
+
897
+ async getLargeFile(fileId: string): Promise<LargeFileRecord | null> {
898
+ const row = this.db
899
+ .prepare(
900
+ `SELECT file_id, conversation_id, file_name, mime_type, byte_size, storage_uri, exploration_summary, created_at
901
+ FROM large_files WHERE file_id = ?`,
902
+ )
903
+ .get(fileId) as unknown as LargeFileRow | undefined;
904
+ return row ? toLargeFileRecord(row) : null;
905
+ }
906
+
907
+ async getLargeFilesByConversation(conversationId: number): Promise<LargeFileRecord[]> {
908
+ const rows = this.db
909
+ .prepare(
910
+ `SELECT file_id, conversation_id, file_name, mime_type, byte_size, storage_uri, exploration_summary, created_at
911
+ FROM large_files
912
+ WHERE conversation_id = ?
913
+ ORDER BY created_at`,
914
+ )
915
+ .all(conversationId) as unknown as LargeFileRow[];
916
+ return rows.map(toLargeFileRecord);
917
+ }
918
+ }