memorylake-openclaw 1.0.2-beta.1 → 1.0.2-beta.3

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 CHANGED
@@ -7,7 +7,7 @@
7
7
  * - 9 tools: memory_search, memory_list, memory_store, memory_get, memory_forget, document_search, document_download, advanced_web_search, open_data_search
8
8
  * - Auto-recall: injects relevant memories and document excerpts before each agent turn
9
9
  * - Auto-capture: stores key facts scoped to the current session after each agent turn
10
- * - CLI: openclaw memorylake search, openclaw memorylake stats
10
+ * - CLI: openclaw memorylake search, openclaw memorylake stats, openclaw memorylake upload
11
11
  */
12
12
 
13
13
  import fs from "node:fs";
@@ -1325,7 +1325,7 @@ const memoryPlugin = {
1325
1325
  name: "document_download",
1326
1326
  label: "Document Download",
1327
1327
  description:
1328
- "Download a document (image, PDF, etc.) from MemoryLake and send it to the user. Returns a temporary download URL. Use document_search first to find the document_id.",
1328
+ "Download a document (image, PDF, etc.) from MemoryLake and get a temporary download URL. After calling this tool, you MUST call the `message` tool with action='send' and media=<the returned URL> to deliver the file to the user.",
1329
1329
  parameters: Type.Object({
1330
1330
  documentId: Type.String({
1331
1331
  description:
@@ -1345,13 +1345,10 @@ const memoryPlugin = {
1345
1345
  content: [
1346
1346
  {
1347
1347
  type: "text",
1348
- text: `Document ${documentId} is ready. Tell the user you are sending the file now.\nMEDIA: ${downloadUrl}`,
1348
+ text: `Document ${documentId} ready. Download URL (expires in ~20 minutes):\n${downloadUrl}\n\nYou MUST now call the message tool with action="send" and media set to this URL to deliver the file to the user.`,
1349
1349
  },
1350
1350
  ],
1351
- details: {
1352
- media: { mediaUrl: downloadUrl },
1353
- documentId,
1354
- },
1351
+ details: { documentId },
1355
1352
  };
1356
1353
  } catch (err) {
1357
1354
  return {
@@ -1684,6 +1681,65 @@ const memoryPlugin = {
1684
1681
  }
1685
1682
  });
1686
1683
 
1684
+ memorylake
1685
+ .command("upload")
1686
+ .description("Upload files or directories to MemoryLake")
1687
+ .argument("<path>", "File or directory path to upload")
1688
+ .option("--agent <id>", "Agent ID (resolves workspace and per-agent projectId)")
1689
+ .option("--project-id <id>", "Override project ID (takes precedence over --agent)")
1690
+ .action(async (targetPath: string, opts: { agent?: string; projectId?: string }) => {
1691
+ // Resolve effective config: --project-id > agent workspace config > global config
1692
+ let effectiveCfg = cfg;
1693
+ if (opts.agent) {
1694
+ try {
1695
+ const openclawPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
1696
+ const openclaw = JSON.parse(fs.readFileSync(openclawPath, "utf-8"));
1697
+ const agents = openclaw?.agents;
1698
+ const agentEntry = agents?.list?.find((a: any) => a.id === opts.agent);
1699
+ const workspace = agentEntry?.workspace || agents?.defaults?.workspace;
1700
+ if (workspace) {
1701
+ effectiveCfg = resolveConfig({ workspaceDir: workspace });
1702
+ } else {
1703
+ console.warn(`Warning: no workspace found for agent "${opts.agent}", using global config.`);
1704
+ }
1705
+ } catch (err) {
1706
+ console.warn(`Warning: failed to resolve agent config: ${String(err)}, using global config.`);
1707
+ }
1708
+ }
1709
+ const effectiveProjectId = opts.projectId || effectiveCfg.projectId;
1710
+ if (!effectiveProjectId) {
1711
+ console.error("No project ID configured. Use --project-id or set up agent/workspace config.");
1712
+ return;
1713
+ }
1714
+
1715
+ // Lazy import upload.mjs (use uploadAuto to support archives)
1716
+ let uploadFn: (o: { host: string; apiKey: string; projectId: string; filePath: string; fileName: string }) => Promise<unknown>;
1717
+ try {
1718
+ const uploadModule = await import(
1719
+ /* webpackIgnore: true */
1720
+ new URL("./skills/memorylake-upload/scripts/upload.mjs", import.meta.url).href
1721
+ );
1722
+ uploadFn = uploadModule.uploadAuto;
1723
+ } catch (err) {
1724
+ console.error(`Failed to load upload module: ${String(err)}`);
1725
+ return;
1726
+ }
1727
+
1728
+ const absPath = path.resolve(targetPath);
1729
+
1730
+ try {
1731
+ await uploadFn({
1732
+ host: effectiveCfg.host,
1733
+ apiKey: effectiveCfg.apiKey,
1734
+ projectId: effectiveProjectId,
1735
+ filePath: absPath,
1736
+ fileName: path.basename(absPath),
1737
+ });
1738
+ } catch (err) {
1739
+ console.error(`Upload failed: ${String(err)}`);
1740
+ }
1741
+ });
1742
+
1687
1743
  memorylake
1688
1744
  .command("stats")
1689
1745
  .description("Show memory statistics from MemoryLake")
@@ -1760,7 +1816,7 @@ const memoryPlugin = {
1760
1816
  // Auto-upload: upload inbound files to MemoryLake before prompt build
1761
1817
  if (cfg.autoUpload) {
1762
1818
  // Lazy-load upload function from upload.mjs
1763
- let uploadFn: ((opts: { host: string; apiKey: string; projectId: string; filePath: string; fileName: string }) => Promise<unknown>) | undefined;
1819
+ let uploadAutoFn: ((opts: { host: string; apiKey: string; projectId: string; filePath: string; fileName: string }) => Promise<unknown>) | undefined;
1764
1820
 
1765
1821
  api.on("before_prompt_build", (event, ctx) => {
1766
1822
  if ((ctx as any)?.trigger !== "user") {
@@ -1785,17 +1841,17 @@ const memoryPlugin = {
1785
1841
  // Fire-and-forget: upload asynchronously without blocking
1786
1842
  (async () => {
1787
1843
  // Lazy import upload.mjs
1788
- if (!uploadFn) {
1844
+ if (!uploadAutoFn) {
1789
1845
  const uploadModule = await import(
1790
1846
  /* webpackIgnore: true */
1791
1847
  new URL("./skills/memorylake-upload/scripts/upload.mjs", import.meta.url).href
1792
1848
  );
1793
- uploadFn = uploadModule.upload;
1849
+ uploadAutoFn = uploadModule.uploadAuto;
1794
1850
  }
1795
1851
 
1796
1852
  for (const { filePath, stat } of filesToUpload) {
1797
1853
  try {
1798
- await uploadFn!({
1854
+ await uploadAutoFn!({
1799
1855
  host: effectiveCfg.host,
1800
1856
  apiKey: effectiveCfg.apiKey,
1801
1857
  projectId: effectiveCfg.projectId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorylake-openclaw",
3
- "version": "1.0.2-beta.1",
3
+ "version": "1.0.2-beta.3",
4
4
  "type": "module",
5
5
  "description": "MemoryLake memory backend for OpenClaw",
6
6
  "license": "MIT",
@@ -1,18 +1,20 @@
1
1
  ---
2
2
  name: memorylake-upload
3
- description: Use when the user wants to upload files, documents, PDFs, or other data files to MemoryLake and associate them with a project.
3
+ description: Use when the user wants to upload files, documents, PDFs, archives, directories, or other data to MemoryLake. Supports single files, archives (zip, tar.gz, tgz, tar.bz2, tar, rar, 7z, etc.) which are extracted then uploaded, and directories which are recursively uploaded.
4
4
  ---
5
5
 
6
6
  # MemoryLake File Upload
7
7
 
8
8
  ## Overview
9
9
 
10
- Upload local files to MemoryLake using the multipart upload API, then associate them with a project.
10
+ Upload local files, archives, or directories to MemoryLake using the multipart upload API, then associate them with a project. Archives are automatically detected, extracted, and each file inside is uploaded individually. Directories are recursively traversed and all files inside are uploaded.
11
11
 
12
12
  ## When to Use
13
13
 
14
14
  - User wants to upload a file (PDF, DOCX, image, etc.) to MemoryLake
15
15
  - User wants to add a local document to a MemoryLake project
16
+ - User wants to upload an archive (zip, tar.gz, tgz, tar.bz2, tbz2, tar.xz, txz, tar, gz, bz2, xz, rar, 7z) — files inside will be extracted and uploaded one by one
17
+ - User wants to upload an entire directory/folder — all files will be recursively collected and uploaded
16
18
 
17
19
  ## Step 1 -- Read MemoryLake Config
18
20
 
@@ -30,22 +32,39 @@ If the script exits with an error, stop and inform the user.
30
32
 
31
33
  The upload script is at `scripts/upload.mjs` relative to **this skill's SKILL.md**.
32
34
 
35
+ The script automatically detects the input type and handles it accordingly — plain files are uploaded directly, archives are extracted first, and directories are recursively traversed.
36
+
33
37
  ```bash
38
+ # Single file
34
39
  node {path-to-this-skill}/scripts/upload.mjs \
35
40
  --host {host} \
36
41
  --api-key {apiKey} \
37
42
  --project-id {projectId} \
38
43
  --file-name {fileName} \
39
44
  /path/to/file
45
+
46
+ # Archive (auto-detected, extracted, each file uploaded)
47
+ node {path-to-this-skill}/scripts/upload.mjs \
48
+ --host {host} \
49
+ --api-key {apiKey} \
50
+ --project-id {projectId} \
51
+ /path/to/archive.zip
52
+
53
+ # Directory (recursively uploads all files inside)
54
+ node {path-to-this-skill}/scripts/upload.mjs \
55
+ --host {host} \
56
+ --api-key {apiKey} \
57
+ --project-id {projectId} \
58
+ /path/to/my-folder/
40
59
  ```
41
60
 
42
- `--file-name` is the original file name as provided by the user (e.g., `report-Q1.pdf`). This is required because the local file path may be a temp path or renamed file that doesn't reflect the real name.
61
+ `--file-name` is the original file name as provided by the user (e.g., `report-Q1.pdf`). This is required because the local file path may be a temp path or renamed file that doesn't reflect the real name. For archives and directories, `--file-name` is not needed since individual file names are taken from the contents.
43
62
 
44
63
  ## Step 3 -- Handle Output
45
64
 
46
- The script prints progress for each step (create upload, upload parts, complete, add to project).
65
+ The script prints progress for each step (create upload, upload parts, complete, add to project). For archives and directories, it additionally prints a summary of succeeded/failed uploads.
47
66
 
48
- - **Success**: Report the document ID and file name to the user
67
+ - **Success**: Report the document ID and file name to the user. For archives/directories, report the total count and list each uploaded file
49
68
  - **Failure**: The script prints the specific error (file not found, auth failed, API error). Read the error message and relay it to the user — don't guess the cause
50
69
 
51
70
  ## Common Mistakes
@@ -20,6 +20,8 @@ import fs from 'fs';
20
20
  import path from 'path';
21
21
  import https from 'https';
22
22
  import http from 'http';
23
+ import os from 'os';
24
+ import { execFileSync } from 'child_process';
23
25
 
24
26
  // API base path
25
27
  const API_BASE = '/openapi/memorylake';
@@ -228,6 +230,233 @@ export async function upload({ host, apiKey, projectId, filePath, fileName }) {
228
230
  return doc;
229
231
  }
230
232
 
233
+ // ============================================================================
234
+ // Archive support
235
+ // ============================================================================
236
+
237
+ const ARCHIVE_EXTENSIONS = [
238
+ '.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz',
239
+ '.tar', '.zip', '.gz', '.bz2', '.xz', '.rar', '.7z',
240
+ ];
241
+
242
+ /**
243
+ * Check if a file is an archive based on its extension.
244
+ * Returns the matched extension or null.
245
+ */
246
+ function getArchiveExtension(filePath) {
247
+ const lower = filePath.toLowerCase();
248
+ // Check compound extensions first (e.g. .tar.gz before .gz)
249
+ for (const ext of ARCHIVE_EXTENSIONS) {
250
+ if (lower.endsWith(ext)) return ext;
251
+ }
252
+ return null;
253
+ }
254
+
255
+ /** Max buffer size for single-file decompression (gz/bz2/xz) stdout capture: 512 MB */
256
+ const DECOMPRESS_MAX_BUFFER = 512 * 1024 * 1024;
257
+
258
+ /**
259
+ * Extract an archive to the given destination directory.
260
+ */
261
+ const TOOL_INSTALL_HINTS = {
262
+ unzip: 'Install via: sudo apt install unzip (Linux) / brew install unzip (macOS) / choco install unzip (Windows)',
263
+ tar: 'tar should be pre-installed on most systems (Linux/macOS/Windows 10+)',
264
+ gunzip: 'Install via: sudo apt install gzip (Linux) / brew install gzip (macOS) / choco install gzip (Windows)',
265
+ bunzip2: 'Install via: sudo apt install bzip2 (Linux) / brew install bzip2 (macOS) / choco install bzip2 (Windows)',
266
+ xz: 'Install via: sudo apt install xz-utils (Linux) / brew install xz (macOS) / choco install xz (Windows)',
267
+ unrar: 'Install via: sudo apt install unrar (Linux) / brew install unrar (macOS) / choco install unrar (Windows)',
268
+ '7z': 'Install via: sudo apt install p7zip-full (Linux) / brew install 7zip (macOS) / choco install 7zip (Windows)',
269
+ };
270
+
271
+ function runExtractCommand(tool, args) {
272
+ try {
273
+ execFileSync(tool, args, { stdio: 'pipe' });
274
+ } catch (err) {
275
+ if (err.code === 'ENOENT') {
276
+ const hint = TOOL_INSTALL_HINTS[tool] || '';
277
+ throw new Error(`"${tool}" not found. ${hint}`);
278
+ }
279
+ throw err;
280
+ }
281
+ }
282
+
283
+ function extractArchive(archivePath, destDir) {
284
+ const ext = getArchiveExtension(archivePath);
285
+
286
+ switch (ext) {
287
+ case '.zip':
288
+ runExtractCommand('unzip', ['-o', archivePath, '-d', destDir]);
289
+ break;
290
+ case '.tar.gz':
291
+ case '.tgz':
292
+ runExtractCommand('tar', ['-xzf', archivePath, '-C', destDir]);
293
+ break;
294
+ case '.tar.bz2':
295
+ case '.tbz2':
296
+ runExtractCommand('tar', ['-xjf', archivePath, '-C', destDir]);
297
+ break;
298
+ case '.tar.xz':
299
+ case '.txz':
300
+ runExtractCommand('tar', ['-xJf', archivePath, '-C', destDir]);
301
+ break;
302
+ case '.tar':
303
+ runExtractCommand('tar', ['-xf', archivePath, '-C', destDir]);
304
+ break;
305
+ case '.gz': {
306
+ const baseName = path.basename(archivePath).slice(0, -3);
307
+ try {
308
+ const output = execFileSync('gunzip', ['-c', archivePath], { maxBuffer: DECOMPRESS_MAX_BUFFER });
309
+ fs.writeFileSync(path.join(destDir, baseName), output);
310
+ } catch (err) {
311
+ if (err.code === 'ENOENT') throw new Error(`"gunzip" not found. ${TOOL_INSTALL_HINTS.gunzip}`);
312
+ throw err;
313
+ }
314
+ break;
315
+ }
316
+ case '.bz2': {
317
+ const baseName = path.basename(archivePath).slice(0, -4);
318
+ try {
319
+ const output = execFileSync('bunzip2', ['-c', archivePath], { maxBuffer: DECOMPRESS_MAX_BUFFER });
320
+ fs.writeFileSync(path.join(destDir, baseName), output);
321
+ } catch (err) {
322
+ if (err.code === 'ENOENT') throw new Error(`"bunzip2" not found. ${TOOL_INSTALL_HINTS.bunzip2}`);
323
+ throw err;
324
+ }
325
+ break;
326
+ }
327
+ case '.xz': {
328
+ const baseName = path.basename(archivePath).slice(0, -3);
329
+ try {
330
+ const output = execFileSync('xz', ['-dc', archivePath], { maxBuffer: DECOMPRESS_MAX_BUFFER });
331
+ fs.writeFileSync(path.join(destDir, baseName), output);
332
+ } catch (err) {
333
+ if (err.code === 'ENOENT') throw new Error(`"xz" not found. ${TOOL_INSTALL_HINTS.xz}`);
334
+ throw err;
335
+ }
336
+ break;
337
+ }
338
+ case '.rar':
339
+ runExtractCommand('unrar', ['x', '-o+', archivePath, destDir + '/']);
340
+ break;
341
+ case '.7z':
342
+ runExtractCommand('7z', ['x', archivePath, `-o${destDir}`, '-y']);
343
+ break;
344
+ default:
345
+ throw new Error(`Unsupported archive format: ${path.basename(archivePath)}`);
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Names and directories to skip when collecting files.
351
+ */
352
+ const IGNORED_NAMES = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']);
353
+ const IGNORED_DIRS = new Set(['__MACOSX', '.Spotlight-V100', '.Trashes']);
354
+
355
+ /**
356
+ * Recursively collect all files in a directory, skipping hidden/system junk.
357
+ */
358
+ function collectFiles(dir) {
359
+ const files = [];
360
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
361
+ if (IGNORED_NAMES.has(entry.name)) continue;
362
+ const full = path.join(dir, entry.name);
363
+ if (entry.isDirectory()) {
364
+ if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
365
+ files.push(...collectFiles(full));
366
+ } else if (entry.isFile()) {
367
+ files.push(full);
368
+ }
369
+ }
370
+ return files;
371
+ }
372
+
373
+ /**
374
+ * Upload multiple files with progress summary.
375
+ * @param {Array<string>} files - absolute paths of files to upload
376
+ * @param {string} label - label for the summary line (e.g. "Archive" / "Directory")
377
+ */
378
+ async function uploadMany({ host, apiKey, projectId, files, label }) {
379
+ const results = [];
380
+ let success = 0;
381
+ let failed = 0;
382
+
383
+ for (const f of files) {
384
+ try {
385
+ const doc = await upload({
386
+ host, apiKey, projectId,
387
+ filePath: f,
388
+ fileName: path.basename(f),
389
+ });
390
+ results.push(doc);
391
+ success++;
392
+ } catch (err) {
393
+ console.error(`Failed to upload ${path.basename(f)}: ${err.message}`);
394
+ failed++;
395
+ }
396
+ }
397
+
398
+ console.log(`\n${label} upload complete: ${success} succeeded, ${failed} failed.`);
399
+ return results;
400
+ }
401
+
402
+ /**
403
+ * Smart upload: automatically handles plain files, archives, and directories.
404
+ *
405
+ * - Plain file → upload directly
406
+ * - Archive → extract to temp dir, upload each extracted file
407
+ * - Directory → recursively collect files, upload each
408
+ *
409
+ * Returns a single doc object for plain files, or an array for multi-file cases.
410
+ */
411
+ export async function uploadAuto({ host, apiKey, projectId, filePath, fileName }) {
412
+ if (!fs.existsSync(filePath)) {
413
+ throw new Error(`Path not found: ${filePath}`);
414
+ }
415
+
416
+ const stat = fs.statSync(filePath);
417
+
418
+ // --- Directory ---
419
+ if (stat.isDirectory()) {
420
+ const displayName = fileName || path.basename(filePath);
421
+ console.log(`\nUploading directory: ${displayName}`);
422
+
423
+ const files = collectFiles(filePath);
424
+ if (files.length === 0) {
425
+ console.log('Directory is empty, nothing to upload.');
426
+ return [];
427
+ }
428
+ console.log(`Found ${files.length} file(s)\n`);
429
+
430
+ return uploadMany({ host, apiKey, projectId, files, label: 'Directory' });
431
+ }
432
+
433
+ // --- Archive ---
434
+ if (getArchiveExtension(filePath)) {
435
+ const displayName = fileName || path.basename(filePath);
436
+ console.log(`\nDetected archive: ${displayName}`);
437
+ console.log('Extracting...');
438
+
439
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memorylake-upload-'));
440
+ try {
441
+ extractArchive(filePath, tmpDir);
442
+ const files = collectFiles(tmpDir);
443
+
444
+ if (files.length === 0) {
445
+ console.log('Archive is empty, nothing to upload.');
446
+ return [];
447
+ }
448
+ console.log(`Extracted ${files.length} file(s)\n`);
449
+
450
+ return await uploadMany({ host, apiKey, projectId, files, label: 'Archive' });
451
+ } finally {
452
+ fs.rmSync(tmpDir, { recursive: true, force: true });
453
+ }
454
+ }
455
+
456
+ // --- Plain file ---
457
+ return upload({ host, apiKey, projectId, filePath, fileName });
458
+ }
459
+
231
460
  // CLI entry point
232
461
  async function main() {
233
462
  const args = process.argv.slice(2);
@@ -237,17 +466,21 @@ async function main() {
237
466
  MemoryLake File Upload
238
467
 
239
468
  Usage:
240
- node upload.mjs --host <url> --api-key <key> --project-id <id> <file_path>
469
+ node upload.mjs --host <url> --api-key <key> --project-id <id> <path>
241
470
 
242
471
  Arguments:
243
472
  --host Base URL (e.g., http://10.71.10.71:3002)
244
473
  --api-key API key for authentication
245
474
  --project-id Project ID to associate the document with (required)
246
- --file-name Custom file name (default: basename of file_path)
247
- file_path Path to the file to upload
475
+ --file-name Custom file name (default: basename of path)
476
+ path Path to a file, archive, or directory to upload
477
+
478
+ Archives are auto-detected and extracted; directories are recursively uploaded.
248
479
 
249
480
  Examples:
250
481
  node upload.mjs --host http://10.71.10.71:3002 --api-key sk-xxx --project-id proj-abc123 document.pdf
482
+ node upload.mjs --host http://10.71.10.71:3002 --api-key sk-xxx --project-id proj-abc123 docs.zip
483
+ node upload.mjs --host http://10.71.10.71:3002 --api-key sk-xxx --project-id proj-abc123 ./my-folder/
251
484
  `);
252
485
  process.exit(0);
253
486
  }
@@ -262,7 +495,7 @@ Examples:
262
495
  }
263
496
 
264
497
  try {
265
- await upload({ host, apiKey, projectId, filePath, fileName });
498
+ await uploadAuto({ host, apiKey, projectId, filePath, fileName });
266
499
  console.log('\nDone!\n');
267
500
  } catch (err) {
268
501
  console.error(`\nError: ${err.message}\n`);