@lotargo/memory_plugin 1.4.601 → 1.4.620
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/mcp-server/cli.js +26 -0
- package/mcp-server/config/config_manager.js +1 -0
- package/mcp-server/db/sync_queue.js +168 -0
- package/mcp-server/memory.js +27 -2
- package/package.json +1 -1
package/mcp-server/cli.js
CHANGED
|
@@ -806,6 +806,12 @@ export async function runCli() {
|
|
|
806
806
|
value: "cloud_mode",
|
|
807
807
|
info: "Choose Operational Mode: only-local | only-cloud | hybrid-sync",
|
|
808
808
|
},
|
|
809
|
+
{
|
|
810
|
+
label: "Conflict Strategy",
|
|
811
|
+
badge: (config.conflictStrategy || "merge").toUpperCase(),
|
|
812
|
+
value: "conflict_strategy",
|
|
813
|
+
info: "How hybrid-sync resolves differing local vs cloud stores: merge | cloud-wins | local-wins",
|
|
814
|
+
},
|
|
809
815
|
],
|
|
810
816
|
},
|
|
811
817
|
{
|
|
@@ -1872,6 +1878,26 @@ export async function runCli() {
|
|
|
1872
1878
|
}
|
|
1873
1879
|
break;
|
|
1874
1880
|
}
|
|
1881
|
+
case "conflict_strategy": {
|
|
1882
|
+
const strategyItems = [
|
|
1883
|
+
{ label: "merge (Union local + cloud)", value: "merge", info: "Facts from both sides are merged and deduplicated — no data loss (recommended)" },
|
|
1884
|
+
{ label: "cloud-wins (Cloud overwrites local)", value: "cloud-wins", info: "On conflict, the cloud copy replaces the local store" },
|
|
1885
|
+
{ label: "local-wins (Local overwrites cloud)", value: "local-wins", info: "On conflict, the local copy replaces the cloud store" },
|
|
1886
|
+
];
|
|
1887
|
+
const initialIdx = Math.max(0, strategyItems.findIndex((i) => i.value === (config.conflictStrategy || "merge")));
|
|
1888
|
+
const subRes = await selectSimpleMenu({
|
|
1889
|
+
title: "CHOOSE CONFLICT STRATEGY",
|
|
1890
|
+
subtitle: "How hybrid-sync resolves differing local vs cloud stores",
|
|
1891
|
+
items: strategyItems,
|
|
1892
|
+
initialIndex: initialIdx,
|
|
1893
|
+
});
|
|
1894
|
+
|
|
1895
|
+
if (subRes.action === "select") {
|
|
1896
|
+
updateConfig({ conflictStrategy: subRes.value });
|
|
1897
|
+
console.log(`\n [OK] Conflict strategy set to: ${subRes.value}`);
|
|
1898
|
+
}
|
|
1899
|
+
break;
|
|
1900
|
+
}
|
|
1875
1901
|
case "enable_prompt": {
|
|
1876
1902
|
const { enableGlobalPrompt } = await import("./prompt_manager.js");
|
|
1877
1903
|
const results = await enableGlobalPrompt();
|
|
@@ -15,6 +15,7 @@ export const DEFAULT_CONFIG = {
|
|
|
15
15
|
onnxThreads: 0, // ONNX WASM threads: 0 = auto-detect CPU cores, or 1-16
|
|
16
16
|
executionDevice: "cpu", // "cpu" | "webgpu"
|
|
17
17
|
mode: "only-local", // "only-local" | "only-cloud" | "hybrid-sync"
|
|
18
|
+
conflictStrategy: "merge", // "merge" | "cloud-wins" | "local-wins"
|
|
18
19
|
tursoUrl: "", // Connection endpoint URL for Turso DB
|
|
19
20
|
failoverUrl: "", // Failover connection endpoint URL (Fly.io + LiteFS)
|
|
20
21
|
authorized: false, // True once the user completed cloud login (token stored encrypted)
|
|
@@ -1,5 +1,15 @@
|
|
|
1
|
+
import { readFile, readdir } from "fs/promises";
|
|
2
|
+
import { join, basename } from "path";
|
|
3
|
+
import { MEMORY_DIR, GLOBAL_KEY, buildMemoryContent, extractFacts, writeMemoryFile, storeFilePath, memoryFileName } from "../memory.js";
|
|
4
|
+
|
|
1
5
|
let isSyncing = false;
|
|
2
6
|
|
|
7
|
+
// Reverse sync (cloud -> local) throttling: only pull at most once per window
|
|
8
|
+
// even if readMemory triggers it frequently (recall hits every keystroke).
|
|
9
|
+
let lastReverseSync = 0;
|
|
10
|
+
let isReverseSyncing = false;
|
|
11
|
+
const REVERSE_SYNC_INTERVAL_MS = 5000;
|
|
12
|
+
|
|
3
13
|
async function processSyncTask(db, task) {
|
|
4
14
|
if (task.action === "write_memory") {
|
|
5
15
|
await db.cloudClient.execute({
|
|
@@ -162,6 +172,161 @@ export async function enqueueSyncTask(action, keyOrId, payload = null) {
|
|
|
162
172
|
});
|
|
163
173
|
}
|
|
164
174
|
|
|
175
|
+
// Map a store key to its local file path, mirroring memory.js naming.
|
|
176
|
+
function localFilePath(key) {
|
|
177
|
+
return join(MEMORY_DIR, memoryFileName(key));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Enumerate local store files as { key, path }.
|
|
181
|
+
async function enumerateLocalStores() {
|
|
182
|
+
const files = await readdir(MEMORY_DIR).catch(() => []);
|
|
183
|
+
const stores = [];
|
|
184
|
+
for (const f of files) {
|
|
185
|
+
if (!f.endsWith(".md")) continue;
|
|
186
|
+
const fp = join(MEMORY_DIR, f);
|
|
187
|
+
let content = "";
|
|
188
|
+
try {
|
|
189
|
+
content = await readFile(fp, "utf-8");
|
|
190
|
+
} catch (e) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const meta = content.match(/<!-- path: (.+?) -->/);
|
|
194
|
+
const key = f === `${GLOBAL_KEY}.md` ? GLOBAL_KEY : (meta ? meta[1].trim() : f.slice(0, -3));
|
|
195
|
+
stores.push({ key, path: fp, file: f });
|
|
196
|
+
}
|
|
197
|
+
return stores;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Reverse sync: pull cloud state down to local stores, resolving conflicts
|
|
201
|
+
// according to config.conflictStrategy ("merge" | "cloud-wins" | "local-wins").
|
|
202
|
+
//
|
|
203
|
+
// Returns a summary of what happened for diagnostics.
|
|
204
|
+
async function pullFromCloud(db) {
|
|
205
|
+
const { getConfig } = await import("../config/config_manager.js");
|
|
206
|
+
const config = getConfig();
|
|
207
|
+
const strategy = config.conflictStrategy || "merge";
|
|
208
|
+
|
|
209
|
+
const summary = { pulled: 0, pushed: 0, merged: 0, cloudWins: 0, localWins: 0, unchanged: 0, conflicts: 0 };
|
|
210
|
+
|
|
211
|
+
// 1. Enumerate cloud notebooks. In hybrid-sync the wrapper's prepare() routes
|
|
212
|
+
// to the LOCAL sqlite, so cloud reads/writes must go through cloudClient directly.
|
|
213
|
+
const cloudRes = await db.cloudClient.execute("SELECT key, content FROM notebooks;");
|
|
214
|
+
const cloudRows = cloudRes.rows || [];
|
|
215
|
+
const cloudByKey = new Map(cloudRows.map((r) => [r.key, r.content || ""]));
|
|
216
|
+
|
|
217
|
+
// 2. Enumerate local store files.
|
|
218
|
+
const localStores = await enumerateLocalStores();
|
|
219
|
+
const localByKey = new Map(localStores.map((s) => [s.key, s.path]));
|
|
220
|
+
const localContentByKey = new Map();
|
|
221
|
+
for (const s of localStores) {
|
|
222
|
+
try {
|
|
223
|
+
localContentByKey.set(s.key, await readFile(s.path, "utf-8"));
|
|
224
|
+
} catch (e) {}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const allKeys = new Set([...cloudByKey.keys(), ...localByKey.keys()]);
|
|
228
|
+
|
|
229
|
+
// Upsert a notebook row directly on the cloud client.
|
|
230
|
+
const upsertCloud = async (key, content) => {
|
|
231
|
+
await db.cloudClient.execute({
|
|
232
|
+
sql: `
|
|
233
|
+
INSERT INTO notebooks (key, content, updated_at)
|
|
234
|
+
VALUES (?, ?, ?)
|
|
235
|
+
ON CONFLICT(key) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at;
|
|
236
|
+
`,
|
|
237
|
+
args: [key, content, Date.now()],
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// 3. Reconcile each key.
|
|
242
|
+
for (const key of allKeys) {
|
|
243
|
+
const cloudContent = cloudByKey.get(key);
|
|
244
|
+
const localPath = localByKey.get(key);
|
|
245
|
+
const localContent = localContentByKey.get(key) || "";
|
|
246
|
+
|
|
247
|
+
const cloudFacts = cloudContent !== undefined ? extractFacts(cloudContent) : null;
|
|
248
|
+
const localFacts = extractFacts(localContent);
|
|
249
|
+
const cloudHas = cloudFacts !== null && cloudFacts.length > 0;
|
|
250
|
+
const localHas = localFacts.length > 0;
|
|
251
|
+
|
|
252
|
+
if (cloudFacts === null) {
|
|
253
|
+
// Store exists only locally -> push up.
|
|
254
|
+
if (localHas) {
|
|
255
|
+
await upsertCloud(key, localContent);
|
|
256
|
+
summary.pushed++;
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!localHas) {
|
|
262
|
+
// Store exists only in cloud -> pull down.
|
|
263
|
+
if (cloudHas) {
|
|
264
|
+
await writeMemoryFile(key, cloudContent);
|
|
265
|
+
summary.pulled++;
|
|
266
|
+
}
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Both exist.
|
|
271
|
+
if (localContent === cloudContent) {
|
|
272
|
+
summary.unchanged++;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
summary.conflicts++;
|
|
277
|
+
if (strategy === "cloud-wins") {
|
|
278
|
+
await writeMemoryFile(key, cloudContent);
|
|
279
|
+
summary.cloudWins++;
|
|
280
|
+
} else if (strategy === "local-wins") {
|
|
281
|
+
await upsertCloud(key, localContent);
|
|
282
|
+
summary.localWins++;
|
|
283
|
+
} else {
|
|
284
|
+
// merge: union of fact lines, deduped, local order first then cloud-only.
|
|
285
|
+
const seen = new Set();
|
|
286
|
+
const mergedFacts = [];
|
|
287
|
+
for (const l of [...localFacts, ...cloudFacts]) {
|
|
288
|
+
if (!seen.has(l)) {
|
|
289
|
+
seen.add(l);
|
|
290
|
+
mergedFacts.push(l);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const mergedContent = buildMemoryContent(key, mergedFacts);
|
|
294
|
+
await writeMemoryFile(key, mergedContent);
|
|
295
|
+
await upsertCloud(key, mergedContent);
|
|
296
|
+
summary.merged++;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return summary;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Trigger a reverse sync now (regardless of throttle). Used after the push queue
|
|
304
|
+
// drains so both directions stay in sync.
|
|
305
|
+
export async function syncFromCloud() {
|
|
306
|
+
if (isReverseSyncing) return { skipped: true };
|
|
307
|
+
isReverseSyncing = true;
|
|
308
|
+
try {
|
|
309
|
+
const { getDatabase } = await import("./database.js");
|
|
310
|
+
const db = await getDatabase();
|
|
311
|
+
if (db.mode !== "hybrid-sync" || !db.cloudClient) return { skipped: true };
|
|
312
|
+
lastReverseSync = Date.now();
|
|
313
|
+
return await pullFromCloud(db);
|
|
314
|
+
} finally {
|
|
315
|
+
isReverseSyncing = false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Throttled reverse sync, safe to call on every recall/read.
|
|
320
|
+
export async function ensureReverseSync() {
|
|
321
|
+
if (Date.now() - lastReverseSync < REVERSE_SYNC_INTERVAL_MS) return { throttled: true };
|
|
322
|
+
return syncFromCloud();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Reset the reverse-sync throttle (used by tests and manual syncs).
|
|
326
|
+
export function resetReverseSyncThrottle() {
|
|
327
|
+
lastReverseSync = 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
165
330
|
export async function triggerBackgroundSync() {
|
|
166
331
|
if (isSyncing) return;
|
|
167
332
|
isSyncing = true;
|
|
@@ -203,6 +368,9 @@ export async function triggerBackgroundSync() {
|
|
|
203
368
|
break;
|
|
204
369
|
}
|
|
205
370
|
}
|
|
371
|
+
|
|
372
|
+
// Push queue drained — now pull cloud state back down (reverse sync).
|
|
373
|
+
await syncFromCloud();
|
|
206
374
|
} catch (err) {
|
|
207
375
|
console.error("Error during background sync execution:", err.message);
|
|
208
376
|
} finally {
|
package/mcp-server/memory.js
CHANGED
|
@@ -142,6 +142,15 @@ export async function readMemory(key) {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
const fp = memoryPath(key);
|
|
145
|
+
if (config.mode === "hybrid-sync") {
|
|
146
|
+
// Pull cloud state down first so cloud-only records appear locally.
|
|
147
|
+
try {
|
|
148
|
+
const { ensureReverseSync } = await import("./db/sync_queue.js");
|
|
149
|
+
await ensureReverseSync();
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error("Failed to reverse-sync before read:", err.message);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
145
154
|
if (existsSync(fp)) {
|
|
146
155
|
const content = await readFile(fp, "utf-8");
|
|
147
156
|
return content.split("\n").filter((l) => l.startsWith("- ["));
|
|
@@ -154,7 +163,8 @@ export async function readMemoryRaw(key) {
|
|
|
154
163
|
return (await readMemory(key)).map((e) => e.slice(2));
|
|
155
164
|
}
|
|
156
165
|
|
|
157
|
-
|
|
166
|
+
// Build the markdown store content for a key from a list of fact lines.
|
|
167
|
+
export function buildMemoryContent(key, entries) {
|
|
158
168
|
const lines = [];
|
|
159
169
|
if (key === GLOBAL_KEY) {
|
|
160
170
|
lines.push("# Global Memory", "");
|
|
@@ -164,7 +174,22 @@ export async function writeMemory(key, entries) {
|
|
|
164
174
|
lines.push(`<!-- path: ${key} -->`, "");
|
|
165
175
|
}
|
|
166
176
|
}
|
|
167
|
-
|
|
177
|
+
return lines.join("\n") + "\n" + (entries.length ? entries.join("\n") + "\n" : "");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Extract fact lines (`- [date] ...`) from a store content string.
|
|
181
|
+
export function extractFacts(content) {
|
|
182
|
+
return (content || "").split("\n").filter((l) => l.startsWith("- ["));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Write a store file directly to disk WITHOUT enqueueing a cloud sync task.
|
|
186
|
+
// Used by the sync worker to apply pulled cloud state without re-queueing.
|
|
187
|
+
export async function writeMemoryFile(key, content) {
|
|
188
|
+
await writeFile(memoryPath(key), content);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function writeMemory(key, entries) {
|
|
192
|
+
const content = buildMemoryContent(key, entries);
|
|
168
193
|
|
|
169
194
|
const { getConfig } = await import("./config/config_manager.js");
|
|
170
195
|
const config = getConfig();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotargo/memory_plugin",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.620",
|
|
4
4
|
"description": "100% local hybrid RAG memory for AI coding agents (OpenCode, Claude Code, Codex, Antigravity). MCP server + plugin: persistent user facts, document ingestion, vector + SQLite FTS5 retrieval across sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "opencode-plugin/index.js",
|