@engramx/client 0.1.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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -0
  3. package/dist/EngramClient.d.ts +204 -0
  4. package/dist/EngramClient.js +1168 -0
  5. package/dist/EngramClient.js.map +1 -0
  6. package/dist/ProfileManager.d.ts +61 -0
  7. package/dist/ProfileManager.js +116 -0
  8. package/dist/ProfileManager.js.map +1 -0
  9. package/dist/SessionManager.d.ts +46 -0
  10. package/dist/SessionManager.js +114 -0
  11. package/dist/SessionManager.js.map +1 -0
  12. package/dist/agent-skill.d.ts +150 -0
  13. package/dist/agent-skill.js +152 -0
  14. package/dist/agent-skill.js.map +1 -0
  15. package/dist/auth.d.ts +17 -0
  16. package/dist/auth.js +65 -0
  17. package/dist/auth.js.map +1 -0
  18. package/dist/bootstrap.d.ts +21 -0
  19. package/dist/bootstrap.js +71 -0
  20. package/dist/bootstrap.js.map +1 -0
  21. package/dist/cache.d.ts +11 -0
  22. package/dist/cache.js +41 -0
  23. package/dist/cache.js.map +1 -0
  24. package/dist/claude-setup.d.ts +29 -0
  25. package/dist/claude-setup.js +57 -0
  26. package/dist/claude-setup.js.map +1 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +1078 -0
  29. package/dist/cli.js.map +1 -0
  30. package/dist/encryption.d.ts +44 -0
  31. package/dist/encryption.js +108 -0
  32. package/dist/encryption.js.map +1 -0
  33. package/dist/index.d.ts +36 -0
  34. package/dist/index.js +23 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/migrate.d.ts +106 -0
  37. package/dist/migrate.js +882 -0
  38. package/dist/migrate.js.map +1 -0
  39. package/dist/offline.d.ts +21 -0
  40. package/dist/offline.js +90 -0
  41. package/dist/offline.js.map +1 -0
  42. package/dist/openclaw-setup.d.ts +24 -0
  43. package/dist/openclaw-setup.js +249 -0
  44. package/dist/openclaw-setup.js.map +1 -0
  45. package/dist/pair.d.ts +20 -0
  46. package/dist/pair.js +68 -0
  47. package/dist/pair.js.map +1 -0
  48. package/dist/path-utils.d.ts +16 -0
  49. package/dist/path-utils.js +29 -0
  50. package/dist/path-utils.js.map +1 -0
  51. package/dist/schema.d.ts +54 -0
  52. package/dist/schema.js +101 -0
  53. package/dist/schema.js.map +1 -0
  54. package/dist/types.d.ts +452 -0
  55. package/dist/types.js +2 -0
  56. package/dist/types.js.map +1 -0
  57. package/package.json +67 -0
@@ -0,0 +1,882 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { execFileSync } from 'child_process';
4
+ import { EngramClient } from './EngramClient';
5
+ function getOpenClawDir() {
6
+ return process.env['OPENCLAW_CONFIG_DIR'] || path.join(process.env['HOME'] || '', '.openclaw');
7
+ }
8
+ function loadOpenClawConfig(openclawDir) {
9
+ const configPath = process.env['OPENCLAW_CONFIG_PATH'] ?? path.join(openclawDir, 'openclaw.json');
10
+ if (!fs.existsSync(configPath))
11
+ return {};
12
+ try {
13
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
14
+ }
15
+ catch {
16
+ return {};
17
+ }
18
+ }
19
+ /**
20
+ * Resolve the memory directory from openclaw.json config.
21
+ *
22
+ * Priority:
23
+ * 1. EngramX plugin config memoryDir (plugins.entries["memory-engramx"].config.memoryDir)
24
+ * 2. Store path base directory (agents.defaults.memorySearch.store.path)
25
+ * 3. Default: {openclawDir}/memory
26
+ */
27
+ function resolveMemoryDir(openclawDir, ocConfig) {
28
+ // Check EngramX plugin config for a memoryDir override
29
+ const engramxEntry = ocConfig.plugins?.entries?.['memory-engramx'];
30
+ if (engramxEntry?.config?.memoryDir) {
31
+ const dir = engramxEntry.config.memoryDir;
32
+ // Handle relative paths as relative to openclawDir
33
+ return path.isAbsolute(dir) ? dir : path.join(openclawDir, dir);
34
+ }
35
+ // Check store path for base directory
36
+ const storePath = ocConfig.agents?.defaults?.memorySearch?.store?.path;
37
+ if (storePath) {
38
+ // Store path may contain {agentId} tokens — derive the base directory
39
+ const withoutToken = storePath.replace(/\{agentId\}.*$/, '');
40
+ const resolved = path.isAbsolute(withoutToken)
41
+ ? path.dirname(withoutToken)
42
+ : path.join(openclawDir, path.dirname(withoutToken));
43
+ if (fs.existsSync(resolved))
44
+ return resolved;
45
+ }
46
+ return path.join(openclawDir, 'memory');
47
+ }
48
+ /**
49
+ * Resolve the workspace directory from openclaw.json config.
50
+ *
51
+ * Priority:
52
+ * 1. agents.defaults.workspace
53
+ * 2. Default: {openclawDir}/workspace
54
+ *
55
+ * The workspace contains the agent's identity files (SOUL.md, USER.md,
56
+ * IDENTITY.md, AGENTS.md, etc.) and is separate from the memory directory.
57
+ */
58
+ function resolveWorkspaceDir(openclawDir, ocConfig) {
59
+ const workspace = ocConfig.agents?.defaults?.workspace;
60
+ if (workspace) {
61
+ return path.isAbsolute(workspace) ? workspace : path.join(openclawDir, workspace);
62
+ }
63
+ return path.join(openclawDir, 'workspace');
64
+ }
65
+ /**
66
+ * Resolve the SOUL.md path from openclaw.json config.
67
+ *
68
+ * Priority:
69
+ * 1. agents.defaults.soul.path (explicit override)
70
+ * 2. {workspaceDir}/SOUL.md
71
+ * 3. {openclawDir}/SOUL.md (legacy fallback)
72
+ */
73
+ function resolveSoulPath(openclawDir, ocConfig) {
74
+ const soulPath = ocConfig.agents?.defaults?.soul?.path;
75
+ if (soulPath) {
76
+ return path.isAbsolute(soulPath) ? soulPath : path.join(openclawDir, soulPath);
77
+ }
78
+ // Check workspace directory first
79
+ const workspaceDir = resolveWorkspaceDir(openclawDir, ocConfig);
80
+ const workspaceSoul = path.join(workspaceDir, 'SOUL.md');
81
+ if (fs.existsSync(workspaceSoul))
82
+ return workspaceSoul;
83
+ // Legacy fallback: directly in openclawDir
84
+ return path.join(openclawDir, 'SOUL.md');
85
+ }
86
+ // ── EngramX directory ─────────────────────────────────────────────
87
+ function getEngramXDir() {
88
+ return path.join(process.env['HOME'] || '', '.engramx');
89
+ }
90
+ function getCheckpointsDir() {
91
+ return path.join(getEngramXDir(), 'checkpoints');
92
+ }
93
+ function makeCheckpointId() {
94
+ // Filesystem-safe ISO timestamp: 2026-02-23T10-30-00-000Z
95
+ return new Date().toISOString().replace(/:/g, '-').replace('.', '-');
96
+ }
97
+ // ── Pairing validation ────────────────────────────────────────────
98
+ export async function hasValidPairing(options) {
99
+ const resolved = options.sessionKeyPath.startsWith('~')
100
+ ? path.join(process.env['HOME'] || '', options.sessionKeyPath.slice(1))
101
+ : options.sessionKeyPath;
102
+ if (!fs.existsSync(resolved))
103
+ return false;
104
+ try {
105
+ const client = new EngramClient({
106
+ canisterId: options.canisterId,
107
+ sessionKeyPath: options.sessionKeyPath,
108
+ host: options.host,
109
+ });
110
+ // Lightweight authenticated query — succeeds only if the operator is registered
111
+ await client.listBackups();
112
+ return true;
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ }
118
+ // ── Database detection (config-driven) ────────────────────────────
119
+ function detectDatabases(openclawDir, ocConfig) {
120
+ const databases = [];
121
+ const backend = ocConfig.memory?.backend;
122
+ if (!backend || backend === 'builtin') {
123
+ // SQLite: collect .sqlite files from openclawDir
124
+ const sqliteFiles = fs.existsSync(openclawDir)
125
+ ? fs
126
+ .readdirSync(openclawDir)
127
+ .filter((f) => f.endsWith('.sqlite'))
128
+ .map((f) => path.join(openclawDir, f))
129
+ : [];
130
+ if (sqliteFiles.length > 0) {
131
+ databases.push({ type: 'sqlite', paths: sqliteFiles });
132
+ }
133
+ }
134
+ if (backend === 'qmd') {
135
+ // QMD: agents/*/qmd/*.sqlite + *.db
136
+ const agentsDir = path.join(openclawDir, 'agents');
137
+ if (fs.existsSync(agentsDir)) {
138
+ const qmdPaths = [];
139
+ for (const agent of fs.readdirSync(agentsDir, { withFileTypes: true })) {
140
+ if (!agent.isDirectory())
141
+ continue;
142
+ const qmdDir = path.join(agentsDir, agent.name, 'qmd');
143
+ if (!fs.existsSync(qmdDir))
144
+ continue;
145
+ for (const f of fs.readdirSync(qmdDir)) {
146
+ if (f.endsWith('.sqlite') || f.endsWith('.db')) {
147
+ qmdPaths.push(path.join(qmdDir, f));
148
+ }
149
+ }
150
+ }
151
+ if (qmdPaths.length > 0) {
152
+ databases.push({ type: 'qmd', paths: qmdPaths });
153
+ }
154
+ }
155
+ }
156
+ const memorySlot = ocConfig.plugins?.slots?.memory;
157
+ if (memorySlot === 'memory-lancedb') {
158
+ const lanceDir = path.join(openclawDir, 'lancedb');
159
+ if (fs.existsSync(lanceDir)) {
160
+ databases.push({ type: 'lancedb', paths: [lanceDir] });
161
+ }
162
+ }
163
+ return databases;
164
+ }
165
+ // ── Checkpoint & Rollback ─────────────────────────────────────────
166
+ function copyRecursive(src, dest) {
167
+ if (fs.statSync(src).isDirectory()) {
168
+ fs.mkdirSync(dest, { recursive: true });
169
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
170
+ copyRecursive(path.join(src, entry.name), path.join(dest, entry.name));
171
+ }
172
+ }
173
+ else {
174
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
175
+ fs.copyFileSync(src, dest);
176
+ }
177
+ }
178
+ export function createCheckpoint(mode, openclawDir, memoryDir, ocConfig) {
179
+ const checkpointId = makeCheckpointId();
180
+ const checkpointDir = path.join(getCheckpointsDir(), checkpointId);
181
+ fs.mkdirSync(checkpointDir, { recursive: true });
182
+ const savedFiles = [];
183
+ const filesDir = path.join(checkpointDir, 'files');
184
+ fs.mkdirSync(filesDir, { recursive: true });
185
+ // Copy top-level special files
186
+ const soulPath = resolveSoulPath(openclawDir, ocConfig);
187
+ if (fs.existsSync(soulPath)) {
188
+ fs.copyFileSync(soulPath, path.join(filesDir, 'SOUL.md'));
189
+ savedFiles.push(soulPath);
190
+ }
191
+ const memoryMdPath = path.join(openclawDir, 'MEMORY.md');
192
+ if (fs.existsSync(memoryMdPath)) {
193
+ fs.copyFileSync(memoryMdPath, path.join(filesDir, 'MEMORY.md'));
194
+ savedFiles.push(memoryMdPath);
195
+ }
196
+ // Recursively copy *.md from memory directory
197
+ if (fs.existsSync(memoryDir)) {
198
+ const memoryDest = path.join(filesDir, 'memory');
199
+ copyMarkdownTree(memoryDir, memoryDest);
200
+ collectMarkdownPaths(memoryDir, savedFiles);
201
+ }
202
+ // Copy databases
203
+ const databases = detectDatabases(openclawDir, ocConfig);
204
+ const dbsDir = path.join(checkpointDir, 'databases');
205
+ const savedDatabases = [];
206
+ for (const db of databases) {
207
+ const dbDest = path.join(dbsDir, db.type);
208
+ fs.mkdirSync(dbDest, { recursive: true });
209
+ for (const dbPath of db.paths) {
210
+ if (!fs.existsSync(dbPath))
211
+ continue;
212
+ if (fs.statSync(dbPath).isDirectory()) {
213
+ copyRecursive(dbPath, path.join(dbDest, path.basename(dbPath)));
214
+ }
215
+ else {
216
+ fs.copyFileSync(dbPath, path.join(dbDest, path.basename(dbPath)));
217
+ }
218
+ }
219
+ savedDatabases.push({ type: db.type, paths: db.paths });
220
+ }
221
+ // Write checkpoint metadata
222
+ const metadata = {
223
+ timestamp: new Date().toISOString(),
224
+ mode,
225
+ memoryDir,
226
+ openclawDir,
227
+ paths: { files: savedFiles, databases: savedDatabases },
228
+ };
229
+ fs.writeFileSync(path.join(checkpointDir, 'checkpoint.json'), JSON.stringify(metadata, null, 2) + '\n');
230
+ return checkpointDir;
231
+ }
232
+ export function listCheckpoints() {
233
+ const checkpointsDir = getCheckpointsDir();
234
+ if (!fs.existsSync(checkpointsDir))
235
+ return [];
236
+ const summaries = [];
237
+ for (const entry of fs.readdirSync(checkpointsDir, { withFileTypes: true })) {
238
+ if (!entry.isDirectory())
239
+ continue;
240
+ const metaPath = path.join(checkpointsDir, entry.name, 'checkpoint.json');
241
+ if (!fs.existsSync(metaPath))
242
+ continue;
243
+ try {
244
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
245
+ summaries.push({
246
+ id: entry.name,
247
+ timestamp: meta.timestamp,
248
+ mode: meta.mode,
249
+ dir: path.join(checkpointsDir, entry.name),
250
+ });
251
+ }
252
+ catch (err) {
253
+ console.warn(`engramx: skipping corrupted checkpoint ${entry.name}: ${err instanceof Error ? err.message : String(err)}`);
254
+ }
255
+ }
256
+ // Sort newest first
257
+ summaries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
258
+ return summaries;
259
+ }
260
+ export function rollbackCheckpoint(checkpointId) {
261
+ const checkpoints = listCheckpoints();
262
+ if (checkpoints.length === 0) {
263
+ throw new Error('No checkpoints to roll back. Run "npx @engramx/client pair" first to create a checkpoint.');
264
+ }
265
+ let target;
266
+ if (checkpointId) {
267
+ const found = checkpoints.find((c) => c.id === checkpointId);
268
+ if (!found) {
269
+ throw new Error(`Checkpoint not found: ${checkpointId}`);
270
+ }
271
+ target = found;
272
+ }
273
+ else {
274
+ target = checkpoints[0]; // latest — length already checked above
275
+ }
276
+ const checkpointDir = target.dir;
277
+ const metadata = JSON.parse(fs.readFileSync(path.join(checkpointDir, 'checkpoint.json'), 'utf-8'));
278
+ const restoredFiles = [];
279
+ const restoredDatabases = [];
280
+ // Restore each file tracked in checkpoint metadata
281
+ const filesDir = path.join(checkpointDir, 'files');
282
+ const ocConfig = loadOpenClawConfig(metadata.openclawDir);
283
+ const soulPath = resolveSoulPath(metadata.openclawDir, ocConfig);
284
+ const workspaceDir = resolveWorkspaceDir(metadata.openclawDir, ocConfig);
285
+ // Build a helper to map checkpoint-relative path → original absolute path
286
+ function checkpointRelToOriginal(rel) {
287
+ // Guard: reject traversal sequences in checkpoint-relative paths
288
+ if (rel.includes('..') || rel.includes('\0')) {
289
+ throw new Error(`Unsafe checkpoint path: ${rel}`);
290
+ }
291
+ if (rel.startsWith('memory/') || rel.startsWith('memory\\')) {
292
+ const resolved = path.resolve(metadata.memoryDir, rel.slice('memory/'.length));
293
+ if (!resolved.startsWith(metadata.memoryDir + path.sep) && resolved !== metadata.memoryDir)
294
+ throw new Error(`Path escapes memory dir: ${rel}`);
295
+ return resolved;
296
+ }
297
+ // Top-level .md files are workspace files (SOUL.md, USER.md, IDENTITY.md, etc.)
298
+ if (rel.endsWith('.md') && !rel.includes('/')) {
299
+ if (rel === 'SOUL.md')
300
+ return soulPath;
301
+ if (fs.existsSync(workspaceDir))
302
+ return path.join(workspaceDir, rel);
303
+ return path.join(metadata.openclawDir, rel);
304
+ }
305
+ const resolved = path.resolve(metadata.openclawDir, rel);
306
+ if (!resolved.startsWith(metadata.openclawDir + path.sep) && resolved !== metadata.openclawDir)
307
+ throw new Error(`Path escapes openclaw dir: ${rel}`);
308
+ return resolved;
309
+ }
310
+ function originalToCheckpointRel(originalPath) {
311
+ // Check if it's a memory dir file
312
+ const memRel = path.relative(metadata.memoryDir, originalPath);
313
+ if (!memRel.startsWith('..')) {
314
+ return path.join('memory', memRel);
315
+ }
316
+ // Check if it's a workspace file
317
+ if (fs.existsSync(workspaceDir)) {
318
+ const wsRel = path.relative(workspaceDir, originalPath);
319
+ if (!wsRel.startsWith('..'))
320
+ return wsRel;
321
+ }
322
+ // Legacy: file directly in openclawDir
323
+ return path.relative(metadata.openclawDir, originalPath);
324
+ }
325
+ // Restore files tracked in metadata
326
+ const restoredRelPaths = new Set();
327
+ for (const originalPath of metadata.paths.files) {
328
+ const checkpointRelative = originalToCheckpointRel(originalPath);
329
+ restoredRelPaths.add(checkpointRelative);
330
+ const srcPath = path.join(filesDir, checkpointRelative);
331
+ if (!fs.existsSync(srcPath)) {
332
+ restoredFiles.push({ path: originalPath, status: 'skipped' });
333
+ continue;
334
+ }
335
+ try {
336
+ fs.mkdirSync(path.dirname(originalPath), { recursive: true });
337
+ fs.copyFileSync(srcPath, originalPath);
338
+ restoredFiles.push({ path: originalPath, status: 'restored' });
339
+ }
340
+ catch (err) {
341
+ restoredFiles.push({ path: originalPath, status: 'failed', error: err.message });
342
+ }
343
+ }
344
+ // Scan for physical checkpoint files not tracked in metadata
345
+ // (handles crash between unlinkSync and metadata write)
346
+ if (fs.existsSync(filesDir)) {
347
+ const physicalFiles = collectAllFiles(filesDir, filesDir);
348
+ for (const relPath of physicalFiles) {
349
+ if (restoredRelPaths.has(relPath))
350
+ continue;
351
+ const originalPath = checkpointRelToOriginal(relPath);
352
+ const srcPath = path.join(filesDir, relPath);
353
+ try {
354
+ fs.mkdirSync(path.dirname(originalPath), { recursive: true });
355
+ fs.copyFileSync(srcPath, originalPath);
356
+ restoredFiles.push({ path: originalPath, status: 'restored' });
357
+ }
358
+ catch (err) {
359
+ restoredFiles.push({ path: originalPath, status: 'failed', error: err.message });
360
+ }
361
+ }
362
+ }
363
+ // Restore databases
364
+ const dbsDir = path.join(checkpointDir, 'databases');
365
+ if (fs.existsSync(dbsDir)) {
366
+ for (const dbEntry of metadata.paths.databases) {
367
+ const dbSrc = path.join(dbsDir, dbEntry.type);
368
+ if (!fs.existsSync(dbSrc))
369
+ continue;
370
+ for (const originalPath of dbEntry.paths) {
371
+ const basename = path.basename(originalPath);
372
+ const srcPath = path.join(dbSrc, basename);
373
+ if (!fs.existsSync(srcPath))
374
+ continue;
375
+ fs.mkdirSync(path.dirname(originalPath), { recursive: true });
376
+ if (fs.statSync(srcPath).isDirectory()) {
377
+ if (fs.existsSync(originalPath)) {
378
+ fs.rmSync(originalPath, { recursive: true, force: true });
379
+ }
380
+ copyRecursive(srcPath, originalPath);
381
+ }
382
+ else {
383
+ fs.copyFileSync(srcPath, originalPath);
384
+ }
385
+ }
386
+ restoredDatabases.push(dbEntry.type);
387
+ }
388
+ }
389
+ const partial = restoredFiles.some((f) => f.status !== 'restored');
390
+ if (partial) {
391
+ // Keep checkpoint dir on partial failure so user can retry
392
+ }
393
+ else {
394
+ // Remove the rolled-back checkpoint on full success
395
+ fs.rmSync(checkpointDir, { recursive: true, force: true });
396
+ }
397
+ return { restoredFiles, restoredDatabases, checkpointTimestamp: metadata.timestamp, partial };
398
+ }
399
+ // ── Checkpoint metadata helper ─────────────────────────────────────
400
+ function writeCheckpointMeta(checkpointDir, metadata) {
401
+ const metaPath = path.join(checkpointDir, 'checkpoint.json');
402
+ const tmpPath = metaPath + '.tmp';
403
+ fs.writeFileSync(tmpPath, JSON.stringify(metadata, null, 2) + '\n');
404
+ fs.renameSync(tmpPath, metaPath);
405
+ }
406
+ async function withRetry(fn, attempts = 3, delayMs = 1000, onRetry) {
407
+ for (let i = 0; i < attempts; i++) {
408
+ try {
409
+ return await fn();
410
+ }
411
+ catch (err) {
412
+ if (i === attempts - 1)
413
+ throw err;
414
+ onRetry?.(i + 1, attempts);
415
+ await new Promise((r) => setTimeout(r, delayMs * 2 ** i));
416
+ }
417
+ }
418
+ throw new Error('unreachable');
419
+ }
420
+ // ── Migration ──────────────────────────────────────────────────────
421
+ function collectOpenClawFiles(openclawDir, ocConfig) {
422
+ const memoryDir = resolveMemoryDir(openclawDir, ocConfig);
423
+ const soulPath = resolveSoulPath(openclawDir, ocConfig);
424
+ const workspaceDir = resolveWorkspaceDir(openclawDir, ocConfig);
425
+ const scannedDirs = [openclawDir];
426
+ if (fs.existsSync(workspaceDir) && workspaceDir !== openclawDir) {
427
+ scannedDirs.push(workspaceDir);
428
+ }
429
+ if (fs.existsSync(memoryDir) && memoryDir !== openclawDir && memoryDir !== workspaceDir) {
430
+ scannedDirs.push(memoryDir);
431
+ }
432
+ const files = [];
433
+ const seen = new Set();
434
+ // Workspace .md files: SOUL.md, USER.md, IDENTITY.md, AGENTS.md, TOOLS.md, HEARTBEAT.md, etc.
435
+ // These are the agent's identity/config files. BOOTSTRAP.md is excluded (one-time use).
436
+ if (fs.existsSync(workspaceDir)) {
437
+ for (const entry of fs.readdirSync(workspaceDir, { withFileTypes: true })) {
438
+ if (!entry.isFile() || !entry.name.endsWith('.md'))
439
+ continue;
440
+ if (entry.name === 'BOOTSTRAP.md')
441
+ continue; // One-time onboarding file
442
+ const absolutePath = path.join(workspaceDir, entry.name);
443
+ files.push({ relativePath: entry.name, absolutePath });
444
+ seen.add(absolutePath);
445
+ }
446
+ }
447
+ // Legacy fallback: check for top-level files directly in openclawDir
448
+ // (for setups without a separate workspace directory)
449
+ const legacyFiles = [
450
+ { name: 'SOUL.md', absolutePath: soulPath },
451
+ { name: 'MEMORY.md', absolutePath: path.join(openclawDir, 'MEMORY.md') },
452
+ ];
453
+ for (const { name, absolutePath } of legacyFiles) {
454
+ if (!seen.has(absolutePath) && fs.existsSync(absolutePath)) {
455
+ files.push({ relativePath: name, absolutePath });
456
+ seen.add(absolutePath);
457
+ }
458
+ }
459
+ // Memory directory: *.md files stored by the memory plugin
460
+ if (fs.existsSync(memoryDir)) {
461
+ collectMarkdownFiles(memoryDir, memoryDir, files);
462
+ }
463
+ return { files, scannedDirs, memoryDir, soulPath };
464
+ }
465
+ async function migrateFiles(client, files, filesDir, metadata, checkpointDir, options, result, onProgress) {
466
+ for (const file of files) {
467
+ try {
468
+ const content = fs.readFileSync(file.absolutePath, 'utf-8');
469
+ if (!content.trim()) {
470
+ const event = {
471
+ type: 'skipped',
472
+ relativePath: file.relativePath,
473
+ absolutePath: file.absolutePath,
474
+ reason: 'empty',
475
+ };
476
+ result.files.push(event);
477
+ result.skipped++;
478
+ onProgress?.(event);
479
+ continue;
480
+ }
481
+ if (options.dryRun) {
482
+ const event = {
483
+ type: 'skipped',
484
+ relativePath: file.relativePath,
485
+ absolutePath: file.absolutePath,
486
+ reason: 'dry-run',
487
+ };
488
+ result.files.push(event);
489
+ result.skipped++;
490
+ onProgress?.(event);
491
+ continue;
492
+ }
493
+ // Idempotency check: if the file already exists on the canister, skip upload
494
+ // but still checkpoint and delete the local copy.
495
+ let alreadyOnEngram = false;
496
+ try {
497
+ await client.readMemory(file.relativePath);
498
+ alreadyOnEngram = true;
499
+ }
500
+ catch {
501
+ /* doesn't exist — proceed with upload */
502
+ }
503
+ if (alreadyOnEngram) {
504
+ // Checkpoint and delete the local file even though we skip the upload
505
+ if (filesDir) {
506
+ const checkpointFilePath = path.join(filesDir, file.relativePath);
507
+ fs.mkdirSync(path.dirname(checkpointFilePath), { recursive: true });
508
+ fs.copyFileSync(file.absolutePath, checkpointFilePath);
509
+ }
510
+ try {
511
+ fs.unlinkSync(file.absolutePath);
512
+ }
513
+ catch (unlinkErr) {
514
+ if (unlinkErr.code !== 'ENOENT')
515
+ throw unlinkErr;
516
+ }
517
+ metadata.paths.files.push(file.absolutePath);
518
+ if (checkpointDir)
519
+ writeCheckpointMeta(checkpointDir, metadata);
520
+ const event = {
521
+ type: 'skipped',
522
+ relativePath: file.relativePath,
523
+ absolutePath: file.absolutePath,
524
+ reason: 'already on engram',
525
+ };
526
+ result.files.push(event);
527
+ result.skipped++;
528
+ onProgress?.(event);
529
+ continue;
530
+ }
531
+ // Upload to engram with retry
532
+ await withRetry(() => client.appendMemory(file.relativePath, content), 3, 1000, (attempt, maxAttempts) => {
533
+ onProgress?.({
534
+ type: 'retry',
535
+ relativePath: file.relativePath,
536
+ absolutePath: file.absolutePath,
537
+ attempt,
538
+ maxAttempts,
539
+ });
540
+ });
541
+ // Copy file to checkpoint dir
542
+ const checkpointFilePath = path.join(filesDir, file.relativePath);
543
+ fs.mkdirSync(path.dirname(checkpointFilePath), { recursive: true });
544
+ fs.copyFileSync(file.absolutePath, checkpointFilePath);
545
+ // Delete from disk
546
+ try {
547
+ fs.unlinkSync(file.absolutePath);
548
+ }
549
+ catch (unlinkErr) {
550
+ if (unlinkErr.code !== 'ENOENT')
551
+ throw unlinkErr;
552
+ }
553
+ // Update checkpoint metadata incrementally
554
+ metadata.paths.files.push(file.absolutePath);
555
+ writeCheckpointMeta(checkpointDir, metadata);
556
+ const event = {
557
+ type: 'migrated',
558
+ relativePath: file.relativePath,
559
+ absolutePath: file.absolutePath,
560
+ };
561
+ result.files.push(event);
562
+ result.migrated++;
563
+ onProgress?.(event);
564
+ }
565
+ catch (err) {
566
+ const event = {
567
+ type: 'error',
568
+ relativePath: file.relativePath,
569
+ absolutePath: file.absolutePath,
570
+ error: err.message,
571
+ };
572
+ result.files.push(event);
573
+ result.errors.push(`${file.relativePath}: ${err.message}`);
574
+ onProgress?.(event);
575
+ // Stop on first error — file is still on disk, already-migrated files are in checkpoint
576
+ break;
577
+ }
578
+ }
579
+ }
580
+ export async function migrateOpenClawMemory(options, onProgress) {
581
+ const openclawDir = getOpenClawDir();
582
+ const ocConfig = loadOpenClawConfig(openclawDir);
583
+ const { files, scannedDirs, memoryDir } = collectOpenClawFiles(openclawDir, ocConfig);
584
+ // Emit scan event
585
+ onProgress?.({ type: 'scan', relativePath: '', absolutePath: '', totalFiles: files.length });
586
+ const result = {
587
+ checkpointDir: '',
588
+ scannedDirs,
589
+ files: [],
590
+ migrated: 0,
591
+ skipped: 0,
592
+ errors: [],
593
+ };
594
+ if (files.length === 0) {
595
+ return result;
596
+ }
597
+ if (options.dryRun) {
598
+ // Dry-run: skip checkpoint creation, DB backup, and client instantiation
599
+ await migrateFiles(null, files, '', {
600
+ timestamp: '',
601
+ mode: 'migration',
602
+ memoryDir,
603
+ openclawDir,
604
+ paths: { files: [], databases: [] },
605
+ }, '', options, result, onProgress);
606
+ return result;
607
+ }
608
+ // Create checkpoint dir (empty — no upfront bulk file copy)
609
+ const checkpointId = makeCheckpointId();
610
+ const checkpointDir = path.join(getCheckpointsDir(), checkpointId);
611
+ fs.mkdirSync(checkpointDir, { recursive: true });
612
+ const filesDir = path.join(checkpointDir, 'files');
613
+ fs.mkdirSync(filesDir, { recursive: true });
614
+ // Checkpoint databases upfront (safety copy, not migrated file-by-file)
615
+ const databases = detectDatabases(openclawDir, ocConfig);
616
+ const dbsDir = path.join(checkpointDir, 'databases');
617
+ const savedDatabases = [];
618
+ for (const db of databases) {
619
+ const dbDest = path.join(dbsDir, db.type);
620
+ fs.mkdirSync(dbDest, { recursive: true });
621
+ for (const dbPath of db.paths) {
622
+ if (!fs.existsSync(dbPath))
623
+ continue;
624
+ if (fs.statSync(dbPath).isDirectory()) {
625
+ copyRecursive(dbPath, path.join(dbDest, path.basename(dbPath)));
626
+ }
627
+ else {
628
+ fs.copyFileSync(dbPath, path.join(dbDest, path.basename(dbPath)));
629
+ }
630
+ }
631
+ savedDatabases.push({ type: db.type, paths: db.paths });
632
+ }
633
+ // Initialize checkpoint metadata (grows incrementally as files are migrated)
634
+ const metadata = {
635
+ timestamp: new Date().toISOString(),
636
+ mode: 'migration',
637
+ memoryDir,
638
+ openclawDir,
639
+ paths: { files: [], databases: savedDatabases },
640
+ };
641
+ writeCheckpointMeta(checkpointDir, metadata);
642
+ result.checkpointDir = checkpointDir;
643
+ const client = new EngramClient({
644
+ canisterId: options.canisterId,
645
+ sessionKeyPath: options.sessionKeyPath,
646
+ host: options.host,
647
+ });
648
+ await migrateFiles(client, files, filesDir, metadata, checkpointDir, options, result, onProgress);
649
+ return result;
650
+ }
651
+ // ── Resume / Incomplete checkpoint detection ──────────────────────
652
+ function collectAllFiles(dir, baseDir) {
653
+ const results = [];
654
+ if (!fs.existsSync(dir))
655
+ return results;
656
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
657
+ const fullPath = path.join(dir, entry.name);
658
+ if (entry.isDirectory()) {
659
+ results.push(...collectAllFiles(fullPath, baseDir));
660
+ }
661
+ else if (entry.isFile()) {
662
+ results.push(path.relative(baseDir, fullPath));
663
+ }
664
+ }
665
+ return results;
666
+ }
667
+ export function findIncompleteCheckpoint() {
668
+ const checkpoints = listCheckpoints();
669
+ const migrationCheckpoints = checkpoints.filter((c) => c.mode === 'migration');
670
+ for (const checkpoint of migrationCheckpoints) {
671
+ const metaPath = path.join(checkpoint.dir, 'checkpoint.json');
672
+ if (!fs.existsSync(metaPath))
673
+ continue;
674
+ let metadata;
675
+ try {
676
+ metadata = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
677
+ }
678
+ catch {
679
+ continue;
680
+ }
681
+ // Re-scan source dirs to find files that still exist on disk
682
+ const openclawDir = metadata.openclawDir;
683
+ const ocConfig = loadOpenClawConfig(openclawDir);
684
+ const { files: currentFiles } = collectOpenClawFiles(openclawDir, ocConfig);
685
+ if (currentFiles.length > 0) {
686
+ // There are source files that haven't been migrated yet
687
+ return {
688
+ checkpoint,
689
+ metadata,
690
+ migratedCount: metadata.paths.files.length,
691
+ remainingCount: currentFiles.length,
692
+ };
693
+ }
694
+ }
695
+ return null;
696
+ }
697
+ export async function resumeMigration(options, onProgress) {
698
+ const incomplete = findIncompleteCheckpoint();
699
+ if (!incomplete) {
700
+ throw new Error('No incomplete migration found to resume.');
701
+ }
702
+ const { checkpoint, metadata } = incomplete;
703
+ const checkpointDir = checkpoint.dir;
704
+ const filesDir = path.join(checkpointDir, 'files');
705
+ if (!fs.existsSync(filesDir)) {
706
+ fs.mkdirSync(filesDir, { recursive: true });
707
+ }
708
+ // Re-scan source dirs for remaining files
709
+ const openclawDir = metadata.openclawDir;
710
+ const ocConfig = loadOpenClawConfig(openclawDir);
711
+ const { files: allFiles, scannedDirs } = collectOpenClawFiles(openclawDir, ocConfig);
712
+ // Filter out already-migrated files (tracked in metadata.paths.files)
713
+ const migratedSet = new Set(metadata.paths.files);
714
+ const remainingFiles = allFiles.filter((f) => !migratedSet.has(f.absolutePath));
715
+ // Emit scan event
716
+ onProgress?.({
717
+ type: 'scan',
718
+ relativePath: '',
719
+ absolutePath: '',
720
+ totalFiles: remainingFiles.length,
721
+ });
722
+ const result = {
723
+ checkpointDir,
724
+ scannedDirs,
725
+ files: [],
726
+ migrated: 0,
727
+ skipped: 0,
728
+ errors: [],
729
+ };
730
+ if (remainingFiles.length === 0) {
731
+ return result;
732
+ }
733
+ const client = new EngramClient({
734
+ canisterId: options.canisterId,
735
+ sessionKeyPath: options.sessionKeyPath,
736
+ host: options.host,
737
+ });
738
+ await migrateFiles(client, remainingFiles, filesDir, metadata, checkpointDir, options, result, onProgress);
739
+ return result;
740
+ }
741
+ // ── Recovery ──────────────────────────────────────────────────────
742
+ export async function recoverOpenClaw(options) {
743
+ const openclawDir = getOpenClawDir();
744
+ const ocConfig = loadOpenClawConfig(openclawDir);
745
+ const memoryDir = resolveMemoryDir(openclawDir, ocConfig);
746
+ // Checkpoint local state before recovery
747
+ const checkpointDir = createCheckpoint('recovery', openclawDir, memoryDir, ocConfig);
748
+ const result = { checkpointDir, restoredBackups: [], errors: [] };
749
+ const client = new EngramClient({
750
+ canisterId: options.canisterId,
751
+ sessionKeyPath: options.sessionKeyPath,
752
+ host: options.host,
753
+ });
754
+ // List all backups and pick latest Finalized per dbType
755
+ let backups;
756
+ try {
757
+ backups = await client.listBackups();
758
+ }
759
+ catch (err) {
760
+ result.errors.push(`Failed to list backups: ${err.message}`);
761
+ return result;
762
+ }
763
+ const finalized = backups.filter((b) => 'Finalized' in b.status);
764
+ const latestByType = new Map();
765
+ for (const backup of finalized) {
766
+ const existing = latestByType.get(backup.dbType);
767
+ if (!existing || backup.createdAt > existing.createdAt) {
768
+ latestByType.set(backup.dbType, backup);
769
+ }
770
+ }
771
+ for (const [dbType, backup] of latestByType) {
772
+ try {
773
+ // Download all chunks (parallel batches of 4)
774
+ const BATCH_SIZE = 4;
775
+ const chunkCount = Number(backup.chunkCount);
776
+ const chunks = new Array(chunkCount);
777
+ for (let start = 0; start < chunkCount; start += BATCH_SIZE) {
778
+ const batch = Array.from({ length: Math.min(BATCH_SIZE, chunkCount - start) }, (_, i) => client.pullBackupChunk(backup.backupId, start + i).then((chunk) => {
779
+ chunks[start + i] = chunk;
780
+ }));
781
+ await Promise.all(batch);
782
+ }
783
+ // Concatenate chunks
784
+ const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
785
+ const data = new Uint8Array(totalLength);
786
+ let offset = 0;
787
+ for (const chunk of chunks) {
788
+ data.set(chunk, offset);
789
+ offset += chunk.length;
790
+ }
791
+ // Write to disk based on type
792
+ restoreBackupData(openclawDir, ocConfig, dbType, data);
793
+ result.restoredBackups.push({
794
+ dbType,
795
+ backupId: backup.backupId,
796
+ size: totalLength,
797
+ });
798
+ }
799
+ catch (err) {
800
+ result.errors.push(`${dbType} (${backup.backupId}): ${err.message}`);
801
+ }
802
+ }
803
+ return result;
804
+ }
805
+ function restoreBackupData(openclawDir, ocConfig, dbType, data) {
806
+ if (!/^[a-zA-Z0-9_-]+$/.test(dbType)) {
807
+ throw new Error(`Invalid dbType: ${dbType}`);
808
+ }
809
+ if (dbType === 'sqlite') {
810
+ // Single .sqlite file — write directly
811
+ const destDir = openclawDir;
812
+ fs.mkdirSync(destDir, { recursive: true });
813
+ fs.writeFileSync(path.join(destDir, 'openclaw.sqlite'), data);
814
+ }
815
+ else {
816
+ // tar.gz archive (qmd, lancedb, multi-sqlite) — extract to target directory
817
+ let extractDir;
818
+ if (dbType === 'qmd') {
819
+ extractDir = path.join(openclawDir, 'agents');
820
+ }
821
+ else if (dbType === 'lancedb') {
822
+ extractDir = path.join(openclawDir, 'lancedb');
823
+ }
824
+ else {
825
+ extractDir = openclawDir;
826
+ }
827
+ fs.mkdirSync(extractDir, { recursive: true });
828
+ const tmpFile = path.join(getEngramXDir(), `restore-${dbType}-${Date.now()}.tar.gz`);
829
+ try {
830
+ fs.writeFileSync(tmpFile, data);
831
+ execFileSync('tar', ['--no-same-owner', '-xzf', tmpFile, '-C', extractDir], {
832
+ timeout: 120000,
833
+ stdio: 'pipe',
834
+ });
835
+ }
836
+ finally {
837
+ if (fs.existsSync(tmpFile))
838
+ fs.unlinkSync(tmpFile);
839
+ }
840
+ }
841
+ }
842
+ // ── File helpers ──────────────────────────────────────────────────
843
+ function collectMarkdownFiles(dir, baseDir, files) {
844
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
845
+ for (const entry of entries) {
846
+ const fullPath = path.join(dir, entry.name);
847
+ if (entry.isDirectory()) {
848
+ collectMarkdownFiles(fullPath, baseDir, files);
849
+ }
850
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
851
+ const relativePath = path.relative(baseDir, fullPath);
852
+ files.push({ relativePath: `memory/${relativePath}`, absolutePath: fullPath });
853
+ }
854
+ }
855
+ }
856
+ function copyMarkdownTree(srcDir, destDir) {
857
+ if (!fs.existsSync(srcDir))
858
+ return;
859
+ fs.mkdirSync(destDir, { recursive: true });
860
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
861
+ const src = path.join(srcDir, entry.name);
862
+ const dest = path.join(destDir, entry.name);
863
+ if (entry.isDirectory()) {
864
+ copyMarkdownTree(src, dest);
865
+ }
866
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
867
+ fs.copyFileSync(src, dest);
868
+ }
869
+ }
870
+ }
871
+ function collectMarkdownPaths(dir, out) {
872
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
873
+ const fullPath = path.join(dir, entry.name);
874
+ if (entry.isDirectory()) {
875
+ collectMarkdownPaths(fullPath, out);
876
+ }
877
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
878
+ out.push(fullPath);
879
+ }
880
+ }
881
+ }
882
+ //# sourceMappingURL=migrate.js.map