mr-memory 3.2.2 → 3.4.0
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/index.ts +71 -172
- package/package.json +2 -3
- package/upload.ts +13 -52
- package/sync.ts +0 -367
package/index.ts
CHANGED
|
@@ -10,11 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
import { readFile, readdir, stat, lstat } from "node:fs/promises";
|
|
12
12
|
import { join, resolve, relative, isAbsolute, sep } from "node:path";
|
|
13
|
-
import { homedir } from "node:os";
|
|
14
13
|
import path from "node:path";
|
|
15
14
|
import { spawn } from "node:child_process";
|
|
16
15
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
17
|
-
import { syncWorkspaceFiles, runSyncCli, showManifestStatus } from "./sync.js";
|
|
18
16
|
|
|
19
17
|
const DEFAULT_ENDPOINT = "https://api.memoryrouter.ai";
|
|
20
18
|
|
|
@@ -273,6 +271,7 @@ type MemoryRouterConfig = {
|
|
|
273
271
|
mode?: "relay" | "proxy";
|
|
274
272
|
logging?: boolean;
|
|
275
273
|
embeddings?: string;
|
|
274
|
+
agentKeys?: Record<string, string>;
|
|
276
275
|
};
|
|
277
276
|
|
|
278
277
|
// ──────────────────────────────────────────────────────
|
|
@@ -382,51 +381,38 @@ const memoryRouterPlugin = {
|
|
|
382
381
|
const mode = cfg?.mode || "relay";
|
|
383
382
|
const logging = cfg?.logging ?? false;
|
|
384
383
|
const embeddings = cfg?.embeddings; // undefined = bge (default), "qwen" = qwen3-8b
|
|
384
|
+
const agentKeys = cfg?.agentKeys;
|
|
385
385
|
const log = (msg: string) => { if (logging) api.logger.info?.(msg); };
|
|
386
386
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
387
|
+
// Resolve the correct memory key for a given agent context.
|
|
388
|
+
// Multi-agent gateways: each agent gets its own key via agentKeys map.
|
|
389
|
+
// Single-agent: falls back to the global key.
|
|
390
|
+
function resolveKey(ctx: any): string | undefined {
|
|
391
|
+
if (agentKeys && ctx?.workspaceDir) {
|
|
392
|
+
// Derive agentId from workspace path: ~/.openclaw/workspace-rex → "rex"
|
|
393
|
+
// Default workspace (~/.openclaw/workspace) → "main"
|
|
394
|
+
const dirName = ctx.workspaceDir.split("/").pop() || "";
|
|
395
|
+
const agentId = dirName === "workspace" ? "main" : dirName.replace(/^workspace-/, "");
|
|
396
|
+
if (agentId && agentKeys[agentId]) return agentKeys[agentId];
|
|
397
|
+
}
|
|
398
|
+
return memoryKey;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const hasAnyKey = !!(memoryKey || (agentKeys && Object.keys(agentKeys).length > 0));
|
|
402
|
+
|
|
403
|
+
if (hasAnyKey) {
|
|
404
|
+
if (memoryKey) api.logger.info?.(`memoryrouter: active (key: ${memoryKey.slice(0, 6)}...)`);
|
|
405
|
+
if (agentKeys) api.logger.info?.(`memoryrouter: ${Object.keys(agentKeys).length} agent key(s) configured`);
|
|
390
406
|
} else {
|
|
391
407
|
api.logger.info?.("memoryrouter: no key configured — run: openclaw mr <key>");
|
|
392
408
|
api.logger.info?.("memoryrouter: get your free API key at https://memoryrouter.ai");
|
|
393
409
|
}
|
|
394
410
|
|
|
395
|
-
// ==================================================================
|
|
396
|
-
// Workspace sync (debounced, fire-and-forget)
|
|
397
|
-
// ==================================================================
|
|
398
|
-
|
|
399
|
-
let lastSyncMs = 0;
|
|
400
|
-
const SYNC_DEBOUNCE_MS = 60_000; // At most once per 60 seconds
|
|
401
|
-
|
|
402
|
-
const triggerSync = (workspaceDir: string) => {
|
|
403
|
-
if (!memoryKey) return;
|
|
404
|
-
const now = Date.now();
|
|
405
|
-
if (now - lastSyncMs < SYNC_DEBOUNCE_MS) return;
|
|
406
|
-
lastSyncMs = now;
|
|
407
|
-
syncWorkspaceFiles({ workspaceDir, endpoint, memoryKey, embeddings, logging })
|
|
408
|
-
.then((result) => {
|
|
409
|
-
if (result.uploaded > 0 || result.deleted > 0) {
|
|
410
|
-
api.logger.info?.(
|
|
411
|
-
`memoryrouter: sync complete — ${result.uploaded} uploaded, ${result.deleted} deleted, ${result.unchanged} unchanged`,
|
|
412
|
-
);
|
|
413
|
-
} else {
|
|
414
|
-
log(`memoryrouter: sync complete — ${result.unchanged} files unchanged`);
|
|
415
|
-
}
|
|
416
|
-
if (result.errors.length > 0) {
|
|
417
|
-
api.logger.warn?.(`memoryrouter: sync had ${result.errors.length} errors`);
|
|
418
|
-
}
|
|
419
|
-
})
|
|
420
|
-
.catch((err) => {
|
|
421
|
-
log(`memoryrouter: sync error — ${err instanceof Error ? err.message : String(err)}`);
|
|
422
|
-
});
|
|
423
|
-
};
|
|
424
|
-
|
|
425
411
|
// ==================================================================
|
|
426
412
|
// Core: before_agent_start — search memories, inject context
|
|
427
413
|
// ==================================================================
|
|
428
414
|
|
|
429
|
-
if (
|
|
415
|
+
if (hasAnyKey) {
|
|
430
416
|
// Track whether we've already fired for this prompt (dedup double-fire)
|
|
431
417
|
let lastPreparedPrompt = "";
|
|
432
418
|
// Track whether before_prompt_build already handled the first call in this run
|
|
@@ -437,7 +423,6 @@ const memoryRouterPlugin = {
|
|
|
437
423
|
// When PR #24122 merges, OpenClaw will use the returned prependContext.
|
|
438
424
|
// This gives forward compatibility — no plugin update needed.
|
|
439
425
|
api.on("llm_input", async (event, ctx) => {
|
|
440
|
-
log(`memoryrouter: llm_input fired (sessionKey=${ctx.sessionKey}, promptBuildFired=${promptBuildFiredThisRun})`);
|
|
441
426
|
// Skip the first call — before_prompt_build already handled it
|
|
442
427
|
// (before_prompt_build includes workspace+tools+skills for accurate billing)
|
|
443
428
|
if (promptBuildFiredThisRun) {
|
|
@@ -445,7 +430,11 @@ const memoryRouterPlugin = {
|
|
|
445
430
|
return;
|
|
446
431
|
}
|
|
447
432
|
|
|
433
|
+
const activeKey = resolveKey(ctx);
|
|
434
|
+
if (!activeKey) return; // No key for this agent
|
|
435
|
+
|
|
448
436
|
try {
|
|
437
|
+
const startMs = Date.now();
|
|
449
438
|
const prompt = event.prompt;
|
|
450
439
|
if (prompt === lastPreparedPrompt && lastPreparedPrompt !== "") return;
|
|
451
440
|
lastPreparedPrompt = prompt;
|
|
@@ -484,7 +473,7 @@ const memoryRouterPlugin = {
|
|
|
484
473
|
method: "POST",
|
|
485
474
|
headers: {
|
|
486
475
|
"Content-Type": "application/json",
|
|
487
|
-
Authorization: `Bearer ${
|
|
476
|
+
Authorization: `Bearer ${activeKey}`,
|
|
488
477
|
},
|
|
489
478
|
body: JSON.stringify({
|
|
490
479
|
messages: contextPayload,
|
|
@@ -511,9 +500,8 @@ const memoryRouterPlugin = {
|
|
|
511
500
|
};
|
|
512
501
|
|
|
513
502
|
if (data.context) {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
);
|
|
503
|
+
const elapsed = Date.now() - startMs;
|
|
504
|
+
api.logger.info?.(`memoryrouter: injected ${data.memories_found || 0} memories (${data.memory_tokens || 0} tokens, ${elapsed}ms) [llm_input]`);
|
|
517
505
|
const wrapped = wrapForInjection(data.context);
|
|
518
506
|
return { appendSystemContext: wrapped };
|
|
519
507
|
}
|
|
@@ -525,12 +513,10 @@ const memoryRouterPlugin = {
|
|
|
525
513
|
// ── before_prompt_build: fires once per run (primary, includes full billing context)
|
|
526
514
|
api.on("before_prompt_build", async (event, ctx) => {
|
|
527
515
|
promptBuildFiredThisRun = true;
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
// Trigger workspace sync (fire-and-forget, debounced — catches new files mid-run)
|
|
531
|
-
if (ctx.workspaceDir) triggerSync(ctx.workspaceDir);
|
|
532
|
-
|
|
516
|
+
const activeKey = resolveKey(ctx);
|
|
517
|
+
if (!activeKey) return; // No key for this agent
|
|
533
518
|
try {
|
|
519
|
+
const startMs = Date.now();
|
|
534
520
|
const prompt = event.prompt;
|
|
535
521
|
|
|
536
522
|
// Deduplicate — if we already prepared this exact prompt, skip
|
|
@@ -599,7 +585,7 @@ const memoryRouterPlugin = {
|
|
|
599
585
|
method: "POST",
|
|
600
586
|
headers: {
|
|
601
587
|
"Content-Type": "application/json",
|
|
602
|
-
Authorization: `Bearer ${
|
|
588
|
+
Authorization: `Bearer ${activeKey}`,
|
|
603
589
|
},
|
|
604
590
|
body: JSON.stringify({
|
|
605
591
|
messages: contextPayload,
|
|
@@ -625,16 +611,11 @@ const memoryRouterPlugin = {
|
|
|
625
611
|
memory_tokens?: number;
|
|
626
612
|
};
|
|
627
613
|
|
|
628
|
-
log(`memoryrouter: prepare response — memories_found=${data.memories_found || 0}, context_length=${data.context?.length || 0}, tokens=${data.memory_tokens || 0}, embeddings=${embeddings || 'bge'}`);
|
|
629
|
-
|
|
630
614
|
if (data.context) {
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
);
|
|
615
|
+
const elapsed = Date.now() - startMs;
|
|
616
|
+
api.logger.info?.(`memoryrouter: injected ${data.memories_found || 0} memories (${data.memory_tokens || 0} tokens, ${elapsed}ms)`);
|
|
634
617
|
const wrapped = wrapForInjection(data.context);
|
|
635
618
|
return { appendSystemContext: wrapped };
|
|
636
|
-
} else {
|
|
637
|
-
log(`memoryrouter: prepare returned no context (memories_found=${data.memories_found || 0})`);
|
|
638
619
|
}
|
|
639
620
|
} catch (err) {
|
|
640
621
|
log(
|
|
@@ -648,6 +629,8 @@ const memoryRouterPlugin = {
|
|
|
648
629
|
// ==================================================================
|
|
649
630
|
|
|
650
631
|
api.on("agent_end", async (event, ctx) => {
|
|
632
|
+
const activeKey = resolveKey(ctx);
|
|
633
|
+
if (!activeKey) return; // No key for this agent
|
|
651
634
|
try {
|
|
652
635
|
const msgs = event.messages;
|
|
653
636
|
if (!msgs || !Array.isArray(msgs) || msgs.length === 0) return;
|
|
@@ -720,7 +703,7 @@ const memoryRouterPlugin = {
|
|
|
720
703
|
method: "POST",
|
|
721
704
|
headers: {
|
|
722
705
|
"Content-Type": "application/json",
|
|
723
|
-
Authorization: `Bearer ${
|
|
706
|
+
Authorization: `Bearer ${activeKey}`,
|
|
724
707
|
},
|
|
725
708
|
body: JSON.stringify({
|
|
726
709
|
messages: toStore,
|
|
@@ -754,6 +737,7 @@ const memoryRouterPlugin = {
|
|
|
754
737
|
|
|
755
738
|
// memory_search — calls MR /v1/memory/search
|
|
756
739
|
api.registerTool((ctx) => {
|
|
740
|
+
const toolKey = resolveKey(ctx);
|
|
757
741
|
return {
|
|
758
742
|
label: "Memory Search",
|
|
759
743
|
name: "memory_search",
|
|
@@ -768,14 +752,15 @@ const memoryRouterPlugin = {
|
|
|
768
752
|
required: ["query"],
|
|
769
753
|
} as any,
|
|
770
754
|
execute: async (_toolCallId: string, params: Record<string, unknown>) => {
|
|
755
|
+
if (!toolKey) return jsonToolResult({ results: [], error: "No memory key configured for this agent" });
|
|
771
756
|
const query = typeof params.query === "string" ? params.query.trim() : "";
|
|
772
757
|
if (!query) return jsonToolResult({ results: [], error: "query required" });
|
|
773
|
-
const limit = typeof params.maxResults === "number" ? params.maxResults :
|
|
758
|
+
const limit = typeof params.maxResults === "number" ? params.maxResults : 50;
|
|
774
759
|
try {
|
|
775
760
|
const res = await fetch(`${endpoint}/v1/memory/search`, {
|
|
776
761
|
method: "POST",
|
|
777
762
|
headers: {
|
|
778
|
-
Authorization: `Bearer ${
|
|
763
|
+
Authorization: `Bearer ${toolKey}`,
|
|
779
764
|
"Content-Type": "application/json",
|
|
780
765
|
...(embeddings && { "X-Embedding-Model": embeddings }),
|
|
781
766
|
},
|
|
@@ -850,7 +835,7 @@ const memoryRouterPlugin = {
|
|
|
850
835
|
},
|
|
851
836
|
};
|
|
852
837
|
});
|
|
853
|
-
} // end if (
|
|
838
|
+
} // end if (hasAnyKey)
|
|
854
839
|
|
|
855
840
|
// ==================================================================
|
|
856
841
|
// CLI Commands
|
|
@@ -1011,71 +996,17 @@ const memoryRouterPlugin = {
|
|
|
1011
996
|
}
|
|
1012
997
|
});
|
|
1013
998
|
|
|
1014
|
-
// Mode commands
|
|
1015
|
-
for (const [modeName, modeDesc] of [
|
|
1016
|
-
["relay", "Relay mode — hooks only, works on stock OpenClaw [default]"],
|
|
1017
|
-
["proxy", "Proxy mode — memory on every LLM call (requires registerStreamFnWrapper)"],
|
|
1018
|
-
] as const) {
|
|
1019
|
-
mr.command(modeName)
|
|
1020
|
-
.description(modeDesc)
|
|
1021
|
-
.action(async () => {
|
|
1022
|
-
if (!memoryKey) { console.error("Not configured. Run: openclaw mr <key>"); return; }
|
|
1023
|
-
try {
|
|
1024
|
-
await setPluginConfig(api, { key: memoryKey, endpoint: cfg?.endpoint, density, mode: modeName });
|
|
1025
|
-
console.log(`✓ Mode set to ${modeName}`);
|
|
1026
|
-
} catch (err) {
|
|
1027
|
-
console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1028
|
-
}
|
|
1029
|
-
});
|
|
1030
|
-
}
|
|
1031
999
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
.description("Get or set embedding model (bge, qwen)")
|
|
1035
|
-
.argument("[model]", "Embedding model: bge (default, 1024d) or qwen (4096d)")
|
|
1036
|
-
.action(async (model: string | undefined) => {
|
|
1037
|
-
if (!model) {
|
|
1038
|
-
console.log(`Current embedding model: ${embeddings || "bge (default)"}`);
|
|
1039
|
-
console.log(`\nAvailable models:`);
|
|
1040
|
-
console.log(` bge BGE-M3 1024 dims (Cloudflare Workers AI, ~18ms, free)`);
|
|
1041
|
-
console.log(` qwen Qwen3-8B 4096 dims (HuggingFace, ~69ms, $0.80/hr)`);
|
|
1042
|
-
console.log(`\nUsage: openclaw mr embeddings <model>`);
|
|
1043
|
-
return;
|
|
1044
|
-
}
|
|
1045
|
-
const normalized = model.toLowerCase().trim();
|
|
1046
|
-
const valid = ["bge", "bge-m3", "qwen", "qwen3", "qwen3-8b", "default"];
|
|
1047
|
-
if (!valid.includes(normalized)) {
|
|
1048
|
-
console.error(`Unknown model: "${model}". Valid options: bge, qwen`);
|
|
1049
|
-
return;
|
|
1050
|
-
}
|
|
1051
|
-
if (!memoryKey) { console.error("Not configured. Run: openclaw mr <key>"); return; }
|
|
1052
|
-
// "bge" / "default" → clear embeddings (use server default)
|
|
1053
|
-
const newEmbeddings = (normalized === "bge" || normalized === "bge-m3" || normalized === "default")
|
|
1054
|
-
? undefined
|
|
1055
|
-
: normalized.startsWith("qwen") ? "qwen" : normalized;
|
|
1056
|
-
try {
|
|
1057
|
-
await setPluginConfig(api, {
|
|
1058
|
-
key: memoryKey,
|
|
1059
|
-
endpoint: cfg?.endpoint,
|
|
1060
|
-
density,
|
|
1061
|
-
mode,
|
|
1062
|
-
logging,
|
|
1063
|
-
...(newEmbeddings && { embeddings: newEmbeddings }),
|
|
1064
|
-
});
|
|
1065
|
-
const label = newEmbeddings || "bge (default)";
|
|
1066
|
-
console.log(`✓ Embedding model set to ${label}`);
|
|
1067
|
-
console.log(` All future prepare/ingest/search will use ${label}`);
|
|
1068
|
-
console.log(`\n Restart gateway to apply: openclaw gateway restart`);
|
|
1069
|
-
} catch (err) {
|
|
1070
|
-
console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1071
|
-
}
|
|
1072
|
-
});
|
|
1000
|
+
|
|
1001
|
+
|
|
1073
1002
|
|
|
1074
1003
|
mr.command("status")
|
|
1075
1004
|
.description("Show MemoryRouter vault stats")
|
|
1076
1005
|
.option("--json", "JSON output")
|
|
1077
|
-
.
|
|
1078
|
-
|
|
1006
|
+
.option("--key <key>", "Override memory key (for subagent vaults)")
|
|
1007
|
+
.action(async (opts: { json?: boolean; key?: string }) => {
|
|
1008
|
+
const effectiveKey = opts.key || memoryKey;
|
|
1009
|
+
if (!effectiveKey) {
|
|
1079
1010
|
if (opts.json) {
|
|
1080
1011
|
console.log(JSON.stringify({ enabled: false, key: null }, null, 2));
|
|
1081
1012
|
} else {
|
|
@@ -1092,19 +1023,17 @@ const memoryRouterPlugin = {
|
|
|
1092
1023
|
? `${endpoint}/v1/memory/stats?embeddings=${encodeURIComponent(embeddings)}`
|
|
1093
1024
|
: `${endpoint}/v1/memory/stats`;
|
|
1094
1025
|
const res = await fetch(statsUrl, {
|
|
1095
|
-
headers: { Authorization: `Bearer ${
|
|
1026
|
+
headers: { Authorization: `Bearer ${effectiveKey}` },
|
|
1096
1027
|
});
|
|
1097
1028
|
const data = (await res.json()) as { totalVectors?: number; totalTokens?: number };
|
|
1098
1029
|
if (opts.json) {
|
|
1099
|
-
console.log(JSON.stringify({ enabled: true, key:
|
|
1030
|
+
console.log(JSON.stringify({ enabled: true, key: effectiveKey, density, stats: data }, null, 2));
|
|
1100
1031
|
} else {
|
|
1101
1032
|
console.log("MemoryRouter Status");
|
|
1102
1033
|
console.log("───────────────────────────");
|
|
1103
1034
|
console.log(`Enabled: ✓ Yes`);
|
|
1104
|
-
console.log(`Key: ${
|
|
1105
|
-
console.log(`Mode: ${mode}`);
|
|
1035
|
+
console.log(`Key: ${effectiveKey.slice(0, 6)}...${effectiveKey.slice(-3)}`);
|
|
1106
1036
|
console.log(`Density: ${density}`);
|
|
1107
|
-
console.log(`Embeddings: ${embeddings || "bge (default)"}`);
|
|
1108
1037
|
console.log(`Endpoint: ${endpoint}`);
|
|
1109
1038
|
console.log(`Memories: ${data.totalVectors ?? 0}`);
|
|
1110
1039
|
console.log(`Tokens: ${data.totalTokens ?? 0}`);
|
|
@@ -1119,8 +1048,10 @@ const memoryRouterPlugin = {
|
|
|
1119
1048
|
.argument("[path]", "Specific file or directory to upload")
|
|
1120
1049
|
.option("--workspace <dir>", "Workspace directory")
|
|
1121
1050
|
.option("--brain <dir>", "State directory with sessions")
|
|
1122
|
-
.
|
|
1123
|
-
|
|
1051
|
+
.option("--key <key>", "Override memory key (for subagent vaults)")
|
|
1052
|
+
.action(async (targetPath: string | undefined, opts: { workspace?: string; brain?: string; key?: string }) => {
|
|
1053
|
+
const effectiveKey = opts.key || memoryKey;
|
|
1054
|
+
if (!effectiveKey) { console.error("Not configured. Run: openclaw mr <key> or pass --key <key>"); return; }
|
|
1124
1055
|
const os = await import("node:os");
|
|
1125
1056
|
const path = await import("node:path");
|
|
1126
1057
|
const stateDir = opts.brain ? path.resolve(opts.brain) : path.join(os.homedir(), ".openclaw");
|
|
@@ -1131,22 +1062,24 @@ const memoryRouterPlugin = {
|
|
|
1131
1062
|
? path.resolve(configWorkspace.replace(/^~/, os.homedir()))
|
|
1132
1063
|
: path.join(os.homedir(), ".openclaw", "workspace");
|
|
1133
1064
|
const { runUpload } = await import("./upload.js");
|
|
1134
|
-
await runUpload({ memoryKey, endpoint, targetPath, stateDir, workspacePath, hasWorkspaceFlag: !!opts.workspace, hasBrainFlag: !!opts.brain, embeddings });
|
|
1065
|
+
await runUpload({ memoryKey: effectiveKey, endpoint, targetPath, stateDir, workspacePath, hasWorkspaceFlag: !!opts.workspace, hasBrainFlag: !!opts.brain, embeddings });
|
|
1135
1066
|
});
|
|
1136
1067
|
|
|
1137
1068
|
mr.command("search")
|
|
1138
1069
|
.description("Search memories in vault")
|
|
1139
1070
|
.argument("<query>", "Search query")
|
|
1140
|
-
.option("-n, --limit <number>", "Number of results", "
|
|
1071
|
+
.option("-n, --limit <number>", "Number of results", "50")
|
|
1141
1072
|
.option("--json", "Output raw JSON response")
|
|
1142
|
-
.
|
|
1143
|
-
|
|
1144
|
-
const
|
|
1073
|
+
.option("--key <key>", "Override memory key (for subagent vaults)")
|
|
1074
|
+
.action(async (query: string, opts: { limit: string; json?: boolean; key?: string }) => {
|
|
1075
|
+
const effectiveKey = opts.key || memoryKey;
|
|
1076
|
+
if (!effectiveKey) { console.error("Not configured. Run: openclaw mr <key> or pass --key <key>"); return; }
|
|
1077
|
+
const limit = parseInt(opts.limit, 10) || 50;
|
|
1145
1078
|
try {
|
|
1146
1079
|
const res = await fetch(`${endpoint}/v1/memory/search`, {
|
|
1147
1080
|
method: "POST",
|
|
1148
1081
|
headers: {
|
|
1149
|
-
Authorization: `Bearer ${
|
|
1082
|
+
Authorization: `Bearer ${effectiveKey}`,
|
|
1150
1083
|
"Content-Type": "application/json",
|
|
1151
1084
|
...(embeddings && { "X-Embedding-Model": embeddings }),
|
|
1152
1085
|
},
|
|
@@ -1211,15 +1144,17 @@ const memoryRouterPlugin = {
|
|
|
1211
1144
|
|
|
1212
1145
|
mr.command("delete")
|
|
1213
1146
|
.description("Clear all memories from vault")
|
|
1214
|
-
.
|
|
1215
|
-
|
|
1147
|
+
.option("--key <key>", "Override memory key (for subagent vaults)")
|
|
1148
|
+
.action(async (opts: { key?: string }) => {
|
|
1149
|
+
const effectiveKey = opts.key || memoryKey;
|
|
1150
|
+
if (!effectiveKey) { console.error("Not configured. Run: openclaw mr <key> or pass --key <key>"); return; }
|
|
1216
1151
|
try {
|
|
1217
1152
|
const deleteUrl = embeddings
|
|
1218
1153
|
? `${endpoint}/v1/memory?embeddings=${encodeURIComponent(embeddings)}`
|
|
1219
1154
|
: `${endpoint}/v1/memory`;
|
|
1220
1155
|
const res = await fetch(deleteUrl, {
|
|
1221
1156
|
method: "DELETE",
|
|
1222
|
-
headers: { Authorization: `Bearer ${
|
|
1157
|
+
headers: { Authorization: `Bearer ${effectiveKey}` },
|
|
1223
1158
|
});
|
|
1224
1159
|
const data = (await res.json()) as { message?: string };
|
|
1225
1160
|
const modelLabel = embeddings ? ` (${embeddings})` : "";
|
|
@@ -1228,33 +1163,6 @@ const memoryRouterPlugin = {
|
|
|
1228
1163
|
console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1229
1164
|
}
|
|
1230
1165
|
});
|
|
1231
|
-
|
|
1232
|
-
// ── Workspace Sync ──
|
|
1233
|
-
mr.command("sync")
|
|
1234
|
-
.description("Sync workspace files to vault using source_hash tracking")
|
|
1235
|
-
.option("--workspace <dir>", "Workspace directory")
|
|
1236
|
-
.option("--status", "Show manifest status instead of syncing")
|
|
1237
|
-
.action(async (opts: { workspace?: string; status?: boolean }) => {
|
|
1238
|
-
if (opts.status) {
|
|
1239
|
-
await showManifestStatus();
|
|
1240
|
-
return;
|
|
1241
|
-
}
|
|
1242
|
-
if (!memoryKey) { console.error("Not configured. Run: openclaw mr <key>"); return; }
|
|
1243
|
-
const os = await import("node:os");
|
|
1244
|
-
const configWorkspace = (api.config as any).workspace || (api.config as any).agents?.defaults?.workspace;
|
|
1245
|
-
const workspaceDir = opts.workspace
|
|
1246
|
-
? path.resolve(opts.workspace)
|
|
1247
|
-
: configWorkspace
|
|
1248
|
-
? path.resolve(configWorkspace.replace(/^~/, os.homedir()))
|
|
1249
|
-
: path.join(os.homedir(), ".openclaw", "workspace");
|
|
1250
|
-
await runSyncCli({
|
|
1251
|
-
workspaceDir,
|
|
1252
|
-
endpoint,
|
|
1253
|
-
memoryKey,
|
|
1254
|
-
embeddings,
|
|
1255
|
-
logging: true,
|
|
1256
|
-
});
|
|
1257
|
-
});
|
|
1258
1166
|
},
|
|
1259
1167
|
{ commands: ["mr"] },
|
|
1260
1168
|
);
|
|
@@ -1265,17 +1173,8 @@ const memoryRouterPlugin = {
|
|
|
1265
1173
|
|
|
1266
1174
|
api.registerService({
|
|
1267
1175
|
id: "mr-memory",
|
|
1268
|
-
start:
|
|
1269
|
-
if (memoryKey) {
|
|
1270
|
-
api.logger.info?.(`memoryrouter: active (key: ${memoryKey.slice(0, 6)}...)`);
|
|
1271
|
-
|
|
1272
|
-
// ── Auto-sync workspace files (non-blocking) ──
|
|
1273
|
-
const configWorkspace = (api.config as any).workspace || (api.config as any).agents?.defaults?.workspace;
|
|
1274
|
-
const workspaceDir = configWorkspace
|
|
1275
|
-
? path.resolve(configWorkspace.replace(/^~/, homedir()))
|
|
1276
|
-
: path.join(homedir(), ".openclaw", "workspace");
|
|
1277
|
-
triggerSync(workspaceDir);
|
|
1278
|
-
}
|
|
1176
|
+
start: () => {
|
|
1177
|
+
if (memoryKey) api.logger.info?.(`memoryrouter: active (key: ${memoryKey.slice(0, 6)}...)`);
|
|
1279
1178
|
},
|
|
1280
1179
|
stop: () => {
|
|
1281
1180
|
api.logger.info?.("memoryrouter: stopped");
|
package/package.json
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mr-memory",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "MemoryRouter persistent memory plugin for OpenClaw — your AI remembers every conversation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"README.md",
|
|
8
8
|
"index.ts",
|
|
9
9
|
"openclaw.plugin.json",
|
|
10
|
-
"upload.ts"
|
|
11
|
-
"sync.ts"
|
|
10
|
+
"upload.ts"
|
|
12
11
|
],
|
|
13
12
|
"keywords": [
|
|
14
13
|
"openclaw",
|
package/upload.ts
CHANGED
|
@@ -370,11 +370,10 @@ export async function runUpload(params: {
|
|
|
370
370
|
|
|
371
371
|
const workspacePath = params.workspacePath ?? process.cwd();
|
|
372
372
|
|
|
373
|
-
|
|
373
|
+
let files: string[];
|
|
374
374
|
if (targetPath) {
|
|
375
375
|
const resolved = path.resolve(targetPath);
|
|
376
376
|
const stat = await fs.stat(resolved);
|
|
377
|
-
let files: string[];
|
|
378
377
|
if (stat.isDirectory()) {
|
|
379
378
|
const allDirFiles = await fs.readdir(resolved, { recursive: true });
|
|
380
379
|
files = (allDirFiles as string[])
|
|
@@ -386,58 +385,19 @@ export async function runUpload(params: {
|
|
|
386
385
|
} else {
|
|
387
386
|
files = [resolved];
|
|
388
387
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const { syncWorkspaceFiles } = await import("./sync.js");
|
|
401
|
-
const syncResult = await syncWorkspaceFiles({
|
|
402
|
-
workspaceDir: workspacePath,
|
|
403
|
-
endpoint,
|
|
404
|
-
memoryKey,
|
|
405
|
-
embeddings,
|
|
406
|
-
logging: true,
|
|
407
|
-
});
|
|
408
|
-
console.log(`\n Workspace: ${syncResult.uploaded} uploaded, ${syncResult.deleted} deleted, ${syncResult.unchanged} unchanged`);
|
|
409
|
-
if (syncResult.errors.length > 0) {
|
|
410
|
-
console.log(` ⚠️ ${syncResult.errors.length} errors`);
|
|
411
|
-
for (const err of syncResult.errors.slice(0, 3)) {
|
|
412
|
-
console.log(` • ${err}`);
|
|
413
|
-
}
|
|
414
|
-
}
|
|
388
|
+
} else if (params.hasBrainFlag && !params.hasWorkspaceFlag) {
|
|
389
|
+
// --brain only: upload sessions from brain path
|
|
390
|
+
files = await discoverBrainFiles(stateDir);
|
|
391
|
+
} else if (params.hasWorkspaceFlag && !params.hasBrainFlag) {
|
|
392
|
+
// --workspace only: upload workspace files only
|
|
393
|
+
files = await discoverWorkspaceFiles(workspacePath);
|
|
394
|
+
} else {
|
|
395
|
+
// No flags or both flags: upload both workspace + sessions
|
|
396
|
+
const wsFiles = await discoverWorkspaceFiles(workspacePath);
|
|
397
|
+
const brainFiles = await discoverBrainFiles(stateDir);
|
|
398
|
+
files = [...wsFiles, ...brainFiles];
|
|
415
399
|
}
|
|
416
400
|
|
|
417
|
-
// ── Session transcripts: batch upload (append-only, no sync needed) ──
|
|
418
|
-
if (doSessions) {
|
|
419
|
-
console.log("\n📝 Uploading session transcripts...");
|
|
420
|
-
const sessionFiles = await discoverBrainFiles(stateDir);
|
|
421
|
-
if (sessionFiles.length === 0) {
|
|
422
|
-
console.log(" No session files found.");
|
|
423
|
-
} else {
|
|
424
|
-
await uploadFilesLegacy(sessionFiles, memoryKey, endpoint, embeddings);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* Legacy batch upload for session transcripts and specific-path uploads.
|
|
431
|
-
* Sessions are append-only, so they don't need source_hash tracking.
|
|
432
|
-
*/
|
|
433
|
-
async function uploadFilesLegacy(
|
|
434
|
-
files: string[],
|
|
435
|
-
memoryKey: string,
|
|
436
|
-
endpoint: string,
|
|
437
|
-
embeddings?: string,
|
|
438
|
-
): Promise<void> {
|
|
439
|
-
const uploadUrl = `${endpoint}/v1/memory/upload`;
|
|
440
|
-
|
|
441
401
|
if (files.length === 0) {
|
|
442
402
|
console.log("No files found to upload.");
|
|
443
403
|
return;
|
|
@@ -536,6 +496,7 @@ async function uploadFilesLegacy(
|
|
|
536
496
|
if (batchFailed > 0) {
|
|
537
497
|
const errHint = result.errors?.[0] ? ` (${result.errors[0].slice(0, 120)})` : "";
|
|
538
498
|
console.log(`⚠ ${batchStored} stored, ${batchFailed} skipped${errHint}`);
|
|
499
|
+
// Show detailed error diagnostics (up to 5 per batch)
|
|
539
500
|
if (result.errors && result.errors.length > 1) {
|
|
540
501
|
for (const err of result.errors.slice(0, 5)) {
|
|
541
502
|
console.log(` → ${err.slice(0, 200)}`);
|
package/sync.ts
DELETED
|
@@ -1,367 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MemoryRouter Workspace Sync — source_hash-based file tracking.
|
|
3
|
-
*
|
|
4
|
-
* Tracks workspace files via SHA-256 content hashes. When files change,
|
|
5
|
-
* old chunks are deleted and new content is uploaded with the new hash.
|
|
6
|
-
* This enables clean replacement without stale chunk accumulation.
|
|
7
|
-
*
|
|
8
|
-
* IMPORTANT: source_hash is ONLY for workspace files, NEVER for sessions.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { readFile, writeFile, readdir, lstat, mkdir } from "node:fs/promises";
|
|
12
|
-
import { createHash } from "node:crypto";
|
|
13
|
-
import { join, relative, sep } from "node:path";
|
|
14
|
-
import { homedir } from "node:os";
|
|
15
|
-
|
|
16
|
-
// ── Types ──
|
|
17
|
-
|
|
18
|
-
export interface ManifestFileEntry {
|
|
19
|
-
source_hash: string;
|
|
20
|
-
size: number;
|
|
21
|
-
chunks: number;
|
|
22
|
-
uploaded_at: number;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface Manifest {
|
|
26
|
-
version: number;
|
|
27
|
-
lastSync: number;
|
|
28
|
-
files: Record<string, ManifestFileEntry>;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface SyncResult {
|
|
32
|
-
uploaded: number;
|
|
33
|
-
deleted: number;
|
|
34
|
-
unchanged: number;
|
|
35
|
-
errors: string[];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface SyncOptions {
|
|
39
|
-
workspaceDir: string;
|
|
40
|
-
endpoint: string;
|
|
41
|
-
memoryKey: string;
|
|
42
|
-
embeddings?: string;
|
|
43
|
-
logging?: boolean;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// ── Constants ──
|
|
47
|
-
|
|
48
|
-
const MANIFEST_PATH = join(homedir(), ".openclaw", "mr-memory-manifest.json");
|
|
49
|
-
const MAX_FILE_SIZE = 1_000_000; // 1MB limit
|
|
50
|
-
const TEXT_EXTENSIONS = new Set(["md", "txt"]);
|
|
51
|
-
|
|
52
|
-
// ── Manifest I/O ──
|
|
53
|
-
|
|
54
|
-
export async function loadManifest(): Promise<Manifest> {
|
|
55
|
-
try {
|
|
56
|
-
const raw = await readFile(MANIFEST_PATH, "utf-8");
|
|
57
|
-
return JSON.parse(raw) as Manifest;
|
|
58
|
-
} catch {
|
|
59
|
-
// First run or corrupted — create empty manifest
|
|
60
|
-
return { version: 1, lastSync: 0, files: {} };
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export async function saveManifest(manifest: Manifest): Promise<void> {
|
|
65
|
-
manifest.lastSync = Date.now();
|
|
66
|
-
// Ensure directory exists
|
|
67
|
-
const dir = join(homedir(), ".openclaw");
|
|
68
|
-
await mkdir(dir, { recursive: true });
|
|
69
|
-
await writeFile(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// ── Hash Computation ──
|
|
73
|
-
|
|
74
|
-
export function computeSourceHash(content: string): string {
|
|
75
|
-
const hash = createHash("sha256").update(content).digest("hex");
|
|
76
|
-
return hash.substring(0, 16); // First 16 hex chars, matching MR API format
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// ── File Discovery ──
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Discover ALL workspace files to sync:
|
|
83
|
-
* - All .md and .txt files in workspace root
|
|
84
|
-
* - All files recursively under memory/ directory
|
|
85
|
-
* - Excludes: hidden files/dirs, node_modules/, files > 1MB, binary files
|
|
86
|
-
*/
|
|
87
|
-
export async function discoverWorkspaceFiles(workspaceDir: string): Promise<string[]> {
|
|
88
|
-
const files: string[] = [];
|
|
89
|
-
|
|
90
|
-
async function walk(dir: string) {
|
|
91
|
-
let entries;
|
|
92
|
-
try {
|
|
93
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
94
|
-
} catch {
|
|
95
|
-
return; // Directory doesn't exist or not readable
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
for (const entry of entries) {
|
|
99
|
-
// Skip hidden dirs/files and node_modules
|
|
100
|
-
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
101
|
-
|
|
102
|
-
const fullPath = join(dir, entry.name);
|
|
103
|
-
|
|
104
|
-
if (entry.isDirectory()) {
|
|
105
|
-
await walk(fullPath);
|
|
106
|
-
} else if (entry.isFile()) {
|
|
107
|
-
// Only text files we want to index
|
|
108
|
-
const ext = entry.name.toLowerCase().split(".").pop() || "";
|
|
109
|
-
if (!TEXT_EXTENSIONS.has(ext)) continue;
|
|
110
|
-
|
|
111
|
-
// Check file size
|
|
112
|
-
try {
|
|
113
|
-
const stat = await lstat(fullPath);
|
|
114
|
-
if (stat.size > MAX_FILE_SIZE) continue;
|
|
115
|
-
if (stat.size === 0) continue; // Skip empty files
|
|
116
|
-
files.push(relative(workspaceDir, fullPath));
|
|
117
|
-
} catch {
|
|
118
|
-
continue; // Skip unreadable files
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
await walk(workspaceDir);
|
|
125
|
-
return files.sort();
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ── API Helpers ──
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Upload file content with source_hash using isolated chunking.
|
|
132
|
-
* Uses POST /v1/memory/upload-source endpoint.
|
|
133
|
-
*/
|
|
134
|
-
export async function uploadSourceFile(
|
|
135
|
-
endpoint: string,
|
|
136
|
-
memoryKey: string,
|
|
137
|
-
content: string,
|
|
138
|
-
sourceHash: string,
|
|
139
|
-
embeddings?: string,
|
|
140
|
-
): Promise<{ stored: number; chunks: number }> {
|
|
141
|
-
const res = await fetch(`${endpoint}/v1/memory/upload-source`, {
|
|
142
|
-
method: "POST",
|
|
143
|
-
headers: {
|
|
144
|
-
Authorization: `Bearer ${memoryKey}`,
|
|
145
|
-
"Content-Type": "application/json",
|
|
146
|
-
...(embeddings && { "X-Embedding-Model": embeddings }),
|
|
147
|
-
},
|
|
148
|
-
body: JSON.stringify({ content, source_hash: sourceHash }),
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
if (!res.ok) {
|
|
152
|
-
const errText = await res.text().catch(() => "");
|
|
153
|
-
throw new Error(`Upload failed: HTTP ${res.status} — ${errText.slice(0, 200)}`);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const data = (await res.json()) as { stored: number; chunks: number; source_hash: string };
|
|
157
|
-
return { stored: data.stored || 0, chunks: data.chunks || data.stored || 0 };
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Delete all chunks with a given source_hash.
|
|
162
|
-
* Uses POST /v1/memory/source/delete (POST fallback for clients that can't send DELETE with body).
|
|
163
|
-
*/
|
|
164
|
-
export async function deleteBySourceHash(
|
|
165
|
-
endpoint: string,
|
|
166
|
-
memoryKey: string,
|
|
167
|
-
sourceHash: string,
|
|
168
|
-
): Promise<number> {
|
|
169
|
-
const res = await fetch(`${endpoint}/v1/memory/source/delete`, {
|
|
170
|
-
method: "POST",
|
|
171
|
-
headers: {
|
|
172
|
-
Authorization: `Bearer ${memoryKey}`,
|
|
173
|
-
"Content-Type": "application/json",
|
|
174
|
-
},
|
|
175
|
-
body: JSON.stringify({ source_hash: sourceHash }),
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
if (!res.ok) {
|
|
179
|
-
const errText = await res.text().catch(() => "");
|
|
180
|
-
throw new Error(`Delete failed: HTTP ${res.status} — ${errText.slice(0, 200)}`);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const data = (await res.json()) as { deleted: number; source_hash: string };
|
|
184
|
-
return data.deleted || 0;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// ── Main Sync Flow ──
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Sync workspace files to MemoryRouter using source_hash tracking.
|
|
191
|
-
*
|
|
192
|
-
* Algorithm:
|
|
193
|
-
* 1. Load manifest (previous state)
|
|
194
|
-
* 2. Discover current workspace files, compute hashes
|
|
195
|
-
* 3. Diff current vs manifest:
|
|
196
|
-
* - NEW: upload with source_hash
|
|
197
|
-
* - CHANGED: delete old hash, upload new
|
|
198
|
-
* - DELETED: delete from MR
|
|
199
|
-
* - UNCHANGED: skip
|
|
200
|
-
* 4. Save updated manifest
|
|
201
|
-
*/
|
|
202
|
-
export async function syncWorkspaceFiles(options: SyncOptions): Promise<SyncResult> {
|
|
203
|
-
const { workspaceDir, endpoint, memoryKey, embeddings, logging } = options;
|
|
204
|
-
const log = (msg: string) => {
|
|
205
|
-
if (logging) console.log(msg);
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
const result: SyncResult = { uploaded: 0, deleted: 0, unchanged: 0, errors: [] };
|
|
209
|
-
|
|
210
|
-
// 1. Load manifest
|
|
211
|
-
const manifest = await loadManifest();
|
|
212
|
-
log(`memoryrouter: sync starting (${Object.keys(manifest.files).length} files in manifest)`);
|
|
213
|
-
|
|
214
|
-
// 2. Discover workspace files and compute hashes
|
|
215
|
-
const diskFiles = await discoverWorkspaceFiles(workspaceDir);
|
|
216
|
-
log(`memoryrouter: discovered ${diskFiles.length} workspace files`);
|
|
217
|
-
|
|
218
|
-
const diskState = new Map<string, { hash: string; size: number; content: string }>();
|
|
219
|
-
|
|
220
|
-
for (const relPath of diskFiles) {
|
|
221
|
-
const absPath = join(workspaceDir, relPath);
|
|
222
|
-
try {
|
|
223
|
-
const content = await readFile(absPath, "utf-8");
|
|
224
|
-
const trimmed = content.trim();
|
|
225
|
-
if (!trimmed) continue; // Skip empty files
|
|
226
|
-
|
|
227
|
-
const hash = computeSourceHash(content);
|
|
228
|
-
const size = Buffer.byteLength(content, "utf-8");
|
|
229
|
-
diskState.set(relPath, { hash, size, content });
|
|
230
|
-
} catch (err) {
|
|
231
|
-
result.errors.push(`Read error: ${relPath} — ${err instanceof Error ? err.message : String(err)}`);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// 3a. Process disk files (new + changed)
|
|
236
|
-
for (const [relPath, { hash, size, content }] of diskState) {
|
|
237
|
-
const manifestEntry = manifest.files[relPath];
|
|
238
|
-
|
|
239
|
-
if (!manifestEntry) {
|
|
240
|
-
// NEW FILE — upload
|
|
241
|
-
try {
|
|
242
|
-
const { chunks } = await uploadSourceFile(endpoint, memoryKey, content, hash, embeddings);
|
|
243
|
-
manifest.files[relPath] = {
|
|
244
|
-
source_hash: hash,
|
|
245
|
-
size,
|
|
246
|
-
chunks,
|
|
247
|
-
uploaded_at: Date.now(),
|
|
248
|
-
};
|
|
249
|
-
result.uploaded++;
|
|
250
|
-
log(`memoryrouter: sync uploaded new file ${relPath} (${chunks} chunks)`);
|
|
251
|
-
} catch (err) {
|
|
252
|
-
result.errors.push(`Upload error: ${relPath} — ${err instanceof Error ? err.message : String(err)}`);
|
|
253
|
-
}
|
|
254
|
-
} else if (manifestEntry.source_hash !== hash) {
|
|
255
|
-
// CHANGED FILE — delete old, upload new
|
|
256
|
-
try {
|
|
257
|
-
const deleted = await deleteBySourceHash(endpoint, memoryKey, manifestEntry.source_hash);
|
|
258
|
-
log(`memoryrouter: sync deleted old version of ${relPath} (${deleted} chunks)`);
|
|
259
|
-
} catch (err) {
|
|
260
|
-
// Log but continue — old chunks may have been deleted manually
|
|
261
|
-
log(`memoryrouter: sync delete warning for ${relPath} — ${err instanceof Error ? err.message : String(err)}`);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
try {
|
|
265
|
-
const { chunks } = await uploadSourceFile(endpoint, memoryKey, content, hash, embeddings);
|
|
266
|
-
manifest.files[relPath] = {
|
|
267
|
-
source_hash: hash,
|
|
268
|
-
size,
|
|
269
|
-
chunks,
|
|
270
|
-
uploaded_at: Date.now(),
|
|
271
|
-
};
|
|
272
|
-
result.uploaded++;
|
|
273
|
-
log(`memoryrouter: sync updated file ${relPath} (${chunks} new chunks)`);
|
|
274
|
-
} catch (err) {
|
|
275
|
-
result.errors.push(`Upload error: ${relPath} — ${err instanceof Error ? err.message : String(err)}`);
|
|
276
|
-
}
|
|
277
|
-
} else {
|
|
278
|
-
// UNCHANGED — skip
|
|
279
|
-
result.unchanged++;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// 3b. Handle deleted files (in manifest but not on disk)
|
|
284
|
-
for (const relPath of Object.keys(manifest.files)) {
|
|
285
|
-
if (!diskState.has(relPath)) {
|
|
286
|
-
try {
|
|
287
|
-
const deleted = await deleteBySourceHash(endpoint, memoryKey, manifest.files[relPath].source_hash);
|
|
288
|
-
delete manifest.files[relPath];
|
|
289
|
-
result.deleted++;
|
|
290
|
-
log(`memoryrouter: sync deleted removed file ${relPath} (${deleted} chunks)`);
|
|
291
|
-
} catch (err) {
|
|
292
|
-
result.errors.push(`Delete error: ${relPath} — ${err instanceof Error ? err.message : String(err)}`);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// 4. Save updated manifest
|
|
298
|
-
await saveManifest(manifest);
|
|
299
|
-
|
|
300
|
-
log(
|
|
301
|
-
`memoryrouter: sync complete — ${result.uploaded} uploaded, ${result.deleted} deleted, ${result.unchanged} unchanged`,
|
|
302
|
-
);
|
|
303
|
-
|
|
304
|
-
return result;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// ── CLI Helper ──
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Run sync from CLI with console output.
|
|
311
|
-
*/
|
|
312
|
-
export async function runSyncCli(options: SyncOptions): Promise<void> {
|
|
313
|
-
const { workspaceDir, embeddings } = options;
|
|
314
|
-
|
|
315
|
-
const modelLabel = embeddings ? `(model: ${embeddings})` : "(model: bge)";
|
|
316
|
-
console.log(`Syncing workspace to MemoryRouter ${modelLabel}...`);
|
|
317
|
-
console.log(` Workspace: ${workspaceDir}`);
|
|
318
|
-
|
|
319
|
-
try {
|
|
320
|
-
const result = await syncWorkspaceFiles({ ...options, logging: true });
|
|
321
|
-
|
|
322
|
-
console.log("\n────────────────────────────────");
|
|
323
|
-
console.log(`✅ Sync complete`);
|
|
324
|
-
console.log(` Uploaded: ${result.uploaded}`);
|
|
325
|
-
console.log(` Deleted: ${result.deleted}`);
|
|
326
|
-
console.log(` Unchanged: ${result.unchanged}`);
|
|
327
|
-
|
|
328
|
-
if (result.errors.length > 0) {
|
|
329
|
-
console.log(`\n⚠️ ${result.errors.length} errors:`);
|
|
330
|
-
for (const err of result.errors.slice(0, 5)) {
|
|
331
|
-
console.log(` • ${err}`);
|
|
332
|
-
}
|
|
333
|
-
if (result.errors.length > 5) {
|
|
334
|
-
console.log(` • ... and ${result.errors.length - 5} more`);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
} catch (err) {
|
|
338
|
-
console.error(`\n❌ Sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Show manifest status from CLI.
|
|
344
|
-
*/
|
|
345
|
-
export async function showManifestStatus(): Promise<void> {
|
|
346
|
-
const manifest = await loadManifest();
|
|
347
|
-
const fileCount = Object.keys(manifest.files).length;
|
|
348
|
-
|
|
349
|
-
console.log("Workspace Sync Manifest");
|
|
350
|
-
console.log("───────────────────────────────");
|
|
351
|
-
console.log(` Path: ${MANIFEST_PATH}`);
|
|
352
|
-
console.log(` Version: ${manifest.version}`);
|
|
353
|
-
console.log(` Last sync: ${manifest.lastSync ? new Date(manifest.lastSync).toLocaleString() : "never"}`);
|
|
354
|
-
console.log(` Files: ${fileCount}`);
|
|
355
|
-
|
|
356
|
-
if (fileCount > 0) {
|
|
357
|
-
console.log("\n Tracked files:");
|
|
358
|
-
const files = Object.entries(manifest.files).sort((a, b) => a[0].localeCompare(b[0]));
|
|
359
|
-
for (const [path, entry] of files.slice(0, 20)) {
|
|
360
|
-
const date = new Date(entry.uploaded_at).toLocaleDateString();
|
|
361
|
-
console.log(` • ${path} (${entry.chunks} chunks, ${date})`);
|
|
362
|
-
}
|
|
363
|
-
if (files.length > 20) {
|
|
364
|
-
console.log(` ... and ${files.length - 20} more`);
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
}
|