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