@aeriondyseti/vector-memory-mcp 2.4.4 → 2.5.0-dev.2

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,815 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { createHash, randomUUID } from "crypto";
3
+ import {
4
+ copyFileSync,
5
+ existsSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ renameSync,
9
+ statSync,
10
+ } from "fs";
11
+ import { readFile } from "fs/promises";
12
+ import { homedir } from "os";
13
+ import { dirname, join, resolve } from "path";
14
+ import { fileURLToPath } from "url";
15
+ import type { EmbeddingsService } from "./embeddings.service";
16
+ import { normalizeProject } from "./project";
17
+ import { safeParseJsonObject, serializeVector } from "./sqlite-utils";
18
+
19
+ const UUID_ZERO = "00000000-0000-0000-0000-000000000000";
20
+
21
+ export interface ConsolidationOptions {
22
+ /** Directory to scan for a .vector-memory/memories.db (default: cwd). */
23
+ root: string;
24
+ /** Walk the tree under root and consolidate every repo-local db found. */
25
+ recursive: boolean;
26
+ /** Plan everything (including re-keys) but write nothing. */
27
+ dryRun: boolean;
28
+ /** Rename .vector-memory/ to .vector-memory.migrated/ after success. */
29
+ archive: boolean;
30
+ /** Proceed even if live servers appear to be using the databases. */
31
+ force: boolean;
32
+ }
33
+
34
+ export interface SourceReport {
35
+ sourceDb: string;
36
+ project: string;
37
+ memoriesImported: number;
38
+ memoriesSkipped: number;
39
+ memoriesRekeyed: number;
40
+ conversationsImported: number;
41
+ conversationsSkipped: number;
42
+ indexStateImported: number;
43
+ rekeyMap: Record<string, string>;
44
+ unresolvedReferences: string[];
45
+ errors: string[];
46
+ }
47
+
48
+ export interface ConsolidationSummary {
49
+ targetDb: string;
50
+ backupPath: string | null;
51
+ importBatch: string;
52
+ dryRun: boolean;
53
+ sources: SourceReport[];
54
+ }
55
+
56
+ interface SourceMemoryRow {
57
+ id: string;
58
+ content: string;
59
+ metadata: string;
60
+ created_at: number;
61
+ updated_at: number;
62
+ superseded_by: string | null;
63
+ usefulness: number;
64
+ access_count: number;
65
+ last_accessed: number | null;
66
+ vector: Buffer | null;
67
+ }
68
+
69
+ interface SourceConversationRow {
70
+ id: string;
71
+ content: string;
72
+ metadata: string;
73
+ created_at: number;
74
+ session_id: string;
75
+ role: string;
76
+ message_index_start: number;
77
+ message_index_end: number;
78
+ project: string;
79
+ vector: Buffer | null;
80
+ }
81
+
82
+ /**
83
+ * LanceDB-era repo stores used `.vector-memory/memories.db` as a *directory*.
84
+ * Extraction shells out because @lancedb/lancedb's native bindings cannot
85
+ * coexist with bun:sqlite in one process.
86
+ */
87
+ function isLanceDir(entries: string[]): boolean {
88
+ return entries.some(
89
+ (e) => e.endsWith(".lance") || e === "_versions" || e === "_indices",
90
+ );
91
+ }
92
+
93
+ async function extractLanceData(path: string): Promise<{
94
+ memories: Array<Omit<SourceMemoryRow, "vector"> & { vector: number[] }>;
95
+ conversations: Array<
96
+ Omit<SourceConversationRow, "vector"> & { vector: number[] }
97
+ >;
98
+ }> {
99
+ const script = resolve(
100
+ dirname(fileURLToPath(import.meta.url)),
101
+ "..",
102
+ "..",
103
+ "scripts",
104
+ "lancedb-extract.ts",
105
+ );
106
+ if (!existsSync(script)) {
107
+ throw new Error(`LanceDB extract script not found at ${script}`);
108
+ }
109
+ const proc = Bun.spawn([process.execPath, script, path], {
110
+ stdout: "pipe",
111
+ stderr: "inherit",
112
+ });
113
+ const output = await new Response(proc.stdout).text();
114
+ const exitCode = await proc.exited;
115
+ if (exitCode !== 0) {
116
+ throw new Error(`LanceDB extraction failed (exit code ${exitCode})`);
117
+ }
118
+ return JSON.parse(output);
119
+ }
120
+
121
+ /**
122
+ * Old schema versions stored vectors in vec0 virtual tables, which need the
123
+ * sqlite-vec extension to query. Sources are opened read-only without it, so
124
+ * treat those vectors as unreadable — rows are re-embedded on import.
125
+ */
126
+ function vecTableReadable(db: Database, name: string): boolean {
127
+ const row = db
128
+ .prepare("SELECT sql FROM sqlite_master WHERE name = ?")
129
+ .get(name) as { sql: string | null } | null;
130
+ return row != null && !(row.sql ?? "").includes("vec0");
131
+ }
132
+
133
+ /** Mirrors MemoryService.waypointId — must stay byte-identical. */
134
+ function waypointIdFor(project: string): string {
135
+ const normalized = project.trim().toLowerCase();
136
+ const hex = createHash("sha256")
137
+ .update(`waypoint:${normalized}`)
138
+ .digest("hex");
139
+ return `wp:${hex.slice(0, 32)}`;
140
+ }
141
+
142
+ function isWaypointRow(row: SourceMemoryRow): boolean {
143
+ if (row.id === UUID_ZERO || row.id.startsWith("wp:")) return true;
144
+ const metadata = safeParseJsonObject(row.metadata);
145
+ return metadata.type === "waypoint";
146
+ }
147
+
148
+ /** Find repo-local databases: <repo>/.vector-memory/memories.db */
149
+ export function discoverSourceDbs(root: string, recursive: boolean): string[] {
150
+ const found: string[] = [];
151
+ const direct = join(root, ".vector-memory", "memories.db");
152
+ if (existsSync(direct)) found.push(direct);
153
+ if (!recursive) return found;
154
+
155
+ const SKIP_DIRS = new Set([
156
+ "node_modules",
157
+ ".git",
158
+ ".vector-memory",
159
+ ".vector-memory.migrated",
160
+ ".cache",
161
+ ]);
162
+
163
+ const walk = (dir: string, depth: number): void => {
164
+ if (depth > 8) return;
165
+ let entries;
166
+ try {
167
+ entries = readdirSync(dir, { withFileTypes: true });
168
+ } catch {
169
+ return;
170
+ }
171
+ for (const entry of entries) {
172
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
173
+ if (entry.name.startsWith(".") && entry.name !== ".vector-memory") continue;
174
+ const sub = join(dir, entry.name);
175
+ const candidate = join(sub, ".vector-memory", "memories.db");
176
+ if (existsSync(candidate)) found.push(candidate);
177
+ walk(sub, depth + 1);
178
+ }
179
+ };
180
+ walk(root, 0);
181
+ return found;
182
+ }
183
+
184
+ /** Lock files (global + per-repo) whose recorded pid is still alive. */
185
+ export function findLiveServerLocks(sourceDirs: string[]): string[] {
186
+ const lockPaths: string[] = [];
187
+ const globalLocksDir = join(homedir(), ".vector-memory", "locks");
188
+ try {
189
+ for (const name of readdirSync(globalLocksDir)) {
190
+ if (name.endsWith(".lock")) lockPaths.push(join(globalLocksDir, name));
191
+ }
192
+ } catch {
193
+ // no locks dir — fine
194
+ }
195
+ for (const dir of sourceDirs) {
196
+ lockPaths.push(join(dir, "server.lock"));
197
+ }
198
+
199
+ const live: string[] = [];
200
+ for (const path of lockPaths) {
201
+ try {
202
+ const raw = JSON.parse(readFileSync(path, "utf8")) as { pid?: number };
203
+ if (typeof raw.pid !== "number") continue;
204
+ process.kill(raw.pid, 0);
205
+ live.push(path);
206
+ } catch {
207
+ // missing, unreadable, or dead pid — not live
208
+ }
209
+ }
210
+ return live;
211
+ }
212
+
213
+ export class ConsolidationService {
214
+ constructor(
215
+ private target: Database,
216
+ private targetDbPath: string,
217
+ private embeddings: EmbeddingsService,
218
+ ) {}
219
+
220
+ async consolidate(options: ConsolidationOptions): Promise<ConsolidationSummary> {
221
+ const sources = discoverSourceDbs(options.root, options.recursive);
222
+ const importBatch = randomUUID();
223
+
224
+ const summary: ConsolidationSummary = {
225
+ targetDb: this.targetDbPath,
226
+ backupPath: null,
227
+ importBatch,
228
+ dryRun: options.dryRun,
229
+ sources: [],
230
+ };
231
+
232
+ if (sources.length === 0) return summary;
233
+
234
+ // Refuse to run against live servers unless forced — a live server's
235
+ // waypoint write racing the import could be clobbered.
236
+ if (!options.force) {
237
+ const live = findLiveServerLocks(sources.map((s) => dirname(s)));
238
+ if (live.length > 0) {
239
+ throw new Error(
240
+ `Live vector-memory servers detected (${live.join(", ")}). ` +
241
+ `Close those sessions first, or re-run with --force.`,
242
+ );
243
+ }
244
+ }
245
+
246
+ // Backup the global db before the first write
247
+ if (!options.dryRun && existsSync(this.targetDbPath)) {
248
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
249
+ summary.backupPath = `${this.targetDbPath}.pre-consolidate-${stamp}`;
250
+ copyFileSync(this.targetDbPath, summary.backupPath);
251
+ }
252
+
253
+ for (const sourceDb of sources) {
254
+ const report = await this.consolidateOne(sourceDb, importBatch, options);
255
+ summary.sources.push(report);
256
+
257
+ if (!options.dryRun && options.archive && report.errors.length === 0) {
258
+ const dir = dirname(sourceDb);
259
+ try {
260
+ renameSync(dir, `${dir}.migrated`);
261
+ } catch (e) {
262
+ report.errors.push(
263
+ `archive failed: ${e instanceof Error ? e.message : String(e)}`,
264
+ );
265
+ }
266
+ }
267
+ }
268
+
269
+ return summary;
270
+ }
271
+
272
+ private async consolidateOne(
273
+ sourceDbPath: string,
274
+ importBatch: string,
275
+ options: ConsolidationOptions,
276
+ ): Promise<SourceReport> {
277
+ // <repo>/.vector-memory/memories.db → project = <repo>
278
+ const project = normalizeProject(dirname(dirname(sourceDbPath)));
279
+
280
+ const report: SourceReport = {
281
+ sourceDb: sourceDbPath,
282
+ project,
283
+ memoriesImported: 0,
284
+ memoriesSkipped: 0,
285
+ memoriesRekeyed: 0,
286
+ conversationsImported: 0,
287
+ conversationsSkipped: 0,
288
+ indexStateImported: 0,
289
+ rekeyMap: {},
290
+ unresolvedReferences: [],
291
+ errors: [],
292
+ };
293
+
294
+ // LanceDB-era stores are directories, not SQLite files
295
+ if (statSync(sourceDbPath).isDirectory()) {
296
+ await this.consolidateLanceSource(
297
+ sourceDbPath,
298
+ project,
299
+ importBatch,
300
+ options,
301
+ report,
302
+ );
303
+ return report;
304
+ }
305
+
306
+ let source: Database;
307
+ try {
308
+ source = new Database(sourceDbPath, { readonly: true });
309
+ } catch (e) {
310
+ report.errors.push(
311
+ `cannot open source: ${e instanceof Error ? e.message : String(e)}`,
312
+ );
313
+ return report;
314
+ }
315
+
316
+ try {
317
+ await this.importMemories(source, project, importBatch, options, report);
318
+ this.importConversations(source, project, importBatch, options, report);
319
+ await this.importIndexState(source, sourceDbPath, project, options, report);
320
+ } catch (e) {
321
+ report.errors.push(e instanceof Error ? e.message : String(e));
322
+ } finally {
323
+ source.close();
324
+ }
325
+
326
+ return report;
327
+ }
328
+
329
+ private async consolidateLanceSource(
330
+ sourceDbPath: string,
331
+ project: string,
332
+ importBatch: string,
333
+ options: ConsolidationOptions,
334
+ report: SourceReport,
335
+ ): Promise<void> {
336
+ const entries = readdirSync(sourceDbPath);
337
+ if (entries.length === 0) return; // failed init left an empty dir — nothing to import
338
+ if (!isLanceDir(entries)) {
339
+ report.errors.push(
340
+ `source is a directory but not a LanceDB store: ${sourceDbPath}`,
341
+ );
342
+ return;
343
+ }
344
+
345
+ let data: Awaited<ReturnType<typeof extractLanceData>>;
346
+ try {
347
+ data = await extractLanceData(sourceDbPath);
348
+ } catch (e) {
349
+ report.errors.push(
350
+ `LanceDB extraction failed: ${e instanceof Error ? e.message : String(e)}`,
351
+ );
352
+ return;
353
+ }
354
+
355
+ const expectedBytes = this.embeddings.dimension * 4;
356
+ const toBuffer = (vector: number[]): Buffer | null => {
357
+ const buf = vector.length > 0 ? serializeVector(vector) : null;
358
+ // Wrong-dimension vectors (model change) are dropped → re-embedded
359
+ return buf && buf.byteLength === expectedBytes ? buf : null;
360
+ };
361
+
362
+ const memoryRows: SourceMemoryRow[] = data.memories.map((m) => ({
363
+ ...m,
364
+ vector: toBuffer(m.vector),
365
+ }));
366
+ const conversationRows: SourceConversationRow[] = data.conversations.map(
367
+ (c) => ({ ...c, vector: toBuffer(c.vector) }),
368
+ );
369
+
370
+ try {
371
+ await this.processMemoryRows(
372
+ memoryRows,
373
+ project,
374
+ importBatch,
375
+ options,
376
+ report,
377
+ );
378
+ this.processConversationRows(
379
+ conversationRows,
380
+ project,
381
+ importBatch,
382
+ options,
383
+ report,
384
+ );
385
+ await this.importIndexState(null, sourceDbPath, project, options, report);
386
+ } catch (e) {
387
+ report.errors.push(e instanceof Error ? e.message : String(e));
388
+ }
389
+ }
390
+
391
+ // ── Memories ────────────────────────────────────────────────────────
392
+
393
+ private async importMemories(
394
+ source: Database,
395
+ project: string,
396
+ importBatch: string,
397
+ options: ConsolidationOptions,
398
+ report: SourceReport,
399
+ ): Promise<void> {
400
+ if (!tableExists(source, "memories")) return;
401
+
402
+ const rows = (
403
+ vecTableReadable(source, "memories_vec")
404
+ ? source.prepare(
405
+ `SELECT m.*, v.vector FROM memories m
406
+ LEFT JOIN memories_vec v ON m.id = v.id`,
407
+ )
408
+ : source.prepare("SELECT m.*, NULL AS vector FROM memories m")
409
+ ).all() as SourceMemoryRow[];
410
+
411
+ await this.processMemoryRows(rows, project, importBatch, options, report);
412
+ }
413
+
414
+ private async processMemoryRows(
415
+ rows: SourceMemoryRow[],
416
+ project: string,
417
+ importBatch: string,
418
+ options: ConsolidationOptions,
419
+ report: SourceReport,
420
+ ): Promise<void> {
421
+ if (rows.length === 0) return;
422
+
423
+ const targetGet = this.target.prepare(
424
+ "SELECT content FROM memories WHERE id = ?",
425
+ );
426
+
427
+ // Plan re-keys first so references can be remapped before any insert.
428
+ // Waypoints collapse to the canonical per-project ID (newest wins);
429
+ // other ID collisions with different content get fresh UUIDs.
430
+ const canonicalWaypointId = waypointIdFor(project);
431
+ const waypoints = rows
432
+ .filter(isWaypointRow)
433
+ .sort((a, b) => b.updated_at - a.updated_at);
434
+ const sourceIds = new Set(rows.map((r) => r.id));
435
+ const toImport: SourceMemoryRow[] = [];
436
+
437
+ for (const row of rows) {
438
+ if (isWaypointRow(row)) {
439
+ if (row !== waypoints[0]) {
440
+ report.memoriesSkipped++; // older duplicate waypoint copies
441
+ continue;
442
+ }
443
+ if (row.id !== canonicalWaypointId) {
444
+ report.rekeyMap[row.id] = canonicalWaypointId;
445
+ report.memoriesRekeyed++;
446
+ }
447
+ toImport.push(row);
448
+ continue;
449
+ }
450
+
451
+ const existing = targetGet.get(row.id) as { content: string } | null;
452
+ if (existing) {
453
+ if (existing.content === row.content) {
454
+ report.memoriesSkipped++;
455
+ continue;
456
+ }
457
+ const fresh = randomUUID();
458
+ report.rekeyMap[row.id] = fresh;
459
+ report.memoriesRekeyed++;
460
+ }
461
+ toImport.push(row);
462
+ }
463
+
464
+ // The target waypoint may also already exist — keep whichever is newer.
465
+ const existingWaypoint = this.target
466
+ .prepare("SELECT updated_at FROM memories WHERE id = ?")
467
+ .get(canonicalWaypointId) as { updated_at: number } | null;
468
+ if (existingWaypoint && waypoints[0]) {
469
+ const idx = toImport.indexOf(waypoints[0]);
470
+ if (idx !== -1 && existingWaypoint.updated_at >= waypoints[0].updated_at) {
471
+ // Target's waypoint is newer — drop the source copy. The rekeyMap
472
+ // entry stays: references remap to the surviving target waypoint.
473
+ toImport.splice(idx, 1);
474
+ report.memoriesSkipped++;
475
+ }
476
+ }
477
+
478
+ if (options.dryRun) {
479
+ report.memoriesImported = toImport.length;
480
+ this.collectUnresolved(rows, sourceIds, report);
481
+ return;
482
+ }
483
+
484
+ // Pre-compute embeddings for rows whose vectors are missing or have the
485
+ // wrong dimension (model change) — outside any transaction.
486
+ const expectedBytes = this.embeddings.dimension * 4;
487
+ const reEmbedded = new Map<string, number[]>();
488
+ for (const row of toImport) {
489
+ if (isWaypointRow(row)) continue; // waypoints keep zero vectors
490
+ if (row.vector && row.vector.byteLength === expectedBytes) continue;
491
+ reEmbedded.set(row.id, await this.embeddings.embed(row.content));
492
+ }
493
+ const zeroVector = serializeVector(
494
+ new Array(this.embeddings.dimension).fill(0),
495
+ );
496
+
497
+ const insertMain = this.target.prepare(
498
+ `INSERT INTO memories (id, content, metadata, created_at, updated_at, superseded_by, usefulness, access_count, last_accessed, project)
499
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
500
+ );
501
+ const replaceMain = this.target.prepare(
502
+ `INSERT OR REPLACE INTO memories (id, content, metadata, created_at, updated_at, superseded_by, usefulness, access_count, last_accessed, project)
503
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
504
+ );
505
+ const deleteVec = this.target.prepare(
506
+ "DELETE FROM memories_vec WHERE id = ?",
507
+ );
508
+ const insertVec = this.target.prepare(
509
+ "INSERT OR REPLACE INTO memories_vec (id, vector) VALUES (?, ?)",
510
+ );
511
+ const deleteFts = this.target.prepare(
512
+ "DELETE FROM memories_fts WHERE id = ?",
513
+ );
514
+ const insertFts = this.target.prepare(
515
+ "INSERT INTO memories_fts (id, content) VALUES (?, ?)",
516
+ );
517
+ const existsStmt = this.target.prepare(
518
+ "SELECT 1 FROM memories WHERE id = ?",
519
+ );
520
+
521
+ const rekey = (id: string | null): string | null =>
522
+ id === null ? null : (report.rekeyMap[id] ?? id);
523
+
524
+ const tx = this.target.transaction(() => {
525
+ for (const row of toImport) {
526
+ const newId = rekey(row.id)!;
527
+ const isWaypoint = isWaypointRow(row);
528
+
529
+ // Remap references in metadata and rendered content
530
+ const metadata = safeParseJsonObject(row.metadata);
531
+ let content = row.content;
532
+ for (const field of ["memory_ids", "related_memory_ids"]) {
533
+ const value = metadata[field];
534
+ if (Array.isArray(value)) {
535
+ metadata[field] = value.map((v) =>
536
+ typeof v === "string" ? rekey(v) : v,
537
+ );
538
+ }
539
+ }
540
+ for (const [oldId, mapped] of Object.entries(report.rekeyMap)) {
541
+ if (oldId !== newId) content = content.replaceAll(oldId, mapped);
542
+ }
543
+
544
+ const originalProject = metadata.project;
545
+ if (
546
+ typeof originalProject === "string" &&
547
+ originalProject.length > 0 &&
548
+ normalizeProject(originalProject) !== project
549
+ ) {
550
+ metadata.original_project = originalProject;
551
+ }
552
+ metadata.project = project;
553
+ metadata.import_batch = importBatch;
554
+ if (newId !== row.id) metadata.original_id = row.id;
555
+
556
+ // Re-check existence inside the transaction (no TOCTOU against a
557
+ // live writer). Waypoints may replace the canonical row (newest-wins
558
+ // was decided above); everything else never overwrites.
559
+ const exists = existsStmt.get(newId) != null;
560
+ if (exists && !isWaypoint) {
561
+ report.memoriesSkipped++;
562
+ continue;
563
+ }
564
+
565
+ const vector = isWaypoint
566
+ ? zeroVector
567
+ : reEmbedded.has(row.id)
568
+ ? serializeVector(reEmbedded.get(row.id)!)
569
+ : row.vector!;
570
+
571
+ (exists ? replaceMain : insertMain).run(
572
+ newId,
573
+ content,
574
+ JSON.stringify(metadata),
575
+ row.created_at,
576
+ row.updated_at,
577
+ rekey(row.superseded_by),
578
+ row.usefulness,
579
+ row.access_count,
580
+ row.last_accessed,
581
+ project,
582
+ );
583
+ deleteVec.run(newId);
584
+ insertVec.run(newId, vector);
585
+ deleteFts.run(newId);
586
+ insertFts.run(newId, content);
587
+ report.memoriesImported++;
588
+ }
589
+ });
590
+ tx();
591
+
592
+ this.collectUnresolved(rows, sourceIds, report);
593
+ }
594
+
595
+ /** Report references that point at IDs in neither the source nor target. */
596
+ private collectUnresolved(
597
+ rows: SourceMemoryRow[],
598
+ sourceIds: Set<string>,
599
+ report: SourceReport,
600
+ ): void {
601
+ const targetHas = this.target.prepare("SELECT 1 FROM memories WHERE id = ?");
602
+ for (const row of rows) {
603
+ const metadata = safeParseJsonObject(row.metadata);
604
+ const refs = [
605
+ ...(Array.isArray(metadata.memory_ids) ? metadata.memory_ids : []),
606
+ ...(Array.isArray(metadata.related_memory_ids)
607
+ ? metadata.related_memory_ids
608
+ : []),
609
+ ...(row.superseded_by && row.superseded_by !== "DELETED"
610
+ ? [row.superseded_by]
611
+ : []),
612
+ ].filter((r): r is string => typeof r === "string");
613
+
614
+ for (const ref of refs) {
615
+ const mapped = report.rekeyMap[ref] ?? ref;
616
+ if (sourceIds.has(ref)) continue;
617
+ if (targetHas.get(mapped) != null) continue;
618
+ report.unresolvedReferences.push(`${row.id} -> ${ref}`);
619
+ }
620
+ }
621
+ }
622
+
623
+ // ── Conversation history ────────────────────────────────────────────
624
+
625
+ private importConversations(
626
+ source: Database,
627
+ project: string,
628
+ importBatch: string,
629
+ options: ConsolidationOptions,
630
+ report: SourceReport,
631
+ ): void {
632
+ if (!tableExists(source, "conversation_history")) return;
633
+
634
+ const rows = (
635
+ vecTableReadable(source, "conversation_history_vec")
636
+ ? source.prepare(
637
+ `SELECT c.*, v.vector FROM conversation_history c
638
+ LEFT JOIN conversation_history_vec v ON c.id = v.id`,
639
+ )
640
+ : source.prepare(
641
+ "SELECT c.*, NULL AS vector FROM conversation_history c",
642
+ )
643
+ ).all() as SourceConversationRow[];
644
+
645
+ this.processConversationRows(rows, project, importBatch, options, report);
646
+ }
647
+
648
+ private processConversationRows(
649
+ rows: SourceConversationRow[],
650
+ project: string,
651
+ importBatch: string,
652
+ options: ConsolidationOptions,
653
+ report: SourceReport,
654
+ ): void {
655
+ if (rows.length === 0) return;
656
+
657
+ const existsStmt = this.target.prepare(
658
+ "SELECT 1 FROM conversation_history WHERE id = ?",
659
+ );
660
+
661
+ if (options.dryRun) {
662
+ for (const row of rows) {
663
+ if (existsStmt.get(row.id) != null) report.conversationsSkipped++;
664
+ else report.conversationsImported++;
665
+ }
666
+ return;
667
+ }
668
+
669
+ const insertMain = this.target.prepare(
670
+ `INSERT INTO conversation_history
671
+ (id, content, metadata, created_at, session_id, role, message_index_start, message_index_end, project)
672
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
673
+ );
674
+ const insertVec = this.target.prepare(
675
+ "INSERT OR REPLACE INTO conversation_history_vec (id, vector) VALUES (?, ?)",
676
+ );
677
+ const insertFts = this.target.prepare(
678
+ "INSERT INTO conversation_history_fts (id, content) VALUES (?, ?)",
679
+ );
680
+
681
+ const tx = this.target.transaction(() => {
682
+ for (const row of rows) {
683
+ if (existsStmt.get(row.id) != null) {
684
+ report.conversationsSkipped++;
685
+ continue;
686
+ }
687
+ // Sessions in a repo-local db were indexed from that repo — stamp
688
+ // the canonical project unless the row already carries one.
689
+ const rowProject = row.project.startsWith("/") ? row.project : project;
690
+ const metadata = safeParseJsonObject(row.metadata);
691
+ metadata.project = rowProject;
692
+ metadata.import_batch = importBatch;
693
+
694
+ insertMain.run(
695
+ row.id,
696
+ row.content,
697
+ JSON.stringify(metadata),
698
+ row.created_at,
699
+ row.session_id,
700
+ row.role,
701
+ row.message_index_start,
702
+ row.message_index_end,
703
+ rowProject,
704
+ );
705
+ if (row.vector) insertVec.run(row.id, row.vector);
706
+ insertFts.run(row.id, row.content);
707
+ report.conversationsImported++;
708
+ }
709
+ });
710
+ tx();
711
+ }
712
+
713
+ // ── Conversation index state ────────────────────────────────────────
714
+
715
+ private async importIndexState(
716
+ source: Database | null,
717
+ sourceDbPath: string,
718
+ project: string,
719
+ options: ConsolidationOptions,
720
+ report: SourceReport,
721
+ ): Promise<void> {
722
+ type StateRow = {
723
+ session_id: string;
724
+ file_path: string;
725
+ project: string;
726
+ last_modified: number;
727
+ chunk_count: number;
728
+ message_count: number;
729
+ indexed_at: number;
730
+ first_message_at: number;
731
+ last_message_at: number;
732
+ };
733
+
734
+ const entries: StateRow[] = [];
735
+ if (source && tableExists(source, "conversation_index_state")) {
736
+ entries.push(
737
+ ...(source
738
+ .prepare("SELECT * FROM conversation_index_state")
739
+ .all() as StateRow[]),
740
+ );
741
+ }
742
+
743
+ // Legacy JSON state next to the source db
744
+ try {
745
+ const raw = await readFile(
746
+ join(dirname(sourceDbPath), "conversation_index_state.json"),
747
+ "utf-8",
748
+ );
749
+ const legacy = JSON.parse(raw) as Array<{
750
+ sessionId: string;
751
+ filePath: string;
752
+ project: string;
753
+ lastModified: number;
754
+ chunkCount: number;
755
+ messageCount: number;
756
+ indexedAt: string;
757
+ firstMessageAt: string;
758
+ lastMessageAt: string;
759
+ }>;
760
+ const seen = new Set(entries.map((e) => e.session_id));
761
+ for (const e of legacy) {
762
+ if (seen.has(e.sessionId)) continue;
763
+ entries.push({
764
+ session_id: e.sessionId,
765
+ file_path: e.filePath,
766
+ project: e.project,
767
+ last_modified: e.lastModified,
768
+ chunk_count: e.chunkCount,
769
+ message_count: e.messageCount,
770
+ indexed_at: new Date(e.indexedAt).getTime(),
771
+ first_message_at: new Date(e.firstMessageAt).getTime(),
772
+ last_message_at: new Date(e.lastMessageAt).getTime(),
773
+ });
774
+ }
775
+ } catch {
776
+ // no legacy file — fine
777
+ }
778
+
779
+ if (entries.length === 0 || options.dryRun) {
780
+ if (options.dryRun) report.indexStateImported = entries.length;
781
+ return;
782
+ }
783
+
784
+ const insert = this.target.prepare(
785
+ `INSERT OR IGNORE INTO conversation_index_state
786
+ (session_id, file_path, project, last_modified, chunk_count, message_count, indexed_at, first_message_at, last_message_at)
787
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
788
+ );
789
+ const tx = this.target.transaction(() => {
790
+ for (const e of entries) {
791
+ const result = insert.run(
792
+ e.session_id,
793
+ e.file_path,
794
+ e.project.startsWith("/") ? e.project : project,
795
+ e.last_modified,
796
+ e.chunk_count,
797
+ e.message_count,
798
+ e.indexed_at,
799
+ e.first_message_at,
800
+ e.last_message_at,
801
+ );
802
+ if (result.changes > 0) report.indexStateImported++;
803
+ }
804
+ });
805
+ tx();
806
+ }
807
+ }
808
+
809
+ function tableExists(db: Database, name: string): boolean {
810
+ return (
811
+ db
812
+ .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
813
+ .get(name) != null
814
+ );
815
+ }