memorylake-openclaw 0.0.10 → 0.0.11

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/README.md CHANGED
@@ -14,7 +14,9 @@ Your agent forgets everything between sessions. This plugin fixes that. It watch
14
14
 
15
15
  **Auto-Capture** — After the agent responds, the plugin sends the exchange to MemoryLake. MemoryLake decides what's worth keeping — new facts get stored, stale ones updated, duplicates merged.
16
16
 
17
- Both run silently. No prompting, no configuration, no manual calls.
17
+ **Auto-Upload** When a user sends a file (image, document, etc.), the plugin uploads it to MemoryLake as a project document.
18
+
19
+ Auto-Recall, Auto-Capture, and Auto-Upload run silently by default.
18
20
 
19
21
  ## Setup
20
22
 
@@ -68,6 +70,7 @@ openclaw memorylake stats
68
70
  | `host` | `string` | `https://app.memorylake.ai` | MemoryLake server endpoint URL |
69
71
  | `autoRecall` | `boolean` | `true` | Inject memories before each turn |
70
72
  | `autoCapture` | `boolean` | `true` | Store facts after each turn |
73
+ | `autoUpload` | `boolean` | `true` | Auto-upload inbound files to MemoryLake |
71
74
  | `topK` | `number` | `5` | Max memories per recall |
72
75
  | `searchThreshold` | `number` | `0.3` | Min similarity (0–1) |
73
76
  | `rerank` | `boolean` | `true` | Rerank search results |
package/docs/openclaw.mdx CHANGED
@@ -13,9 +13,10 @@ Add long-term memory to [OpenClaw](https://github.com/openclaw/openclaw) agents
13
13
  The plugin provides:
14
14
  1. **Auto-Recall** — Before the agent responds, memories and relevant document excerpts matching the current message are injected into context
15
15
  2. **Auto-Capture** — After the agent responds, the exchange is sent to MemoryLake which decides what's worth keeping
16
- 3. **Agent Tools** — Seven tools for memory, document, and optional web search operations during conversations
16
+ 3. **Auto-Upload** — When a user sends a file, the plugin uploads it to MemoryLake as a project document asynchronously
17
+ 4. **Agent Tools** — Seven tools for memory, document, and optional web search operations during conversations
17
18
 
18
- Both auto-recall and auto-capture run silently with no manual configuration required.
19
+ Auto-Recall, Auto-Capture, and Auto-Upload run silently by default.
19
20
 
20
21
  ## Installation
21
22
 
@@ -73,6 +74,7 @@ openclaw memorylake stats
73
74
  | `host` | `string` | `https://app.memorylake.ai` | MemoryLake server endpoint URL |
74
75
  | `autoRecall` | `boolean` | `true` | Inject memories before each turn |
75
76
  | `autoCapture` | `boolean` | `true` | Store facts after each turn |
77
+ | `autoUpload` | `boolean` | `true` | Auto-upload inbound files to MemoryLake |
76
78
  | `topK` | `number` | `5` | Max memories per recall |
77
79
  | `searchThreshold` | `number` | `0.3` | Min similarity (0–1) |
78
80
  | `rerank` | `boolean` | `true` | Rerank search results for better relevance |
@@ -86,7 +88,7 @@ openclaw memorylake stats
86
88
  ## Key Features
87
89
 
88
90
  1. **Zero Configuration** — Auto-recall and auto-capture work out of the box with no prompting required
89
- 2. **Async Processing** — Memory extraction runs asynchronously via MemoryLake's API
91
+ 2. **Async Processing** — Memory extraction and file uploads run asynchronously without blocking the agent
90
92
  3. **Session Tracking** — Conversations are tagged with `chat_session_id` for traceability
91
93
  4. **Rich Tool Suite** — Seven agent tools for memory, document, and optional web search operations when needed
92
94
 
package/index.ts CHANGED
@@ -28,6 +28,7 @@ type MemoryLakeConfig = {
28
28
  userId: string;
29
29
  autoCapture: boolean;
30
30
  autoRecall: boolean;
31
+ autoUpload: boolean;
31
32
  searchThreshold: number;
32
33
  topK: number;
33
34
  rerank: boolean;
@@ -204,7 +205,7 @@ class PlatformProvider implements MemoryLakeProvider {
204
205
  constructor(host: string, apiKey: string, projectId: string) {
205
206
  this.basePath = `openapi/memorylake/api/v2/projects/${projectId}/memories`;
206
207
  this.docSearchPath = `openapi/memorylake/api/v1/projects/${projectId}/documents/search`;
207
- this.webSearchPath = "openapi/memorylake/api/v1/search";
208
+ this.webSearchPath = "openapi/memorylake/api/v1/search";
208
209
  this.http = got.extend({
209
210
  prefixUrl: host,
210
211
  headers: {
@@ -310,6 +311,7 @@ class PlatformProvider implements MemoryLakeProvider {
310
311
  .json<WebSearchResponse>();
311
312
  return normalizeWebSearchResponse(resp);
312
313
  }
314
+
313
315
  }
314
316
 
315
317
  // ============================================================================
@@ -427,6 +429,7 @@ const ALLOWED_KEYS = [
427
429
  "userId",
428
430
  "autoCapture",
429
431
  "autoRecall",
432
+ "autoUpload",
430
433
  "searchThreshold",
431
434
  "topK",
432
435
  "rerank",
@@ -493,6 +496,7 @@ const memoryLakeConfigSchema = {
493
496
  userId: DEFAULT_USER_ID,
494
497
  autoCapture: cfg.autoCapture !== false,
495
498
  autoRecall: cfg.autoRecall !== false,
499
+ autoUpload: cfg.autoUpload !== false,
496
500
  searchThreshold:
497
501
  typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.3,
498
502
  topK: typeof cfg.topK === "number" ? cfg.topK : 5,
@@ -582,7 +586,7 @@ const memoryPlugin = {
582
586
  let currentSessionId: string | undefined;
583
587
 
584
588
  api.logger.info(
585
- `memorylake-openclaw: registered (user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
589
+ `memorylake-openclaw: registered (user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture}, autoUpload: ${cfg.autoUpload})`,
586
590
  );
587
591
 
588
592
  // Helper: build add options
@@ -1191,6 +1195,114 @@ const memoryPlugin = {
1191
1195
  // Lifecycle Hooks
1192
1196
  // ========================================================================
1193
1197
 
1198
+ // --- Auto-upload helpers ---
1199
+ const UPLOADED_RECORD_FILE = "uploaded.json";
1200
+
1201
+ type UploadedRecord = Record<string, { mtimeMs: number }>;
1202
+
1203
+ function getUploadedRecord(workspaceDir: string): UploadedRecord {
1204
+ const filePath = path.join(workspaceDir, ".memorylake", UPLOADED_RECORD_FILE);
1205
+ try {
1206
+ if (fs.existsSync(filePath)) {
1207
+ const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
1208
+ return data && typeof data === "object" && !Array.isArray(data) ? data : {};
1209
+ }
1210
+ } catch (err) {
1211
+ api.logger.warn(`memorylake-openclaw: failed to read uploaded record: ${String(err)}`);
1212
+ }
1213
+ return {};
1214
+ }
1215
+
1216
+ function saveUploadedRecord(workspaceDir: string, record: UploadedRecord): void {
1217
+ const dirPath = path.join(workspaceDir, ".memorylake");
1218
+ if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true });
1219
+ fs.writeFileSync(
1220
+ path.join(dirPath, UPLOADED_RECORD_FILE),
1221
+ JSON.stringify(record, null, 2),
1222
+ );
1223
+ }
1224
+
1225
+ function needsUpload(record: UploadedRecord, filePath: string): fs.Stats | null {
1226
+ if (!fs.existsSync(filePath)) return null;
1227
+ const stat = fs.statSync(filePath);
1228
+ const prev = record[filePath];
1229
+ return (!prev || prev.mtimeMs !== stat.mtimeMs) ? stat : null;
1230
+ }
1231
+
1232
+ function extractInboundPaths(prompt: string): string[] {
1233
+ // Path must contain /media/inbound/ (or \media\inbound\)
1234
+ // Filename must end with .<ext>, ext = alphanumeric, 1-6 chars
1235
+ const sep = '[/\\\\]';
1236
+ const regex = new RegExp(
1237
+ `(?:[A-Za-z]:${sep}|/)\\S*?media${sep}inbound${sep}.+?\\.[a-zA-Z0-9]{1,6}(?=[^a-zA-Z0-9]|$)`,
1238
+ "g",
1239
+ );
1240
+ const matches = prompt.match(regex) || [];
1241
+ return [...new Set(matches)];
1242
+ }
1243
+
1244
+ // Auto-upload: upload inbound files to MemoryLake before prompt build
1245
+ if (cfg.autoUpload) {
1246
+ // Lazy-load upload function from upload.mjs
1247
+ let uploadFn: ((opts: { host: string; apiKey: string; projectId: string; filePath: string; fileName: string }) => Promise<unknown>) | undefined;
1248
+
1249
+ api.on("before_prompt_build", (event, ctx) => {
1250
+ const workspaceDir = (ctx as any)?.workspaceDir;
1251
+ if (!workspaceDir || !event.prompt) return;
1252
+
1253
+ const effectiveCfg = resolveConfig(ctx);
1254
+ const paths = extractInboundPaths(event.prompt);
1255
+ if (paths.length === 0) return;
1256
+
1257
+ const record = getUploadedRecord(workspaceDir);
1258
+ const filesToUpload: { filePath: string; stat: fs.Stats }[] = [];
1259
+ for (const p of paths) {
1260
+ const stat = needsUpload(record, p);
1261
+ if (stat) filesToUpload.push({ filePath: p, stat });
1262
+ }
1263
+ if (filesToUpload.length === 0) return;
1264
+
1265
+ // Fire-and-forget: upload asynchronously without blocking
1266
+ (async () => {
1267
+ // Lazy import upload.mjs
1268
+ if (!uploadFn) {
1269
+ const uploadModule = await import(
1270
+ /* webpackIgnore: true */
1271
+ new URL("./skills/memorylake-upload/scripts/upload.mjs", import.meta.url).href
1272
+ );
1273
+ uploadFn = uploadModule.upload;
1274
+ }
1275
+
1276
+ for (const { filePath, stat } of filesToUpload) {
1277
+ try {
1278
+ await uploadFn!({
1279
+ host: effectiveCfg.host,
1280
+ apiKey: effectiveCfg.apiKey,
1281
+ projectId: effectiveCfg.projectId,
1282
+ filePath,
1283
+ fileName: path.basename(filePath),
1284
+ });
1285
+ // Save record only after successful upload to avoid race on crash
1286
+ const current = getUploadedRecord(workspaceDir);
1287
+ current[filePath] = { mtimeMs: stat.mtimeMs };
1288
+ saveUploadedRecord(workspaceDir, current);
1289
+ api.logger.info(
1290
+ `memorylake-openclaw: auto-uploaded ${path.basename(filePath)}`,
1291
+ );
1292
+ } catch (err) {
1293
+ api.logger.warn(
1294
+ `memorylake-openclaw: auto-upload failed for ${filePath}: ${String(err)}`,
1295
+ );
1296
+ }
1297
+ }
1298
+ })().catch((err) => {
1299
+ api.logger.warn(
1300
+ `memorylake-openclaw: auto-upload unexpected error: ${String(err)}`,
1301
+ );
1302
+ });
1303
+ });
1304
+ }
1305
+
1194
1306
  // Auto-recall: inject relevant memories and documents before agent starts
1195
1307
  if (cfg.autoRecall) {
1196
1308
  api.on("before_agent_start", async (event, ctx) => {
@@ -1337,7 +1449,7 @@ const memoryPlugin = {
1337
1449
  id: "memorylake-openclaw",
1338
1450
  start: () => {
1339
1451
  api.logger.info(
1340
- `memorylake-openclaw: initialized (user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
1452
+ `memorylake-openclaw: initialized (user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture}, autoUpload: ${cfg.autoUpload})`,
1341
1453
  );
1342
1454
  },
1343
1455
  stop: () => {
@@ -22,6 +22,10 @@
22
22
  "label": "Auto-Recall",
23
23
  "help": "Automatically inject relevant memories before each agent turn"
24
24
  },
25
+ "autoUpload": {
26
+ "label": "Auto-Upload",
27
+ "help": "Automatically upload files from media/inbound to MemoryLake"
28
+ },
25
29
  "searchThreshold": {
26
30
  "label": "Search Threshold",
27
31
  "placeholder": "0.3",
@@ -76,6 +80,9 @@
76
80
  "autoRecall": {
77
81
  "type": "boolean"
78
82
  },
83
+ "autoUpload": {
84
+ "type": "boolean"
85
+ },
79
86
  "searchThreshold": {
80
87
  "type": "number"
81
88
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorylake-openclaw",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "type": "module",
5
5
  "description": "MemoryLake memory backend for OpenClaw",
6
6
  "license": "MIT",
@@ -270,4 +270,8 @@ Examples:
270
270
  }
271
271
  }
272
272
 
273
- main();
273
+ // Only run CLI when executed directly, not when imported
274
+ const isDirectRun = process.argv[1] && import.meta.url === new URL(process.argv[1], 'file://').href;
275
+ if (isDirectRun) {
276
+ main();
277
+ }
@@ -0,0 +1,197 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ // Extract the regex logic inline (same as index.ts extractInboundPaths)
5
+ function extractInboundPaths(prompt) {
6
+ // Path must contain /media/inbound/ (or \media\inbound\)
7
+ // Filename must end with .<ext>, ext = alphanumeric, 1-6 chars
8
+ const sep = '[/\\\\]';
9
+ const regex = new RegExp(
10
+ `(?:[A-Za-z]:${sep}|/)\\S*?media${sep}inbound${sep}.+?\\.[a-zA-Z0-9]{1,6}(?=[^a-zA-Z0-9]|$)`,
11
+ "g",
12
+ );
13
+ const matches = prompt.match(regex) || [];
14
+ return [...new Set(matches)];
15
+ }
16
+
17
+ describe("extractInboundPaths", () => {
18
+ // ==================== Should match ====================
19
+
20
+ it("Unix: basic inbound path", () => {
21
+ const prompt = "请看这个文件 /Users/henry/.openclaw/media/inbound/abc.png";
22
+ assert.deepEqual(extractInboundPaths(prompt), [
23
+ "/Users/henry/.openclaw/media/inbound/abc.png",
24
+ ]);
25
+ });
26
+
27
+ it("Unix: UUID filename", () => {
28
+ const prompt =
29
+ "[media attached: /Users/henry/.openclaw/media/inbound/69b064ed-7afa-4184-b81d-aacbc34c7d95.png (image/png)]";
30
+ assert.deepEqual(extractInboundPaths(prompt), [
31
+ "/Users/henry/.openclaw/media/inbound/69b064ed-7afa-4184-b81d-aacbc34c7d95.png",
32
+ ]);
33
+ });
34
+
35
+ it("Unix: timestamp-prefixed filename", () => {
36
+ const prompt =
37
+ "文件已保存: /Users/henry/.openclaw/workspace/media/inbound/1774440188532-b204bb126d0b41bf8b97275d0871728c_1.doc,请基于文件名和上下文回答";
38
+ assert.deepEqual(extractInboundPaths(prompt), [
39
+ "/Users/henry/.openclaw/workspace/media/inbound/1774440188532-b204bb126d0b41bf8b97275d0871728c_1.doc",
40
+ ]);
41
+ });
42
+
43
+ it("Unix: Chinese filename", () => {
44
+ const prompt =
45
+ "上传 /Users/henry/.openclaw/workspace/media/inbound/1773887151830-男士护肤品推广.xlsx 到项目";
46
+ assert.deepEqual(extractInboundPaths(prompt), [
47
+ "/Users/henry/.openclaw/workspace/media/inbound/1773887151830-男士护肤品推广.xlsx",
48
+ ]);
49
+ });
50
+
51
+ it("Unix: subdirectory under inbound", () => {
52
+ const prompt = "file at /data/media/inbound/subdir/nested/report.pdf end";
53
+ assert.deepEqual(extractInboundPaths(prompt), [
54
+ "/data/media/inbound/subdir/nested/report.pdf",
55
+ ]);
56
+ });
57
+
58
+ it("Unix: multiple paths in one prompt", () => {
59
+ const prompt =
60
+ "files: /a/media/inbound/one.txt and /b/media/inbound/two.pdf here";
61
+ const result = extractInboundPaths(prompt);
62
+ assert.equal(result.length, 2);
63
+ assert.ok(result.includes("/a/media/inbound/one.txt"));
64
+ assert.ok(result.includes("/b/media/inbound/two.pdf"));
65
+ });
66
+
67
+ it("Unix: dedup same path appearing twice", () => {
68
+ const prompt =
69
+ "/x/media/inbound/f.txt and again /x/media/inbound/f.txt here";
70
+ assert.deepEqual(extractInboundPaths(prompt), ["/x/media/inbound/f.txt"]);
71
+ });
72
+
73
+ it("Windows: backslash path with drive letter", () => {
74
+ const prompt =
75
+ 'file at C:\\Users\\hello\\.openclaw\\media\\inbound\\report.docx end';
76
+ assert.deepEqual(extractInboundPaths(prompt), [
77
+ "C:\\Users\\hello\\.openclaw\\media\\inbound\\report.docx",
78
+ ]);
79
+ });
80
+
81
+ it("Windows: Chinese username and filename", () => {
82
+ const prompt =
83
+ 'C:\\Users\\你好\\.openclaw\\media\\inbound\\sds\\中文的.odf 这个文件';
84
+ assert.deepEqual(extractInboundPaths(prompt), [
85
+ "C:\\Users\\你好\\.openclaw\\media\\inbound\\sds\\中文的.odf",
86
+ ]);
87
+ });
88
+
89
+ it("Windows: mixed separators (forward slash after drive)", () => {
90
+ const prompt = "C:/Users/test/media/inbound/file.txt done";
91
+ assert.deepEqual(extractInboundPaths(prompt), [
92
+ "C:/Users/test/media/inbound/file.txt",
93
+ ]);
94
+ });
95
+
96
+ it("Unix: path with dot-prefixed directory", () => {
97
+ const prompt = "/home/user/.config/media/inbound/data.csv end";
98
+ assert.deepEqual(extractInboundPaths(prompt), [
99
+ "/home/user/.config/media/inbound/data.csv",
100
+ ]);
101
+ });
102
+
103
+ // ==================== Should NOT match ====================
104
+
105
+ it("no match: no media/inbound in path", () => {
106
+ const prompt = "/Users/henry/Documents/report.pdf";
107
+ assert.deepEqual(extractInboundPaths(prompt), []);
108
+ });
109
+
110
+ it("no match: media/outbound (not inbound)", () => {
111
+ const prompt = "/Users/henry/media/outbound/file.txt";
112
+ assert.deepEqual(extractInboundPaths(prompt), []);
113
+ });
114
+
115
+ it("no match: bare text media/inbound without leading path", () => {
116
+ const prompt = "check media/inbound/file.txt please";
117
+ assert.deepEqual(extractInboundPaths(prompt), []);
118
+ });
119
+
120
+ it("no match: empty prompt", () => {
121
+ assert.deepEqual(extractInboundPaths(""), []);
122
+ });
123
+
124
+ it("no match: path-like text without separator before media", () => {
125
+ const prompt = "wordmedia/inbound/file.txt";
126
+ assert.deepEqual(extractInboundPaths(prompt), []);
127
+ });
128
+
129
+ // ==================== Edge cases ====================
130
+
131
+ it("stops at Chinese comma after path", () => {
132
+ const prompt =
133
+ "文件已保存: /a/media/inbound/file.doc,请回答";
134
+ const result = extractInboundPaths(prompt);
135
+ assert.equal(result.length, 1);
136
+ // Should NOT include the Chinese comma or text after it
137
+ assert.ok(!result[0].includes(","));
138
+ });
139
+
140
+ it("stops at Chinese enumeration comma (、) after path", () => {
141
+ const prompt =
142
+ "上传 /a/media/inbound/one.txt、/a/media/inbound/two.txt 到项目";
143
+ const result = extractInboundPaths(prompt);
144
+ assert.equal(result.length, 2);
145
+ assert.ok(!result[0].includes("、"));
146
+ assert.ok(!result[1].includes("、"));
147
+ assert.ok(result.includes("/a/media/inbound/one.txt"));
148
+ assert.ok(result.includes("/a/media/inbound/two.txt"));
149
+ });
150
+
151
+ it("stops at parenthesis after path", () => {
152
+ const prompt =
153
+ "[media attached: /a/media/inbound/img.png (image/png) | /a/media/inbound/img.png]";
154
+ const result = extractInboundPaths(prompt);
155
+ // Should capture the path but not "(image/png)"
156
+ assert.ok(result.length >= 1);
157
+ assert.ok(result.some((p) => p === "/a/media/inbound/img.png"));
158
+ });
159
+
160
+ it("stops at double quote", () => {
161
+ const prompt = 'path is "/a/media/inbound/file.txt" here';
162
+ const result = extractInboundPaths(prompt);
163
+ assert.equal(result.length, 1);
164
+ assert.ok(!result[0].includes('"'));
165
+ });
166
+
167
+ it("path inside brackets", () => {
168
+ const prompt = "[/a/media/inbound/file.txt]";
169
+ const result = extractInboundPaths(prompt);
170
+ assert.equal(result.length, 1);
171
+ assert.equal(result[0], "/a/media/inbound/file.txt");
172
+ });
173
+
174
+ it("filename with parentheses (browser duplicate)", () => {
175
+ const prompt =
176
+ "C:\\Users\\test\\.openclaw\\workspace\\media\\inbound\\米家吸尘器2(1)-1774512141374.pdf 这个文件";
177
+ assert.deepEqual(extractInboundPaths(prompt), [
178
+ "C:\\Users\\test\\.openclaw\\workspace\\media\\inbound\\米家吸尘器2(1)-1774512141374.pdf",
179
+ ]);
180
+ });
181
+
182
+ it("filename with spaces", () => {
183
+ const prompt =
184
+ "C:\\Users\\test\\.openclaw\\workspace\\media\\inbound\\Contemporary Report-1774522842609.pdf 请分析";
185
+ assert.deepEqual(extractInboundPaths(prompt), [
186
+ "C:\\Users\\test\\.openclaw\\workspace\\media\\inbound\\Contemporary Report-1774522842609.pdf",
187
+ ]);
188
+ });
189
+
190
+ it("Unix: filename with spaces and parens", () => {
191
+ const prompt =
192
+ "看看 /Users/henry/.openclaw/media/inbound/My Document (2).pdf 这个";
193
+ assert.deepEqual(extractInboundPaths(prompt), [
194
+ "/Users/henry/.openclaw/media/inbound/My Document (2).pdf",
195
+ ]);
196
+ });
197
+ });