@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.
- package/CHANGELOG.md +34 -0
- package/README.md +576 -443
- package/mcp-server/benchmarks/fetch_real_corpus.js +351 -0
- package/mcp-server/benchmarks/gpu_profile_benchmark.js +170 -0
- package/mcp-server/benchmarks/policy_dominance_test.js +221 -0
- package/mcp-server/benchmarks/quality_evaluator.js +598 -0
- package/mcp-server/benchmarks/raw_corpus_data.js +613 -0
- package/mcp-server/benchmarks/run_benchmarks.js +366 -0
- package/mcp-server/benchmarks/stress_ingestion.js +195 -0
- package/mcp-server/benchmarks/table_code_retrieval.js +453 -0
- package/mcp-server/benchmarks/test_dual_layer.js +141 -0
- package/mcp-server/cli/direct_commands.js +39 -0
- package/mcp-server/cli.js +16 -5
- package/mcp-server/cli_boot.js +4 -1
- package/mcp-server/client_cli.js +73 -0
- package/mcp-server/client_paths.js +44 -0
- package/mcp-server/client_registration.js +38 -0
- package/mcp-server/codex_config.js +86 -8
- package/mcp-server/db/database.js +14 -21
- package/mcp-server/db/migrations.js +66 -77
- package/mcp-server/db/rag_blob_transport.js +143 -0
- package/mcp-server/db/rag_sync.js +284 -0
- package/mcp-server/db/sync_queue.js +219 -307
- package/mcp-server/dev_link.js +142 -0
- package/mcp-server/fact_format.js +44 -12
- package/mcp-server/index.js +17 -7
- package/mcp-server/ingest/exporter.js +44 -38
- package/mcp-server/ingest/pipeline.js +260 -248
- package/mcp-server/persona_migration.js +39 -0
- package/mcp-server/prompt_manager.js +162 -55
- package/mcp-server/rag_scope.js +83 -0
- package/mcp-server/retrieval/retriever.js +99 -64
- package/mcp-server/setup.js +150 -100
- package/mcp-server/storage/blob_store.js +53 -1
- package/mcp-server/tools/core/knowledge_read_core.js +163 -0
- package/mcp-server/tools/core/memory_core.js +24 -4
- package/mcp-server/tools/core/memory_routing.js +10 -0
- package/mcp-server/tools/core/note_core.js +53 -0
- package/mcp-server/tools/core/rag_query_core.js +169 -0
- package/mcp-server/tools/index.js +11 -9
- package/mcp-server/tools/memory_tools.js +4 -1
- package/mcp-server/tools/note_tools.js +35 -0
- package/mcp-server/tools/rag_tools.js +211 -364
- package/mcp-server/uninstall.js +627 -0
- package/opencode-plugin/index.js +80 -12
- package/opencode-plugin/main.js +136 -0
- package/package.json +17 -34
- 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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
OR target_id
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
OR target_id
|
|
75
|
-
OR source_id IN (SELECT id FROM
|
|
76
|
-
OR target_id IN (SELECT id FROM
|
|
77
|
-
OR source_id IN (SELECT id FROM
|
|
78
|
-
OR target_id IN (SELECT id FROM
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
|
262
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
343
|
-
if (!seen.has(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
386
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
}
|