@lotargo/memory_plugin 1.6.5 → 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 (48) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +576 -443
  3. package/mcp-server/benchmarks/fetch_real_corpus.js +351 -0
  4. package/mcp-server/benchmarks/gpu_profile_benchmark.js +170 -0
  5. package/mcp-server/benchmarks/policy_dominance_test.js +221 -0
  6. package/mcp-server/benchmarks/quality_evaluator.js +598 -0
  7. package/mcp-server/benchmarks/raw_corpus_data.js +613 -0
  8. package/mcp-server/benchmarks/run_benchmarks.js +366 -0
  9. package/mcp-server/benchmarks/stress_ingestion.js +195 -0
  10. package/mcp-server/benchmarks/table_code_retrieval.js +453 -0
  11. package/mcp-server/benchmarks/test_dual_layer.js +141 -0
  12. package/mcp-server/cli/direct_commands.js +39 -0
  13. package/mcp-server/cli.js +16 -5
  14. package/mcp-server/cli_boot.js +4 -1
  15. package/mcp-server/client_cli.js +73 -0
  16. package/mcp-server/client_paths.js +44 -0
  17. package/mcp-server/client_registration.js +38 -0
  18. package/mcp-server/codex_config.js +86 -8
  19. package/mcp-server/db/database.js +14 -21
  20. package/mcp-server/db/migrations.js +66 -77
  21. package/mcp-server/db/rag_blob_transport.js +143 -0
  22. package/mcp-server/db/rag_sync.js +284 -0
  23. package/mcp-server/db/sync_queue.js +219 -307
  24. package/mcp-server/dev_link.js +142 -0
  25. package/mcp-server/fact_format.js +44 -12
  26. package/mcp-server/index.js +17 -7
  27. package/mcp-server/ingest/exporter.js +44 -38
  28. package/mcp-server/ingest/pipeline.js +260 -248
  29. package/mcp-server/persona_migration.js +39 -0
  30. package/mcp-server/prompt_manager.js +162 -55
  31. package/mcp-server/rag_scope.js +83 -0
  32. package/mcp-server/retrieval/retriever.js +99 -64
  33. package/mcp-server/setup.js +150 -100
  34. package/mcp-server/storage/blob_store.js +53 -1
  35. package/mcp-server/tools/core/knowledge_read_core.js +163 -0
  36. package/mcp-server/tools/core/memory_core.js +24 -4
  37. package/mcp-server/tools/core/memory_routing.js +10 -0
  38. package/mcp-server/tools/core/note_core.js +53 -0
  39. package/mcp-server/tools/core/rag_query_core.js +169 -0
  40. package/mcp-server/tools/index.js +11 -9
  41. package/mcp-server/tools/memory_tools.js +4 -1
  42. package/mcp-server/tools/note_tools.js +35 -0
  43. package/mcp-server/tools/rag_tools.js +211 -364
  44. package/mcp-server/uninstall.js +627 -0
  45. package/opencode-plugin/index.js +80 -12
  46. package/opencode-plugin/main.js +136 -0
  47. package/package.json +17 -34
  48. package/skills/using-memory/SKILL.md +28 -19
@@ -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
+ }