@lotargo/memory_plugin 1.6.6 → 1.6.7

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 (38) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +576 -443
  3. package/mcp-server/cli/direct_commands.js +39 -0
  4. package/mcp-server/cli.js +16 -5
  5. package/mcp-server/cli_boot.js +4 -1
  6. package/mcp-server/client_cli.js +73 -0
  7. package/mcp-server/client_paths.js +44 -0
  8. package/mcp-server/client_registration.js +38 -0
  9. package/mcp-server/codex_config.js +86 -8
  10. package/mcp-server/db/database.js +14 -21
  11. package/mcp-server/db/migrations.js +66 -77
  12. package/mcp-server/db/rag_blob_transport.js +143 -0
  13. package/mcp-server/db/rag_sync.js +284 -0
  14. package/mcp-server/db/sync_queue.js +219 -307
  15. package/mcp-server/dev_link.js +142 -0
  16. package/mcp-server/fact_format.js +44 -12
  17. package/mcp-server/index.js +17 -7
  18. package/mcp-server/ingest/exporter.js +44 -38
  19. package/mcp-server/ingest/pipeline.js +260 -248
  20. package/mcp-server/persona_migration.js +39 -0
  21. package/mcp-server/prompt_manager.js +162 -55
  22. package/mcp-server/retrieval/retriever.js +99 -64
  23. package/mcp-server/setup.js +150 -100
  24. package/mcp-server/storage/blob_store.js +53 -1
  25. package/mcp-server/tools/core/knowledge_read_core.js +163 -0
  26. package/mcp-server/tools/core/memory_core.js +24 -4
  27. package/mcp-server/tools/core/memory_routing.js +10 -0
  28. package/mcp-server/tools/core/note_core.js +53 -0
  29. package/mcp-server/tools/core/rag_query_core.js +169 -0
  30. package/mcp-server/tools/index.js +11 -9
  31. package/mcp-server/tools/memory_tools.js +4 -1
  32. package/mcp-server/tools/note_tools.js +35 -0
  33. package/mcp-server/tools/rag_tools.js +211 -364
  34. package/mcp-server/uninstall.js +627 -0
  35. package/opencode-plugin/index.js +80 -12
  36. package/opencode-plugin/main.js +136 -0
  37. package/package.json +16 -12
  38. package/skills/using-memory/SKILL.md +28 -19
@@ -3,7 +3,6 @@ const MIGRATIONS = [
3
3
  version: 1,
4
4
  name: "001_initial_rag_schema",
5
5
  up: async (db) => {
6
- // 1. Documents Table
7
6
  await db.exec(`
8
7
  CREATE TABLE IF NOT EXISTS documents (
9
8
  id TEXT PRIMARY KEY,
@@ -17,8 +16,6 @@ const MIGRATIONS = [
17
16
  updated_at INTEGER NOT NULL
18
17
  );
19
18
  `);
20
-
21
- // 2. Sections Table
22
19
  await db.exec(`
23
20
  CREATE TABLE IF NOT EXISTS sections (
24
21
  id TEXT PRIMARY KEY,
@@ -29,8 +26,6 @@ const MIGRATIONS = [
29
26
  token_count INTEGER NOT NULL
30
27
  );
31
28
  `);
32
-
33
- // 3. Micro-Chunks Table
34
29
  await db.exec(`
35
30
  CREATE TABLE IF NOT EXISTS micro_chunks (
36
31
  id TEXT PRIMARY KEY,
@@ -41,8 +36,6 @@ const MIGRATIONS = [
41
36
  token_count INTEGER NOT NULL
42
37
  );
43
38
  `);
44
-
45
- // 4. Full-Text Search (BM25 Index via SQLite FTS5)
46
39
  await db.exec(`
47
40
  CREATE VIRTUAL TABLE IF NOT EXISTS micro_chunks_fts USING fts5(
48
41
  id UNINDEXED,
@@ -50,8 +43,6 @@ const MIGRATIONS = [
50
43
  breadcrumbs
51
44
  );
52
45
  `);
53
-
54
- // 5. GraphRAG Lite Edges Table
55
46
  await db.exec(`
56
47
  CREATE TABLE IF NOT EXISTS graph_edges (
57
48
  source_id TEXT NOT NULL,
@@ -66,13 +57,8 @@ const MIGRATIONS = [
66
57
  version: 2,
67
58
  name: "002_agent_knowledge_graph",
68
59
  up: async (db) => {
69
- try {
70
- await db.exec(`ALTER TABLE graph_edges ADD COLUMN metadata_json TEXT;`);
71
- } catch (e) {}
72
- try {
73
- await db.exec(`ALTER TABLE graph_edges ADD COLUMN created_at INTEGER;`);
74
- } catch (e) {}
75
-
60
+ try { await db.exec(`ALTER TABLE graph_edges ADD COLUMN metadata_json TEXT;`); } catch (e) {}
61
+ try { await db.exec(`ALTER TABLE graph_edges ADD COLUMN created_at INTEGER;`); } catch (e) {}
76
62
  await db.exec(`
77
63
  CREATE TABLE IF NOT EXISTS knowledge_links (
78
64
  id TEXT PRIMARY KEY,
@@ -104,10 +90,7 @@ const MIGRATIONS = [
104
90
  created_at INTEGER
105
91
  );
106
92
  `);
107
-
108
- try {
109
- await db.exec(`ALTER TABLE micro_chunks ADD COLUMN medium_id TEXT REFERENCES medium_chunks(id) ON DELETE CASCADE;`);
110
- } catch (e) {}
93
+ try { await db.exec(`ALTER TABLE micro_chunks ADD COLUMN medium_id TEXT REFERENCES medium_chunks(id) ON DELETE CASCADE;`); } catch (e) {}
111
94
  },
112
95
  },
113
96
  {
@@ -116,65 +99,81 @@ const MIGRATIONS = [
116
99
  up: async (db) => {
117
100
  await db.exec(`
118
101
  CREATE TABLE IF NOT EXISTS project_identities (
119
- key TEXT PRIMARY KEY,
120
- name TEXT NOT NULL,
102
+ key TEXT PRIMARY KEY,
103
+ name TEXT NOT NULL,
121
104
  primary_remote TEXT,
122
- created_at INTEGER NOT NULL,
123
- updated_at INTEGER NOT NULL
105
+ created_at INTEGER NOT NULL,
106
+ updated_at INTEGER NOT NULL
124
107
  );
125
108
  `);
126
-
127
109
  await db.exec(`
128
110
  CREATE TABLE IF NOT EXISTS project_aliases (
129
- alias TEXT PRIMARY KEY,
111
+ alias TEXT PRIMARY KEY,
130
112
  identity_key TEXT NOT NULL REFERENCES project_identities(key) ON DELETE CASCADE,
131
- kind TEXT NOT NULL,
132
- created_at INTEGER NOT NULL
113
+ kind TEXT NOT NULL,
114
+ created_at INTEGER NOT NULL
133
115
  );
134
116
  `);
135
-
117
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_project_aliases_identity ON project_aliases(identity_key);`);
118
+ },
119
+ },
120
+ {
121
+ version: 5,
122
+ name: "005_retrieval_policy",
123
+ up: async (db) => {
124
+ try { await db.exec(`ALTER TABLE micro_chunks ADD COLUMN retrieval_policy TEXT DEFAULT 'micro_chunk';`); } catch (e) {}
125
+ try { await db.exec(`ALTER TABLE micro_chunks ADD COLUMN policy_source_id TEXT;`); } catch (e) {}
126
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_micro_chunks_retrieval_policy ON micro_chunks(retrieval_policy);`);
127
+ },
128
+ },
129
+ {
130
+ version: 6,
131
+ name: "006_project_scoped_rag",
132
+ up: async (db) => {
136
133
  await db.exec(`
137
- CREATE INDEX IF NOT EXISTS idx_project_aliases_identity ON project_aliases(identity_key);
134
+ CREATE TABLE IF NOT EXISTS document_scopes (
135
+ doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
136
+ scope_key TEXT NOT NULL,
137
+ created_at INTEGER NOT NULL,
138
+ PRIMARY KEY (doc_id, scope_key)
139
+ );
140
+ `);
141
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_document_scopes_scope ON document_scopes(scope_key, doc_id);`);
142
+ await db.exec(`
143
+ INSERT OR IGNORE INTO document_scopes (doc_id, scope_key, created_at)
144
+ SELECT id, 'global', created_at FROM documents;
138
145
  `);
139
146
  },
140
147
  },
141
- {
142
- version: 5,
143
- name: "005_retrieval_policy",
148
+ {
149
+ version: 7,
150
+ name: "007_rag_blob_transport",
144
151
  up: async (db) => {
145
- try {
146
- await db.exec(`ALTER TABLE micro_chunks ADD COLUMN retrieval_policy TEXT DEFAULT 'micro_chunk';`);
147
- } catch (e) {}
148
- try {
149
- await db.exec(`ALTER TABLE micro_chunks ADD COLUMN policy_source_id TEXT;`);
150
- } catch (e) {}
151
152
  await db.exec(`
152
- CREATE INDEX IF NOT EXISTS idx_micro_chunks_retrieval_policy ON micro_chunks(retrieval_policy);
153
+ CREATE TABLE IF NOT EXISTS rag_blobs (
154
+ hash TEXT PRIMARY KEY,
155
+ gzip_base64 TEXT NOT NULL,
156
+ raw_size INTEGER NOT NULL DEFAULT 0,
157
+ created_at INTEGER NOT NULL
158
+ );
153
159
  `);
154
- },
155
- },
156
- {
157
- version: 6,
158
- name: "006_project_scoped_rag",
159
- up: async (db) => {
160
- await db.exec(`
161
- CREATE TABLE IF NOT EXISTS document_scopes (
162
- doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
163
- scope_key TEXT NOT NULL,
164
- created_at INTEGER NOT NULL,
165
- PRIMARY KEY (doc_id, scope_key)
166
- );
167
- `);
168
- await db.exec(`
169
- CREATE INDEX IF NOT EXISTS idx_document_scopes_scope ON document_scopes(scope_key, doc_id);
170
- `);
171
- await db.exec(`
172
- INSERT OR IGNORE INTO document_scopes (doc_id, scope_key, created_at)
173
- SELECT id, 'global', created_at FROM documents;
174
- `);
175
- },
176
- },
177
- ];
160
+ },
161
+ },
162
+ {
163
+ version: 8,
164
+ name: "008_rag_document_tombstones",
165
+ up: async (db) => {
166
+ await db.exec(`
167
+ CREATE TABLE IF NOT EXISTS rag_document_tombstones (
168
+ doc_id TEXT PRIMARY KEY,
169
+ path TEXT,
170
+ deleted_at INTEGER NOT NULL
171
+ );
172
+ `);
173
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_rag_tombstones_path ON rag_document_tombstones(path);`);
174
+ },
175
+ },
176
+ ];
178
177
 
179
178
  export async function runMigrations(db) {
180
179
  let currentVersion = 0;
@@ -188,12 +187,7 @@ export async function runMigrations(db) {
188
187
  } catch (e2) {}
189
188
  }
190
189
 
191
- // Ensure schema_migrations table exists for future
192
- try {
193
- await db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY);`);
194
- } catch (e) {}
195
-
196
- // Create notebooks table if not exists
190
+ try { await db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY);`); } catch (e) {}
197
191
  try {
198
192
  await db.exec(`
199
193
  CREATE TABLE IF NOT EXISTS notebooks (
@@ -211,19 +205,14 @@ export async function runMigrations(db) {
211
205
  await migration.up(db);
212
206
  await db.prepare("INSERT INTO schema_migrations (version) VALUES (?);").run(migration.version);
213
207
  await db.exec("COMMIT;");
214
- try {
215
- await db.exec(`PRAGMA user_version = ${migration.version};`);
216
- } catch (e) {}
208
+ try { await db.exec(`PRAGMA user_version = ${migration.version};`); } catch (e) {}
217
209
  } catch (err) {
218
- try {
219
- await db.exec("ROLLBACK;");
220
- } catch (e) {}
210
+ try { await db.exec("ROLLBACK;"); } catch (e) {}
221
211
  throw new Error(`Migration ${migration.name} failed: ${err.message}`);
222
212
  }
223
213
  }
224
214
  }
225
215
 
226
- // Defensive table & column check for medium_chunks hierarchy
227
216
  try {
228
217
  await db.exec(`
229
218
  CREATE TABLE IF NOT EXISTS medium_chunks (
@@ -0,0 +1,143 @@
1
+ import { BLOBS_DIR } from "./database.js";
2
+ import {
3
+ blobExists,
4
+ readBlobTransport,
5
+ saveBlobTransport,
6
+ } from "../storage/blob_store.js";
7
+
8
+ async function executeCloud(db, sql, args = []) {
9
+ if (!db?.cloudClient && !db?.failoverClient) {
10
+ throw new Error("Cloud database client is not available for RAG blob transport");
11
+ }
12
+ if (typeof db.runWithRetry === "function") {
13
+ return await db.runWithRetry(async (client) => client.execute({ sql, args }));
14
+ }
15
+ return await db.cloudClient.execute({ sql, args });
16
+ }
17
+
18
+ export async function pushBlobToCloud(db, hash, baseDir = BLOBS_DIR) {
19
+ if (!hash) return { skipped: true, reason: "missing_hash" };
20
+ if (!db || db.mode === "only-local") return { skipped: true, reason: "local_mode" };
21
+
22
+ const transport = await readBlobTransport(hash, baseDir);
23
+ await executeCloud(
24
+ db,
25
+ `INSERT INTO rag_blobs (hash, gzip_base64, raw_size, created_at)
26
+ VALUES (?, ?, ?, ?)
27
+ ON CONFLICT(hash) DO NOTHING;`,
28
+ [transport.hash, transport.gzipBase64, transport.rawSize, Date.now()]
29
+ );
30
+ return { pushed: true, hash, rawSize: transport.rawSize };
31
+ }
32
+
33
+ /**
34
+ * Upgrade/backfill path for documents that existed before portable cloud blobs.
35
+ * Existing cloud hashes are fetched once, then only missing local blobs are sent.
36
+ * A cloud deletion tombstone newer than the local document blocks backfill so a
37
+ * stale machine cannot re-upload an orphaned raw payload after another machine
38
+ * deliberately deleted the document/note.
39
+ */
40
+ export async function backfillCloudBlobsFromLocal(db, baseDir = BLOBS_DIR) {
41
+ if (!db || db.mode === "only-local") return { skipped: true };
42
+
43
+ const docs = await db.prepare(`
44
+ SELECT id, path, blob_hash, updated_at
45
+ FROM documents
46
+ WHERE blob_hash IS NOT NULL;
47
+ `).all();
48
+ const [cloudBlobRes, tombstoneRes] = await Promise.all([
49
+ executeCloud(db, "SELECT hash FROM rag_blobs;"),
50
+ executeCloud(db, "SELECT doc_id, path, deleted_at FROM rag_document_tombstones;"),
51
+ ]);
52
+ const existing = new Set((cloudBlobRes?.rows || []).map((row) => row.hash));
53
+ const tombById = new Map((tombstoneRes?.rows || []).map((row) => [row.doc_id, row]));
54
+ const tombByPath = new Map(
55
+ (tombstoneRes?.rows || []).filter((row) => row.path).map((row) => [row.path, row])
56
+ );
57
+ const summary = {
58
+ candidates: docs.length,
59
+ pushed: 0,
60
+ existing: 0,
61
+ tombstoned: 0,
62
+ missingLocal: 0,
63
+ errors: 0,
64
+ };
65
+
66
+ for (const row of docs) {
67
+ const hash = row.blob_hash;
68
+ if (!hash) continue;
69
+
70
+ const tombstone = tombById.get(row.id) || tombByPath.get(row.path);
71
+ if (tombstone && Number(tombstone.deleted_at || 0) >= Number(row.updated_at || 0)) {
72
+ summary.tombstoned++;
73
+ continue;
74
+ }
75
+
76
+ if (existing.has(hash)) {
77
+ summary.existing++;
78
+ continue;
79
+ }
80
+ if (!(await blobExists(hash, baseDir))) {
81
+ summary.missingLocal++;
82
+ continue;
83
+ }
84
+ try {
85
+ await pushBlobToCloud(db, hash, baseDir);
86
+ existing.add(hash);
87
+ summary.pushed++;
88
+ } catch (err) {
89
+ summary.errors++;
90
+ console.warn(`Failed to backfill RAG blob ${hash}: ${err.message}`);
91
+ }
92
+ }
93
+
94
+ return summary;
95
+ }
96
+
97
+ export async function materializeBlobFromCloud(db, hash, baseDir = BLOBS_DIR) {
98
+ if (!hash) return { materialized: false, reason: "missing_hash" };
99
+ if (await blobExists(hash, baseDir)) {
100
+ return { materialized: false, existing: true, hash };
101
+ }
102
+ if (!db || db.mode === "only-local") {
103
+ return { materialized: false, reason: "local_mode", hash };
104
+ }
105
+
106
+ const res = await executeCloud(
107
+ db,
108
+ "SELECT gzip_base64, raw_size FROM rag_blobs WHERE hash = ?;",
109
+ [hash]
110
+ );
111
+ const row = res?.rows?.[0];
112
+ if (!row?.gzip_base64) {
113
+ return { materialized: false, reason: "cloud_blob_missing", hash };
114
+ }
115
+
116
+ const saved = await saveBlobTransport(hash, row.gzip_base64, baseDir);
117
+ return {
118
+ materialized: true,
119
+ hash,
120
+ rawSize: Number(row.raw_size || saved.size || 0),
121
+ path: saved.path,
122
+ };
123
+ }
124
+
125
+ export async function deleteCloudBlobIfUnreferenced(db, hash) {
126
+ if (!hash || !db || db.mode === "only-local") return { deleted: false };
127
+ const refs = await executeCloud(
128
+ db,
129
+ "SELECT COUNT(*) AS cnt FROM documents WHERE blob_hash = ?;",
130
+ [hash]
131
+ );
132
+ const count = Number(refs?.rows?.[0]?.cnt || 0);
133
+ if (count > 0) return { deleted: false, references: count };
134
+
135
+ await executeCloud(db, "DELETE FROM rag_blobs WHERE hash = ?;", [hash]);
136
+ return { deleted: true, references: 0 };
137
+ }
138
+
139
+ export async function cloudBlobExists(db, hash) {
140
+ if (!hash || !db || db.mode === "only-local") return false;
141
+ const res = await executeCloud(db, "SELECT hash FROM rag_blobs WHERE hash = ?;", [hash]);
142
+ return Boolean(res?.rows?.length);
143
+ }
@@ -0,0 +1,284 @@
1
+ import { toVectorBytes } from "../retrieval/retriever.js";
2
+ import { deleteBlob } from "../storage/blob_store.js";
3
+ import { materializeBlobFromCloud } from "./rag_blob_transport.js";
4
+
5
+ async function executeCloud(db, sql, args = []) {
6
+ if (!db?.cloudClient && !db?.failoverClient) {
7
+ throw new Error("Cloud database client is not available for RAG reverse sync");
8
+ }
9
+ if (typeof db.runWithRetry === "function") {
10
+ return await db.runWithRetry(async (client) => client.execute({ sql, args }));
11
+ }
12
+ return await db.cloudClient.execute({ sql, args });
13
+ }
14
+
15
+ export async function recordCloudDocumentTombstone(db, { docId, path = null, deletedAt = Date.now() }) {
16
+ if (!docId || !db || db.mode === "only-local") return { recorded: false };
17
+ await executeCloud(
18
+ db,
19
+ `INSERT INTO rag_document_tombstones (doc_id, path, deleted_at)
20
+ VALUES (?, ?, ?)
21
+ ON CONFLICT(doc_id) DO UPDATE SET path = excluded.path, deleted_at = excluded.deleted_at;`,
22
+ [docId, path || null, deletedAt]
23
+ );
24
+ return { recorded: true, docId, path, deletedAt };
25
+ }
26
+
27
+ export async function clearCloudDocumentTombstone(db, { docId, path = null }) {
28
+ if (!db || db.mode === "only-local") return { cleared: false };
29
+ if (!docId && !path) return { cleared: false };
30
+ await executeCloud(
31
+ db,
32
+ "DELETE FROM rag_document_tombstones WHERE doc_id = ? OR (? IS NOT NULL AND path = ?);",
33
+ [docId || "", path || null, path || null]
34
+ );
35
+ return { cleared: true };
36
+ }
37
+
38
+ async function fetchRemoteDocumentBundle(db, doc) {
39
+ const [sectionsRes, mediumRes, microRes, scopesRes, linksRes, edgesRes] = await Promise.all([
40
+ executeCloud(db, "SELECT * FROM sections WHERE doc_id = ? ORDER BY id;", [doc.id]),
41
+ executeCloud(db, "SELECT * FROM medium_chunks WHERE doc_id = ? ORDER BY id;", [doc.id]),
42
+ executeCloud(db, "SELECT * FROM micro_chunks WHERE doc_id = ? ORDER BY id;", [doc.id]),
43
+ executeCloud(db, "SELECT * FROM document_scopes WHERE doc_id = ? ORDER BY scope_key;", [doc.id]),
44
+ executeCloud(db, "SELECT * FROM knowledge_links WHERE doc_id = ? ORDER BY id;", [doc.id]),
45
+ executeCloud(
46
+ db,
47
+ `SELECT * FROM graph_edges
48
+ WHERE source_id = ? OR target_id = ?
49
+ OR source_id GLOB ? OR target_id GLOB ?
50
+ OR source_id IN (SELECT id FROM sections WHERE doc_id = ?)
51
+ OR target_id IN (SELECT id FROM sections WHERE doc_id = ?)
52
+ OR source_id IN (SELECT id FROM medium_chunks WHERE doc_id = ?)
53
+ OR target_id IN (SELECT id FROM medium_chunks WHERE doc_id = ?)
54
+ OR source_id IN (SELECT id FROM micro_chunks WHERE doc_id = ?)
55
+ OR target_id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);`,
56
+ [doc.id, doc.id, `${doc.id}:L*`, `${doc.id}:L*`, doc.id, doc.id, doc.id, doc.id, doc.id, doc.id]
57
+ ),
58
+ ]);
59
+
60
+ return {
61
+ document: doc,
62
+ sections: sectionsRes.rows || [],
63
+ medium_chunks: mediumRes.rows || [],
64
+ micro_chunks: microRes.rows || [],
65
+ document_scopes: scopesRes.rows || [],
66
+ knowledge_links: linksRes.rows || [],
67
+ graph_edges: edgesRes.rows || [],
68
+ };
69
+ }
70
+
71
+ async function collectOwnedIds(db, docId) {
72
+ const rows = await db.prepare(`
73
+ SELECT id FROM sections WHERE doc_id = ?
74
+ UNION SELECT id FROM medium_chunks WHERE doc_id = ?
75
+ UNION SELECT id FROM micro_chunks WHERE doc_id = ?;
76
+ `).all(docId, docId, docId);
77
+ return [docId, ...rows.map((row) => row.id)];
78
+ }
79
+
80
+ async function clearLocalDocumentRelations(db, docId) {
81
+ const ownedIds = await collectOwnedIds(db, docId);
82
+ try {
83
+ await db.prepare("DELETE FROM micro_chunks_fts WHERE id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);").run(docId);
84
+ } catch {}
85
+ await db.prepare("DELETE FROM knowledge_links WHERE doc_id = ?;").run(docId);
86
+ for (const id of ownedIds) {
87
+ await db.prepare(
88
+ "DELETE FROM graph_edges WHERE source_id = ? OR target_id = ? OR source_id GLOB ? OR target_id GLOB ?;"
89
+ ).run(id, id, `${id}:L*`, `${id}:L*`);
90
+ }
91
+ await db.prepare("DELETE FROM micro_chunks WHERE doc_id = ?;").run(docId);
92
+ await db.prepare("DELETE FROM medium_chunks WHERE doc_id = ?;").run(docId);
93
+ await db.prepare("DELETE FROM sections WHERE doc_id = ?;").run(docId);
94
+ await db.prepare("DELETE FROM document_scopes WHERE doc_id = ?;").run(docId);
95
+ }
96
+
97
+ async function cleanupOrphanBlob(db, hash) {
98
+ if (!hash) return;
99
+ const refs = await db.prepare("SELECT COUNT(*) AS cnt FROM documents WHERE blob_hash = ?;").get(hash);
100
+ if (Number(refs?.cnt || 0) === 0) {
101
+ try { await deleteBlob(hash); } catch {}
102
+ }
103
+ }
104
+
105
+ async function applyRemoteTombstone(db, tombstone) {
106
+ const local = await db.prepare(
107
+ "SELECT id, path, blob_hash, updated_at FROM documents WHERE id = ? OR (? IS NOT NULL AND path = ?);"
108
+ ).get(tombstone.doc_id, tombstone.path || null, tombstone.path || null);
109
+ if (!local) return "absent";
110
+ if (Number(local.updated_at || 0) > Number(tombstone.deleted_at || 0)) return "local_newer";
111
+
112
+ await db.exec("BEGIN IMMEDIATE;");
113
+ try {
114
+ await clearLocalDocumentRelations(db, local.id);
115
+ await db.prepare("DELETE FROM documents WHERE id = ?;").run(local.id);
116
+ await db.prepare(`
117
+ INSERT INTO rag_document_tombstones (doc_id, path, deleted_at)
118
+ VALUES (?, ?, ?)
119
+ ON CONFLICT(doc_id) DO UPDATE SET path = excluded.path, deleted_at = excluded.deleted_at;
120
+ `).run(tombstone.doc_id, tombstone.path || local.path || null, tombstone.deleted_at || Date.now());
121
+ await db.exec("COMMIT;");
122
+ } catch (err) {
123
+ try { await db.exec("ROLLBACK;"); } catch {}
124
+ throw err;
125
+ }
126
+ await cleanupOrphanBlob(db, local.blob_hash);
127
+ return "deleted";
128
+ }
129
+
130
+ async function removeConflictingLocalDocument(db, doc) {
131
+ const conflict = await db.prepare("SELECT id, blob_hash, updated_at FROM documents WHERE path = ? AND id != ?;").get(doc.path, doc.id);
132
+ if (!conflict) return null;
133
+ if (Number(conflict.updated_at || 0) > Number(doc.updated_at || 0)) {
134
+ return { blocked: true, conflict };
135
+ }
136
+ await clearLocalDocumentRelations(db, conflict.id);
137
+ await db.prepare("DELETE FROM documents WHERE id = ?;").run(conflict.id);
138
+ return { blocked: false, conflict };
139
+ }
140
+
141
+ async function applyRemoteDocumentBundle(db, bundle) {
142
+ const doc = bundle.document;
143
+ const local = await db.prepare("SELECT id, path, blob_hash, updated_at FROM documents WHERE id = ?;").get(doc.id);
144
+ const remoteUpdated = Number(doc.updated_at || 0);
145
+ const localUpdated = Number(local?.updated_at || 0);
146
+
147
+ if (local && localUpdated > remoteUpdated) return { action: "local_newer" };
148
+ if (local && localUpdated === remoteUpdated && local.blob_hash === doc.blob_hash && local.path === doc.path) {
149
+ return { action: "unchanged" };
150
+ }
151
+
152
+ let orphanCandidate = null;
153
+ await db.exec("BEGIN IMMEDIATE;");
154
+ try {
155
+ const conflictResult = await removeConflictingLocalDocument(db, doc);
156
+ if (conflictResult?.blocked) {
157
+ await db.exec("ROLLBACK;");
158
+ return { action: "path_conflict_local_newer" };
159
+ }
160
+ orphanCandidate = conflictResult?.conflict?.blob_hash || null;
161
+
162
+ if (local) await clearLocalDocumentRelations(db, doc.id);
163
+
164
+ await db.prepare(`
165
+ INSERT INTO documents (id, path, blob_hash, title, checksum, toc_json, metadata_json, created_at, updated_at)
166
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
167
+ ON CONFLICT(id) DO UPDATE SET
168
+ path = excluded.path,
169
+ blob_hash = excluded.blob_hash,
170
+ title = excluded.title,
171
+ checksum = excluded.checksum,
172
+ toc_json = excluded.toc_json,
173
+ metadata_json = excluded.metadata_json,
174
+ created_at = excluded.created_at,
175
+ updated_at = excluded.updated_at;
176
+ `).run(doc.id, doc.path, doc.blob_hash, doc.title, doc.checksum, doc.toc_json || null, doc.metadata_json || null, doc.created_at || Date.now(), doc.updated_at || Date.now());
177
+
178
+ await db.prepare("DELETE FROM rag_document_tombstones WHERE doc_id = ? OR path = ?;").run(doc.id, doc.path);
179
+
180
+ const scopes = bundle.document_scopes.length ? bundle.document_scopes : [{ scope_key: "global", created_at: doc.created_at || Date.now() }];
181
+ for (const scope of scopes) {
182
+ await db.prepare("INSERT OR IGNORE INTO document_scopes (doc_id, scope_key, created_at) VALUES (?, ?, ?);").run(doc.id, scope.scope_key || "global", scope.created_at || Date.now());
183
+ }
184
+
185
+ for (const section of bundle.sections) {
186
+ await db.prepare("INSERT INTO sections (id, doc_id, heading, breadcrumbs, content, token_count) VALUES (?, ?, ?, ?, ?, ?);").run(section.id, doc.id, section.heading || null, section.breadcrumbs || null, section.content || "", Number(section.token_count || 0));
187
+ }
188
+ for (const medium of bundle.medium_chunks) {
189
+ await db.prepare("INSERT INTO medium_chunks (id, section_id, doc_id, content, block_type, token_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?);").run(medium.id, medium.section_id, doc.id, medium.content || "", medium.block_type || "paragraph", Number(medium.token_count || 0), medium.created_at || Date.now());
190
+ }
191
+
192
+ const sectionBreadcrumbs = new Map(bundle.sections.map((section) => [section.id, section.breadcrumbs || ""]));
193
+ for (const chunk of bundle.micro_chunks) {
194
+ const bytes = toVectorBytes(chunk.vector);
195
+ const vector = bytes ? Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength) : Buffer.alloc(0);
196
+ await db.prepare(`INSERT INTO micro_chunks
197
+ (id, section_id, doc_id, content, vector, token_count, medium_id, retrieval_policy, policy_source_id)
198
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`).run(chunk.id, chunk.section_id, doc.id, chunk.content || "", vector, Number(chunk.token_count || 0), chunk.medium_id || null, chunk.retrieval_policy || "micro_chunk", chunk.policy_source_id || null);
199
+ await db.prepare("INSERT INTO micro_chunks_fts (id, content, breadcrumbs) VALUES (?, ?, ?);").run(chunk.id, chunk.content || "", sectionBreadcrumbs.get(chunk.section_id) || "");
200
+ }
201
+
202
+ for (const edge of bundle.graph_edges) {
203
+ await db.prepare(`INSERT OR REPLACE INTO graph_edges
204
+ (source_id, target_id, relation_type, metadata_json, created_at)
205
+ VALUES (?, ?, ?, ?, ?);`).run(edge.source_id, edge.target_id, edge.relation_type, edge.metadata_json || null, edge.created_at || null);
206
+ }
207
+ for (const link of bundle.knowledge_links) {
208
+ await db.prepare(`INSERT OR REPLACE INTO knowledge_links
209
+ (id, fact_key, fact_text, doc_id, section_id, start_line, end_line, relation_type, metadata_json, created_at)
210
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`).run(link.id, link.fact_key, link.fact_text, doc.id, link.section_id || null, link.start_line || null, link.end_line || null, link.relation_type || "LINKS_TO", link.metadata_json || null, link.created_at || Date.now());
211
+ }
212
+
213
+ await db.exec("COMMIT;");
214
+ } catch (err) {
215
+ try { await db.exec("ROLLBACK;"); } catch {}
216
+ throw err;
217
+ }
218
+
219
+ if (orphanCandidate) await cleanupOrphanBlob(db, orphanCandidate);
220
+ if (local?.blob_hash && local.blob_hash !== doc.blob_hash) await cleanupOrphanBlob(db, local.blob_hash);
221
+ return { action: local ? "updated" : "pulled" };
222
+ }
223
+
224
+ export async function pullRagFromCloud(db) {
225
+ if (!db || db.mode !== "hybrid-sync" || !db.cloudClient) return { skipped: true };
226
+
227
+ const tombRes = await executeCloud(db, "SELECT * FROM rag_document_tombstones ORDER BY deleted_at ASC;");
228
+ const tombstones = tombRes.rows || [];
229
+ const tombById = new Map(tombstones.map((t) => [t.doc_id, t]));
230
+ const tombByPath = new Map(tombstones.filter((t) => t.path).map((t) => [t.path, t]));
231
+
232
+ const docsRes = await executeCloud(db, "SELECT * FROM documents ORDER BY updated_at ASC;");
233
+ const docs = docsRes.rows || [];
234
+ const summary = {
235
+ remoteDocuments: docs.length,
236
+ remoteTombstones: tombstones.length,
237
+ pulled: 0,
238
+ updated: 0,
239
+ deleted: 0,
240
+ unchanged: 0,
241
+ localNewer: 0,
242
+ pathConflicts: 0,
243
+ blobsMaterialized: 0,
244
+ missingBlobs: 0,
245
+ errors: 0,
246
+ };
247
+
248
+ for (const tombstone of tombstones) {
249
+ try {
250
+ const action = await applyRemoteTombstone(db, tombstone);
251
+ if (action === "deleted") summary.deleted++;
252
+ else if (action === "local_newer") summary.localNewer++;
253
+ } catch (err) {
254
+ summary.errors++;
255
+ console.warn(`Failed to apply RAG tombstone ${tombstone.doc_id}: ${err.message}`);
256
+ }
257
+ }
258
+
259
+ for (const doc of docs) {
260
+ try {
261
+ const tombstone = tombById.get(doc.id) || tombByPath.get(doc.path);
262
+ if (tombstone && Number(tombstone.deleted_at || 0) >= Number(doc.updated_at || 0)) {
263
+ continue;
264
+ }
265
+
266
+ const bundle = await fetchRemoteDocumentBundle(db, doc);
267
+ const result = await applyRemoteDocumentBundle(db, bundle);
268
+ if (result.action === "pulled") summary.pulled++;
269
+ else if (result.action === "updated") summary.updated++;
270
+ else if (result.action === "unchanged") summary.unchanged++;
271
+ else if (result.action === "local_newer") summary.localNewer++;
272
+ else if (result.action === "path_conflict_local_newer") summary.pathConflicts++;
273
+
274
+ const blobResult = await materializeBlobFromCloud(db, doc.blob_hash);
275
+ if (blobResult.materialized) summary.blobsMaterialized++;
276
+ else if (blobResult.reason === "cloud_blob_missing") summary.missingBlobs++;
277
+ } catch (err) {
278
+ summary.errors++;
279
+ console.warn(`Failed to reverse-sync RAG document ${doc.id}: ${err.message}`);
280
+ }
281
+ }
282
+
283
+ return summary;
284
+ }