@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
@@ -2,12 +2,16 @@ import { readFile, readdir } from "fs/promises";
2
2
  import { join } from "path";
3
3
  import { MEMORY_DIR, GLOBAL_KEY, buildMemoryContent, extractFacts, writeMemoryFile } from "../memory.js";
4
4
  import { toVectorBytes } from "../retrieval/retriever.js";
5
+ import { pushBlobToCloud, deleteCloudBlobIfUnreferenced } from "./rag_blob_transport.js";
6
+ import {
7
+ pullRagFromCloud,
8
+ recordCloudDocumentTombstone,
9
+ clearCloudDocumentTombstone,
10
+ } from "./rag_sync.js";
5
11
 
6
12
  let isSyncing = false;
7
13
  let syncRequested = false;
8
-
9
- // Reverse sync (cloud -> local) throttling: only pull at most once per window
10
- // even if readMemory triggers it frequently (recall hits every keystroke).
14
+ let activeSyncPromise = null;
11
15
  let lastReverseSync = 0;
12
16
  let isReverseSyncing = false;
13
17
  const REVERSE_SYNC_INTERVAL_MS = 5000;
@@ -15,42 +19,58 @@ const REVERSE_SYNC_INTERVAL_MS = 5000;
15
19
  async function processSyncTask(db, task) {
16
20
  if (task.action === "write_memory") {
17
21
  await db.cloudClient.execute({
18
- sql: `
19
- INSERT INTO notebooks (key, content, updated_at)
20
- VALUES (?, ?, ?)
21
- ON CONFLICT(key) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at;
22
- `,
22
+ sql: `INSERT INTO notebooks (key, content, updated_at)
23
+ VALUES (?, ?, ?)
24
+ ON CONFLICT(key) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at;`,
23
25
  args: [task.key_or_id, task.payload, task.created_at],
24
26
  });
25
27
  return;
26
28
  }
27
29
 
28
30
  if (task.action === "delete_document") {
29
- const docId = task.key_or_id;
31
+ let payload = {};
32
+ try { payload = task.payload ? JSON.parse(task.payload) : {}; } catch {}
33
+ const key = task.key_or_id;
34
+ const hintedPath = payload.path || null;
30
35
  const docRow = await db.cloudClient.execute({
31
- sql: "SELECT id FROM documents WHERE id = ? OR path = ?;",
32
- args: [docId, docId],
36
+ sql: "SELECT id, path, blob_hash FROM documents WHERE id = ? OR path = ? OR (? IS NOT NULL AND path = ?);",
37
+ args: [key, key, hintedPath, hintedPath],
33
38
  });
34
- if (docRow.rows.length > 0) {
35
- const realDocId = docRow.rows[0].id;
36
- await db.cloudClient.execute({
37
- sql: `DELETE FROM graph_edges
38
- WHERE source_id = ? OR target_id = ?
39
- OR target_id GLOB ?
40
- OR source_id IN (SELECT id FROM sections WHERE doc_id = ?)
41
- OR target_id IN (SELECT id FROM sections WHERE doc_id = ?)
42
- OR source_id IN (SELECT id FROM medium_chunks WHERE doc_id = ?)
43
- OR target_id IN (SELECT id FROM medium_chunks WHERE doc_id = ?)
44
- OR source_id IN (SELECT id FROM micro_chunks WHERE doc_id = ?)
45
- OR target_id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);`,
46
- args: [realDocId, realDocId, `${realDocId}:L*`, realDocId, realDocId, realDocId, realDocId, realDocId, realDocId],
47
- });
48
- await db.cloudClient.execute({ sql: "DELETE FROM micro_chunks_fts WHERE id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);", args: [realDocId] });
39
+
40
+ let realDocId = String(key || "").startsWith("doc_") ? key : null;
41
+ let realPath = hintedPath;
42
+ let blobHash = null;
43
+ if (docRow.rows.length > 0) {
44
+ realDocId = docRow.rows[0].id;
45
+ realPath = docRow.rows[0].path || realPath;
46
+ blobHash = docRow.rows[0].blob_hash || null;
47
+ await db.cloudClient.execute({
48
+ sql: `DELETE FROM graph_edges
49
+ WHERE source_id = ? OR target_id = ?
50
+ OR target_id GLOB ?
51
+ OR source_id IN (SELECT id FROM sections WHERE doc_id = ?)
52
+ OR target_id IN (SELECT id FROM sections WHERE doc_id = ?)
53
+ OR source_id IN (SELECT id FROM medium_chunks WHERE doc_id = ?)
54
+ OR target_id IN (SELECT id FROM medium_chunks WHERE doc_id = ?)
55
+ OR source_id IN (SELECT id FROM micro_chunks WHERE doc_id = ?)
56
+ OR target_id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);`,
57
+ args: [realDocId, realDocId, `${realDocId}:L*`, realDocId, realDocId, realDocId, realDocId, realDocId, realDocId],
58
+ });
59
+ await db.cloudClient.execute({ sql: "DELETE FROM micro_chunks_fts WHERE id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);", args: [realDocId] });
49
60
  await db.cloudClient.execute({ sql: "DELETE FROM micro_chunks WHERE doc_id = ?;", args: [realDocId] });
50
61
  await db.cloudClient.execute({ sql: "DELETE FROM medium_chunks WHERE doc_id = ?;", args: [realDocId] });
51
62
  await db.cloudClient.execute({ sql: "DELETE FROM sections WHERE doc_id = ?;", args: [realDocId] });
52
63
  await db.cloudClient.execute({ sql: "DELETE FROM knowledge_links WHERE doc_id = ?;", args: [realDocId] });
53
64
  await db.cloudClient.execute({ sql: "DELETE FROM documents WHERE id = ?;", args: [realDocId] });
65
+ if (blobHash) await deleteCloudBlobIfUnreferenced(db, blobHash);
66
+ }
67
+
68
+ if (realDocId) {
69
+ await recordCloudDocumentTombstone(db, {
70
+ docId: realDocId,
71
+ path: realPath,
72
+ deletedAt: payload.deletedAt || task.created_at || Date.now(),
73
+ });
54
74
  }
55
75
  return;
56
76
  }
@@ -58,181 +78,121 @@ async function processSyncTask(db, task) {
58
78
  if (task.action === "ingest_document") {
59
79
  const data = JSON.parse(task.payload);
60
80
  const doc = data.document;
81
+ await pushBlobToCloud(db, doc.blob_hash);
61
82
 
62
- // 1. Delete existing doc from cloud if any
63
83
  const existingDocRow = await db.cloudClient.execute({
64
- sql: "SELECT id FROM documents WHERE id = ? OR path = ?;",
84
+ sql: "SELECT id, blob_hash FROM documents WHERE id = ? OR path = ?;",
65
85
  args: [doc.id, doc.path],
66
86
  });
67
- if (existingDocRow.rows.length > 0) {
68
- const realDocId = existingDocRow.rows[0].id;
69
- await db.cloudClient.execute({
70
- sql: `DELETE FROM graph_edges
71
- WHERE source_id = ? OR target_id = ?
72
- OR target_id GLOB ?
73
- OR source_id IN (SELECT id FROM sections WHERE doc_id = ?)
74
- OR target_id IN (SELECT id FROM sections WHERE doc_id = ?)
75
- OR source_id IN (SELECT id FROM medium_chunks WHERE doc_id = ?)
76
- OR target_id IN (SELECT id FROM medium_chunks WHERE doc_id = ?)
77
- OR source_id IN (SELECT id FROM micro_chunks WHERE doc_id = ?)
78
- OR target_id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);`,
79
- args: [realDocId, realDocId, `${realDocId}:L*`, realDocId, realDocId, realDocId, realDocId, realDocId, realDocId],
80
- });
81
- await db.cloudClient.execute({ sql: "DELETE FROM micro_chunks_fts WHERE id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);", args: [realDocId] });
87
+ let previousBlobHash = null;
88
+ if (existingDocRow.rows.length > 0) {
89
+ const realDocId = existingDocRow.rows[0].id;
90
+ previousBlobHash = existingDocRow.rows[0].blob_hash || null;
91
+ await db.cloudClient.execute({
92
+ sql: `DELETE FROM graph_edges
93
+ WHERE source_id = ? OR target_id = ?
94
+ OR target_id GLOB ?
95
+ OR source_id IN (SELECT id FROM sections WHERE doc_id = ?)
96
+ OR target_id IN (SELECT id FROM sections WHERE doc_id = ?)
97
+ OR source_id IN (SELECT id FROM medium_chunks WHERE doc_id = ?)
98
+ OR target_id IN (SELECT id FROM medium_chunks WHERE doc_id = ?)
99
+ OR source_id IN (SELECT id FROM micro_chunks WHERE doc_id = ?)
100
+ OR target_id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);`,
101
+ args: [realDocId, realDocId, `${realDocId}:L*`, realDocId, realDocId, realDocId, realDocId, realDocId, realDocId],
102
+ });
103
+ await db.cloudClient.execute({ sql: "DELETE FROM micro_chunks_fts WHERE id IN (SELECT id FROM micro_chunks WHERE doc_id = ?);", args: [realDocId] });
82
104
  await db.cloudClient.execute({ sql: "DELETE FROM micro_chunks WHERE doc_id = ?;", args: [realDocId] });
83
105
  await db.cloudClient.execute({ sql: "DELETE FROM medium_chunks WHERE doc_id = ?;", args: [realDocId] });
84
106
  await db.cloudClient.execute({ sql: "DELETE FROM sections WHERE doc_id = ?;", args: [realDocId] });
85
- await db.cloudClient.execute({ sql: "DELETE FROM knowledge_links WHERE doc_id = ?;", args: [realDocId] });
107
+ await db.cloudClient.execute({ sql: "DELETE FROM knowledge_links WHERE doc_id = ?;", args: [realDocId] });
86
108
  await db.cloudClient.execute({ sql: "DELETE FROM documents WHERE id = ?;", args: [realDocId] });
87
109
  }
88
110
 
89
- // 2. Insert document
90
111
  await db.cloudClient.execute({
91
- sql: `
92
- INSERT INTO documents (id, path, blob_hash, title, checksum, toc_json, metadata_json, created_at, updated_at)
93
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
94
- `,
95
- args: [
96
- doc.id,
97
- doc.path,
98
- doc.blob_hash,
99
- doc.title,
100
- doc.checksum,
112
+ sql: `INSERT INTO documents (id, path, blob_hash, title, checksum, toc_json, metadata_json, created_at, updated_at)
113
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`,
114
+ args: [doc.id, doc.path, doc.blob_hash, doc.title, doc.checksum,
101
115
  doc.toc_json ? (typeof doc.toc_json === "string" ? doc.toc_json : JSON.stringify(doc.toc_json)) : null,
102
116
  doc.metadata_json ? (typeof doc.metadata_json === "string" ? doc.metadata_json : JSON.stringify(doc.metadata_json)) : null,
103
- doc.created_at,
104
- doc.updated_at,
105
- ],
106
- });
107
-
108
- const scopes = Array.isArray(data.document_scopes) && data.document_scopes.length
109
- ? data.document_scopes
110
- : [{ scope_key: "global", created_at: doc.created_at }];
111
- for (const scope of scopes) {
112
- await db.cloudClient.execute({
113
- sql: "INSERT OR IGNORE INTO document_scopes (doc_id, scope_key, created_at) VALUES (?, ?, ?);",
114
- args: [doc.id, scope.scope_key || "global", scope.created_at || Date.now()],
115
- });
116
- }
117
+ doc.created_at, doc.updated_at],
118
+ });
117
119
 
118
- // 3. Insert sections
119
- if (Array.isArray(data.sections)) {
120
- for (const s of data.sections) {
121
- await db.cloudClient.execute({
122
- sql: "INSERT INTO sections (id, doc_id, heading, breadcrumbs, content, token_count) VALUES (?, ?, ?, ?, ?, ?);",
123
- args: [s.id, doc.id, s.heading, s.breadcrumbs, s.content, s.token_count],
124
- });
125
- }
120
+ const scopes = Array.isArray(data.document_scopes) && data.document_scopes.length
121
+ ? data.document_scopes
122
+ : [{ scope_key: "global", created_at: doc.created_at }];
123
+ for (const scope of scopes) {
124
+ await db.cloudClient.execute({
125
+ sql: "INSERT OR IGNORE INTO document_scopes (doc_id, scope_key, created_at) VALUES (?, ?, ?);",
126
+ args: [doc.id, scope.scope_key || "global", scope.created_at || Date.now()],
127
+ });
126
128
  }
127
129
 
128
- // 4. Insert medium_chunks
129
- if (Array.isArray(data.medium_chunks)) {
130
- for (const m of data.medium_chunks) {
131
- await db.cloudClient.execute({
132
- sql: "INSERT INTO medium_chunks (id, section_id, doc_id, content, block_type, token_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?);",
133
- args: [m.id, m.section_id, doc.id, m.content, m.block_type, m.token_count, m.created_at || Date.now()],
134
- });
135
- }
130
+ for (const s of data.sections || []) {
131
+ await db.cloudClient.execute({
132
+ sql: "INSERT INTO sections (id, doc_id, heading, breadcrumbs, content, token_count) VALUES (?, ?, ?, ?, ?, ?);",
133
+ args: [s.id, doc.id, s.heading, s.breadcrumbs, s.content, s.token_count],
134
+ });
136
135
  }
137
-
138
- // 5. Insert micro_chunks & FTS
139
- if (Array.isArray(data.micro_chunks)) {
140
- for (const mc of data.micro_chunks) {
141
- // node:sqlite hands back Uint8Array, which matched none of the old
142
- // branches — hybrid-sync silently pushed EMPTY vectors to the cloud.
143
- const vecBytes = toVectorBytes(mc.vector);
144
- const vecBuf = vecBytes
145
- ? Buffer.from(vecBytes.buffer, vecBytes.byteOffset, vecBytes.byteLength)
146
- : Buffer.alloc(0);
136
+ for (const m of data.medium_chunks || []) {
137
+ await db.cloudClient.execute({
138
+ sql: "INSERT INTO medium_chunks (id, section_id, doc_id, content, block_type, token_count, created_at) VALUES (?, ?, ?, ?, ?, ?, ?);",
139
+ args: [m.id, m.section_id, doc.id, m.content, m.block_type, m.token_count, m.created_at || Date.now()],
140
+ });
141
+ }
142
+ for (const mc of data.micro_chunks || []) {
143
+ const vecBytes = toVectorBytes(mc.vector);
144
+ const vecBuf = vecBytes ? Buffer.from(vecBytes.buffer, vecBytes.byteOffset, vecBytes.byteLength) : Buffer.alloc(0);
145
+ await db.cloudClient.execute({
146
+ sql: "INSERT INTO micro_chunks (id, section_id, doc_id, content, vector, token_count, medium_id, retrieval_policy, policy_source_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);",
147
+ args: [mc.id, mc.section_id, doc.id, mc.content, vecBuf, mc.token_count, mc.medium_id || null, mc.retrieval_policy || "micro_chunk", mc.policy_source_id || null],
148
+ });
149
+ try {
147
150
  await db.cloudClient.execute({
148
- sql: "INSERT INTO micro_chunks (id, section_id, doc_id, content, vector, token_count, medium_id, retrieval_policy, policy_source_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);",
149
- args: [
150
- mc.id,
151
- mc.section_id,
152
- doc.id,
153
- mc.content,
154
- vecBuf,
155
- mc.token_count,
156
- mc.medium_id || null,
157
- mc.retrieval_policy || "micro_chunk",
158
- mc.policy_source_id || null,
159
- ],
151
+ sql: "INSERT INTO micro_chunks_fts (id, content, breadcrumbs) VALUES (?, ?, ?);",
152
+ args: [mc.id, mc.content, mc.breadcrumbs || ""],
160
153
  });
161
-
162
- // Insert into remote FTS
163
- try {
164
- await db.cloudClient.execute({
165
- sql: "INSERT INTO micro_chunks_fts (id, content, breadcrumbs) VALUES (?, ?, ?);",
166
- args: [mc.id, mc.content, mc.breadcrumbs || ""],
167
- });
168
- } catch (ftsErr) {
169
- console.warn("FTS insertion failed on cloud:", ftsErr.message);
170
- }
154
+ } catch (ftsErr) {
155
+ console.warn("FTS insertion failed on cloud:", ftsErr.message);
171
156
  }
172
157
  }
158
+ for (const e of data.graph_edges || []) {
159
+ await db.cloudClient.execute({
160
+ sql: "INSERT OR IGNORE INTO graph_edges (source_id, target_id, relation_type, metadata_json, created_at) VALUES (?, ?, ?, ?, ?);",
161
+ args: [e.source_id, e.target_id, e.relation_type, e.metadata_json ? (typeof e.metadata_json === "string" ? e.metadata_json : JSON.stringify(e.metadata_json)) : null, e.created_at || Date.now()],
162
+ });
163
+ }
164
+ for (const link of data.knowledge_links || []) {
165
+ await db.cloudClient.execute({
166
+ sql: `INSERT OR REPLACE INTO knowledge_links
167
+ (id, fact_key, fact_text, doc_id, section_id, start_line, end_line, relation_type, metadata_json, created_at)
168
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
169
+ args: [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()],
170
+ });
171
+ }
173
172
 
174
- // 6. Insert graph_edges
175
- if (Array.isArray(data.graph_edges)) {
176
- for (const e of data.graph_edges) {
177
- await db.cloudClient.execute({
178
- sql: "INSERT OR IGNORE INTO graph_edges (source_id, target_id, relation_type, metadata_json, created_at) VALUES (?, ?, ?, ?, ?);",
179
- args: [e.source_id, e.target_id, e.relation_type, e.metadata_json ? (typeof e.metadata_json === "string" ? e.metadata_json : JSON.stringify(e.metadata_json)) : null, e.created_at || Date.now()],
180
- });
181
- }
182
- }
183
-
184
- if (Array.isArray(data.knowledge_links)) {
185
- for (const link of data.knowledge_links) {
186
- await db.cloudClient.execute({
187
- sql: `INSERT OR REPLACE INTO knowledge_links
188
- (id, fact_key, fact_text, doc_id, section_id, start_line, end_line, relation_type, metadata_json, created_at)
189
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
190
- args: [
191
- link.id,
192
- link.fact_key,
193
- link.fact_text,
194
- doc.id,
195
- link.section_id || null,
196
- link.start_line || null,
197
- link.end_line || null,
198
- link.relation_type || "LINKS_TO",
199
- link.metadata_json || null,
200
- link.created_at || Date.now(),
201
- ],
202
- });
203
- }
204
- }
205
- }
206
- }
173
+ await clearCloudDocumentTombstone(db, { docId: doc.id, path: doc.path });
174
+ if (previousBlobHash && previousBlobHash !== doc.blob_hash) {
175
+ await deleteCloudBlobIfUnreferenced(db, previousBlobHash);
176
+ }
177
+ }
178
+ }
207
179
 
208
180
  export async function enqueueSyncTask(action, keyOrId, payload = null) {
209
181
  const { getDatabase } = await import("./database.js");
210
182
  const db = await getDatabase();
211
- if (db.mode === "only-cloud") return; // No need to queue in only-cloud mode
212
-
213
- // Ensure queue table exists
214
- await db.exec(`
215
- CREATE TABLE IF NOT EXISTS sync_queue (
216
- id INTEGER PRIMARY KEY AUTOINCREMENT,
217
- action TEXT NOT NULL,
218
- key_or_id TEXT NOT NULL,
219
- payload TEXT,
220
- created_at INTEGER NOT NULL
221
- );
222
- `);
223
-
224
- await db.prepare(`
225
- INSERT INTO sync_queue (action, key_or_id, payload, created_at)
226
- VALUES (?, ?, ?, ?);
227
- `).run(action, keyOrId, payload ? (typeof payload === "string" ? payload : JSON.stringify(payload)) : null, Date.now());
228
-
229
- // Trigger background sync worker asynchronously
230
- triggerBackgroundSync().catch((err) => {
231
- console.error("Background sync trigger error:", err.message);
232
- });
183
+ if (db.mode === "only-cloud") return;
184
+ await db.exec(`CREATE TABLE IF NOT EXISTS sync_queue (
185
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
186
+ action TEXT NOT NULL,
187
+ key_or_id TEXT NOT NULL,
188
+ payload TEXT,
189
+ created_at INTEGER NOT NULL
190
+ );`);
191
+ await db.prepare(`INSERT INTO sync_queue (action, key_or_id, payload, created_at) VALUES (?, ?, ?, ?);`)
192
+ .run(action, keyOrId, payload ? (typeof payload === "string" ? payload : JSON.stringify(payload)) : null, Date.now());
193
+ triggerBackgroundSync().catch((err) => console.error("Background sync trigger error:", err.message));
233
194
  }
234
195
 
235
- // Enumerate local store files as { key, path }.
236
196
  async function enumerateLocalStores() {
237
197
  const files = await readdir(MEMORY_DIR).catch(() => []);
238
198
  const stores = [];
@@ -240,11 +200,7 @@ async function enumerateLocalStores() {
240
200
  if (!f.endsWith(".md")) continue;
241
201
  const fp = join(MEMORY_DIR, f);
242
202
  let content = "";
243
- try {
244
- content = await readFile(fp, "utf-8");
245
- } catch (e) {
246
- continue;
247
- }
203
+ try { content = await readFile(fp, "utf-8"); } catch { continue; }
248
204
  const meta = content.match(/<!-- path: (.+?) -->/);
249
205
  const key = f === `${GLOBAL_KEY}.md` ? GLOBAL_KEY : (meta ? meta[1].trim() : f.slice(0, -3));
250
206
  stores.push({ key, path: fp, file: f });
@@ -252,111 +208,76 @@ async function enumerateLocalStores() {
252
208
  return stores;
253
209
  }
254
210
 
255
- // Reverse sync: pull cloud state down to local stores, resolving conflicts
256
- // according to config.conflictStrategy ("merge" | "cloud-wins" | "local-wins").
257
- //
258
- // Returns a summary of what happened for diagnostics.
259
- async function pullFromCloud(db) {
211
+ async function pullFromCloud(db) {
260
212
  const { getConfig } = await import("../config/config_manager.js");
261
- const config = getConfig();
262
- const strategy = config.conflictStrategy || "merge";
263
-
264
- const summary = { pulled: 0, pushed: 0, merged: 0, cloudWins: 0, localWins: 0, unchanged: 0, conflicts: 0 };
265
-
266
- // 1. Enumerate cloud notebooks. In hybrid-sync the wrapper's prepare() routes
267
- // to the LOCAL sqlite, so cloud reads/writes must go through cloudClient directly.
213
+ const strategy = getConfig().conflictStrategy || "merge";
214
+ const summary = { pulled: 0, pushed: 0, merged: 0, cloudWins: 0, localWins: 0, unchanged: 0, conflicts: 0 };
215
+ let globalChanged = false;
268
216
  const cloudRes = await db.cloudClient.execute("SELECT key, content FROM notebooks;");
269
217
  const cloudRows = cloudRes.rows || [];
270
218
  const cloudByKey = new Map(cloudRows.map((r) => [r.key, r.content || ""]));
271
-
272
- // 2. Enumerate local store files.
273
219
  const localStores = await enumerateLocalStores();
274
220
  const localByKey = new Map(localStores.map((s) => [s.key, s.path]));
275
221
  const localContentByKey = new Map();
276
222
  for (const s of localStores) {
277
- try {
278
- localContentByKey.set(s.key, await readFile(s.path, "utf-8"));
279
- } catch (e) {}
223
+ try { localContentByKey.set(s.key, await readFile(s.path, "utf-8")); } catch {}
280
224
  }
281
-
282
225
  const allKeys = new Set([...cloudByKey.keys(), ...localByKey.keys()]);
226
+ const upsertCloud = async (key, content) => db.cloudClient.execute({
227
+ sql: `INSERT INTO notebooks (key, content, updated_at) VALUES (?, ?, ?)
228
+ ON CONFLICT(key) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at;`,
229
+ args: [key, content, Date.now()],
230
+ });
283
231
 
284
- // Upsert a notebook row directly on the cloud client.
285
- const upsertCloud = async (key, content) => {
286
- await db.cloudClient.execute({
287
- sql: `
288
- INSERT INTO notebooks (key, content, updated_at)
289
- VALUES (?, ?, ?)
290
- ON CONFLICT(key) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at;
291
- `,
292
- args: [key, content, Date.now()],
293
- });
294
- };
295
-
296
- // 3. Reconcile each key.
297
232
  for (const key of allKeys) {
298
233
  const cloudContent = cloudByKey.get(key);
299
- const localPath = localByKey.get(key);
300
234
  const localContent = localContentByKey.get(key) || "";
301
-
302
235
  const cloudFacts = cloudContent !== undefined ? extractFacts(cloudContent) : null;
303
236
  const localFacts = extractFacts(localContent);
304
237
  const cloudHas = cloudFacts !== null && cloudFacts.length > 0;
305
238
  const localHas = localFacts.length > 0;
306
-
307
239
  if (cloudFacts === null) {
308
- // Store exists only locally -> push up.
309
- if (localHas) {
310
- await upsertCloud(key, localContent);
311
- summary.pushed++;
312
- }
240
+ if (localHas) { await upsertCloud(key, localContent); summary.pushed++; }
313
241
  continue;
314
242
  }
315
-
316
243
  if (!localHas) {
317
- // Store exists only in cloud -> pull down.
318
- if (cloudHas) {
319
- await writeMemoryFile(key, cloudContent);
320
- summary.pulled++;
321
- }
322
- continue;
323
- }
324
-
325
- // Both exist.
326
- if (localContent === cloudContent) {
327
- summary.unchanged++;
244
+ if (cloudHas) {
245
+ await writeMemoryFile(key, cloudContent);
246
+ if (key === GLOBAL_KEY) globalChanged = true;
247
+ summary.pulled++;
248
+ }
328
249
  continue;
329
250
  }
330
-
251
+ if (localContent === cloudContent) { summary.unchanged++; continue; }
331
252
  summary.conflicts++;
332
- if (strategy === "cloud-wins") {
333
- await writeMemoryFile(key, cloudContent);
334
- summary.cloudWins++;
335
- } else if (strategy === "local-wins") {
336
- await upsertCloud(key, localContent);
337
- summary.localWins++;
338
- } else {
339
- // merge: union of fact lines, deduped, local order first then cloud-only.
253
+ if (strategy === "cloud-wins") {
254
+ await writeMemoryFile(key, cloudContent);
255
+ if (key === GLOBAL_KEY) globalChanged = true;
256
+ summary.cloudWins++;
257
+ }
258
+ else if (strategy === "local-wins") { await upsertCloud(key, localContent); summary.localWins++; }
259
+ else {
340
260
  const seen = new Set();
341
261
  const mergedFacts = [];
342
- for (const l of [...localFacts, ...cloudFacts]) {
343
- if (!seen.has(l)) {
344
- seen.add(l);
345
- mergedFacts.push(l);
346
- }
262
+ for (const line of [...localFacts, ...cloudFacts]) {
263
+ if (!seen.has(line)) { seen.add(line); mergedFacts.push(line); }
347
264
  }
348
265
  const mergedContent = buildMemoryContent(key, mergedFacts);
349
- await writeMemoryFile(key, mergedContent);
266
+ await writeMemoryFile(key, mergedContent);
267
+ if (key === GLOBAL_KEY) globalChanged = true;
350
268
  await upsertCloud(key, mergedContent);
351
269
  summary.merged++;
352
270
  }
353
- }
354
-
355
- return summary;
356
- }
271
+ }
272
+ if (globalChanged && process.env.MEMORY_DISABLE_PERSONA_SYNC !== "1") {
273
+ try {
274
+ const { syncPersonaPrompts } = await import("../prompt_manager.js");
275
+ await syncPersonaPrompts();
276
+ } catch {}
277
+ }
278
+ return summary;
279
+ }
357
280
 
358
- // Trigger a reverse sync now (regardless of throttle). Used after the push queue
359
- // drains so both directions stay in sync.
360
281
  export async function syncFromCloud() {
361
282
  if (isReverseSyncing) return { skipped: true };
362
283
  isReverseSyncing = true;
@@ -365,79 +286,70 @@ export async function syncFromCloud() {
365
286
  const db = await getDatabase();
366
287
  if (db.mode !== "hybrid-sync" || !db.cloudClient) return { skipped: true };
367
288
  lastReverseSync = Date.now();
368
- return await pullFromCloud(db);
289
+ const notebook = await pullFromCloud(db);
290
+ const rag = await pullRagFromCloud(db);
291
+ return { ...notebook, rag };
369
292
  } finally {
370
293
  isReverseSyncing = false;
371
294
  }
372
295
  }
373
296
 
374
- // Throttled reverse sync, safe to call on every recall/read.
375
297
  export async function ensureReverseSync() {
376
298
  if (Date.now() - lastReverseSync < REVERSE_SYNC_INTERVAL_MS) return { throttled: true };
377
299
  return syncFromCloud();
378
300
  }
379
301
 
380
- // Reset the reverse-sync throttle (used by tests and manual syncs).
381
302
  export function resetReverseSyncThrottle() {
382
303
  lastReverseSync = 0;
383
304
  }
384
305
 
385
- export async function triggerBackgroundSync() {
386
- if (isSyncing) {
306
+ async function runBackgroundSyncPass() {
307
+ const { getDatabase } = await import("./database.js");
308
+ const db = await getDatabase();
309
+ if (db.mode !== "hybrid-sync" || !db.cloudClient) return;
310
+ await db.exec(`CREATE TABLE IF NOT EXISTS sync_queue (
311
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
312
+ action TEXT NOT NULL,
313
+ key_or_id TEXT NOT NULL,
314
+ payload TEXT,
315
+ created_at INTEGER NOT NULL
316
+ );`);
317
+ let syncFailed = false;
318
+ while (!syncFailed) {
319
+ const tasks = await db.prepare("SELECT * FROM sync_queue ORDER BY id ASC LIMIT 50;").all();
320
+ if (tasks.length === 0) break;
321
+ for (const task of tasks) {
322
+ try {
323
+ await processSyncTask(db, task);
324
+ await db.prepare("DELETE FROM sync_queue WHERE id = ?;").run(task.id);
325
+ } catch (err) {
326
+ console.error(`Failed to process sync task ${task.id} (${task.action}):`, err.message, err.stack);
327
+ syncFailed = true;
328
+ break;
329
+ }
330
+ }
331
+ }
332
+ await syncFromCloud();
333
+ }
334
+
335
+ export function triggerBackgroundSync() {
336
+ if (activeSyncPromise) {
387
337
  syncRequested = true;
388
- return;
338
+ return activeSyncPromise;
389
339
  }
390
340
  isSyncing = true;
391
- syncRequested = false;
392
-
393
- try {
394
- const { getDatabase } = await import("./database.js");
395
- const db = await getDatabase();
396
- if (db.mode !== "hybrid-sync" || !db.cloudClient) {
397
- isSyncing = false;
398
- return;
399
- }
400
-
401
- // Ensure table exists
402
- await db.exec(`
403
- CREATE TABLE IF NOT EXISTS sync_queue (
404
- id INTEGER PRIMARY KEY AUTOINCREMENT,
405
- action TEXT NOT NULL,
406
- key_or_id TEXT NOT NULL,
407
- payload TEXT,
408
- created_at INTEGER NOT NULL
409
- );
410
- `);
411
-
412
- // Drain until empty. New tasks can be enqueued while a batch is running;
413
- // stopping after one snapshot of the queue left those tasks stranded until
414
- // an unrelated future write happened to wake the worker again.
415
- let syncFailed = false;
416
- while (!syncFailed) {
417
- const tasks = await db.prepare("SELECT * FROM sync_queue ORDER BY id ASC LIMIT 50;").all();
418
- if (tasks.length === 0) break;
419
-
420
- for (const task of tasks) {
421
- try {
422
- await processSyncTask(db, task);
423
- await db.prepare("DELETE FROM sync_queue WHERE id = ?;").run(task.id);
424
- } catch (err) {
425
- console.error(`Failed to process sync task ${task.id} (${task.action}):`, err.message, err.stack);
426
- syncFailed = true;
427
- break;
428
- }
429
- }
341
+ activeSyncPromise = (async () => {
342
+ try {
343
+ do {
344
+ syncRequested = false;
345
+ await runBackgroundSyncPass();
346
+ } while (syncRequested);
347
+ } catch (err) {
348
+ console.error("Error during background sync execution:", err.message);
349
+ } finally {
350
+ isSyncing = false;
351
+ activeSyncPromise = null;
430
352
  }
431
-
432
- // Push queue drained — now pull cloud state back down (reverse sync).
433
- await syncFromCloud();
434
- } catch (err) {
435
- console.error("Error during background sync execution:", err.message);
436
- } finally {
437
- isSyncing = false;
438
- if (syncRequested) {
439
- syncRequested = false;
440
- await triggerBackgroundSync();
441
- }
442
- }
353
+ })();
354
+ return activeSyncPromise;
443
355
  }