mr-memory 3.1.1 → 3.2.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.
Files changed (3) hide show
  1. package/index.ts +67 -5
  2. package/package.json +3 -2
  3. package/sync.ts +367 -0
package/index.ts CHANGED
@@ -10,9 +10,11 @@
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";
13
14
  import path from "node:path";
14
15
  import { spawn } from "node:child_process";
15
16
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
17
+ import { syncWorkspaceFiles, runSyncCli, showManifestStatus } from "./sync.js";
16
18
 
17
19
  const DEFAULT_ENDPOINT = "https://api.memoryrouter.ai";
18
20
 
@@ -734,7 +736,7 @@ const memoryRouterPlugin = {
734
736
  execute: async (_toolCallId: string, params: Record<string, unknown>) => {
735
737
  const query = typeof params.query === "string" ? params.query.trim() : "";
736
738
  if (!query) return jsonToolResult({ results: [], error: "query required" });
737
- const limit = typeof params.maxResults === "number" ? params.maxResults : 50;
739
+ const limit = typeof params.maxResults === "number" ? params.maxResults : 10;
738
740
  try {
739
741
  const res = await fetch(`${endpoint}/v1/memory/search`, {
740
742
  method: "POST",
@@ -1101,11 +1103,11 @@ const memoryRouterPlugin = {
1101
1103
  mr.command("search")
1102
1104
  .description("Search memories in vault")
1103
1105
  .argument("<query>", "Search query")
1104
- .option("-n, --limit <number>", "Number of results", "50")
1106
+ .option("-n, --limit <number>", "Number of results", "5")
1105
1107
  .option("--json", "Output raw JSON response")
1106
1108
  .action(async (query: string, opts: { limit: string; json?: boolean }) => {
1107
1109
  if (!memoryKey) { console.error("Not configured. Run: openclaw mr <key>"); return; }
1108
- const limit = parseInt(opts.limit, 10) || 50;
1110
+ const limit = parseInt(opts.limit, 10) || 5;
1109
1111
  try {
1110
1112
  const res = await fetch(`${endpoint}/v1/memory/search`, {
1111
1113
  method: "POST",
@@ -1192,6 +1194,33 @@ const memoryRouterPlugin = {
1192
1194
  console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
1193
1195
  }
1194
1196
  });
1197
+
1198
+ // ── Workspace Sync ──
1199
+ mr.command("sync")
1200
+ .description("Sync workspace files to vault using source_hash tracking")
1201
+ .option("--workspace <dir>", "Workspace directory")
1202
+ .option("--status", "Show manifest status instead of syncing")
1203
+ .action(async (opts: { workspace?: string; status?: boolean }) => {
1204
+ if (opts.status) {
1205
+ await showManifestStatus();
1206
+ return;
1207
+ }
1208
+ if (!memoryKey) { console.error("Not configured. Run: openclaw mr <key>"); return; }
1209
+ const os = await import("node:os");
1210
+ const configWorkspace = (api.config as any).workspace || (api.config as any).agents?.defaults?.workspace;
1211
+ const workspaceDir = opts.workspace
1212
+ ? path.resolve(opts.workspace)
1213
+ : configWorkspace
1214
+ ? path.resolve(configWorkspace.replace(/^~/, os.homedir()))
1215
+ : path.join(os.homedir(), ".openclaw", "workspace");
1216
+ await runSyncCli({
1217
+ workspaceDir,
1218
+ endpoint,
1219
+ memoryKey,
1220
+ embeddings,
1221
+ logging: true,
1222
+ });
1223
+ });
1195
1224
  },
1196
1225
  { commands: ["mr"] },
1197
1226
  );
@@ -1202,8 +1231,41 @@ const memoryRouterPlugin = {
1202
1231
 
1203
1232
  api.registerService({
1204
1233
  id: "mr-memory",
1205
- start: () => {
1206
- if (memoryKey) api.logger.info?.(`memoryrouter: active (key: ${memoryKey.slice(0, 6)}...)`);
1234
+ start: async () => {
1235
+ if (memoryKey) {
1236
+ api.logger.info?.(`memoryrouter: active (key: ${memoryKey.slice(0, 6)}...)`);
1237
+
1238
+ // ── Auto-sync workspace files (non-blocking) ──
1239
+ // Resolve workspace directory
1240
+ const configWorkspace = (api.config as any).workspace || (api.config as any).agents?.defaults?.workspace;
1241
+ const workspaceDir = configWorkspace
1242
+ ? path.resolve(configWorkspace.replace(/^~/, homedir()))
1243
+ : path.join(homedir(), ".openclaw", "workspace");
1244
+
1245
+ // Fire and forget — don't block service startup
1246
+ syncWorkspaceFiles({
1247
+ workspaceDir,
1248
+ endpoint,
1249
+ memoryKey,
1250
+ embeddings,
1251
+ logging,
1252
+ })
1253
+ .then((result) => {
1254
+ if (result.uploaded > 0 || result.deleted > 0) {
1255
+ api.logger.info?.(
1256
+ `memoryrouter: sync complete — ${result.uploaded} uploaded, ${result.deleted} deleted, ${result.unchanged} unchanged`,
1257
+ );
1258
+ } else {
1259
+ log(`memoryrouter: sync complete — ${result.unchanged} files unchanged`);
1260
+ }
1261
+ if (result.errors.length > 0) {
1262
+ api.logger.warn?.(`memoryrouter: sync had ${result.errors.length} errors`);
1263
+ }
1264
+ })
1265
+ .catch((err) => {
1266
+ api.logger.warn?.(`memoryrouter: sync error — ${err instanceof Error ? err.message : String(err)}`);
1267
+ });
1268
+ }
1207
1269
  },
1208
1270
  stop: () => {
1209
1271
  api.logger.info?.("memoryrouter: stopped");
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "mr-memory",
3
- "version": "3.1.1",
3
+ "version": "3.2.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"
10
+ "upload.ts",
11
+ "sync.ts"
11
12
  ],
12
13
  "keywords": [
13
14
  "openclaw",
package/sync.ts ADDED
@@ -0,0 +1,367 @@
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
+ }