@engramx/openclaw 0.1.0-rc.2

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/dist/index.js ADDED
@@ -0,0 +1,951 @@
1
+ /**
2
+ * OpenClaw Memory Plugin: EngramX (Hybrid Architecture)
3
+ *
4
+ * This plugin uses a hybrid approach:
5
+ *
6
+ * EngramX canister = source of truth for markdown memory files
7
+ * Local SQLite = performance cache for search (vector + FTS5)
8
+ * EngramX backup API = disaster recovery for the SQLite database
9
+ *
10
+ * How it works:
11
+ * 1. Local SQLite search (memory_search / memory_get) — delegated to
12
+ * OpenClaw's built-in memory-core runtime, which uses sqlite-vec
13
+ * and FTS5 for sub-100ms hybrid search. Never queries the canister.
14
+ *
15
+ * 2. Memory file sync — pulls markdown files from the engram canister
16
+ * to the local memory/ directory on startup and periodically. Local
17
+ * file changes are pushed back to the canister. The existing SQLite
18
+ * indexer watches these files and re-chunks/embeds automatically.
19
+ *
20
+ * 3. Database backup — auto-detects active databases (SQLite, QMD,
21
+ * LanceDB) from openclaw.json and periodically uploads snapshots
22
+ * to the engram canister's backup API for disaster recovery.
23
+ *
24
+ * 4. Session sync — serializes conversation transcripts to JSONL files
25
+ * stored in the canister under sessions/{agentId}/{key}.jsonl.
26
+ *
27
+ * 5. Supplementary engram_* tools — direct canister access for wallet,
28
+ * audit log, and cross-machine memory retrieval.
29
+ */
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+ import { createHash, randomBytes } from 'node:crypto';
33
+ import { execFileSync } from 'node:child_process';
34
+ import { tmpdir } from 'node:os';
35
+ import { Type } from '@sinclair/typebox';
36
+ import { EngramClient } from '@engramx/client';
37
+ import { engramxConfigSchema } from './config.js';
38
+ // ---------------------------------------------------------------------------
39
+ // Constants
40
+ // ---------------------------------------------------------------------------
41
+ const BACKUP_CHUNK_SIZE = 1_900_000; // ~1.9 MiB, under ICP's 2 MiB limit
42
+ const MAX_BACKUP_SIZE = 500 * 1024 * 1024; // 500 MiB limit
43
+ const SQLITE3_TIMEOUT_MS = 30_000;
44
+ const NS_PER_MS = 1_000_000;
45
+ const E8S_PER_ICP = 100_000_000;
46
+ const TAR_TIMEOUT_MS = 120_000;
47
+ const DB_TYPES = {
48
+ sqlite: 'openclaw-sqlite',
49
+ qmd: 'openclaw-qmd',
50
+ lancedb: 'openclaw-lancedb',
51
+ };
52
+ function loadOpenClawConfig(stateDir) {
53
+ const configPath = process.env['OPENCLAW_CONFIG_PATH'] ?? path.join(stateDir, 'openclaw.json');
54
+ if (!fs.existsSync(configPath))
55
+ return {};
56
+ try {
57
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
58
+ }
59
+ catch {
60
+ return {};
61
+ }
62
+ }
63
+ // ---------------------------------------------------------------------------
64
+ // Config-driven database detection
65
+ // ---------------------------------------------------------------------------
66
+ function detectDatabases(stateDir) {
67
+ const ocConfig = loadOpenClawConfig(stateDir);
68
+ const detected = [];
69
+ const backend = ocConfig.memory?.backend ?? 'builtin';
70
+ // SQLite (builtin) — active when memory.backend is "builtin" or absent
71
+ if (backend === 'builtin') {
72
+ const storePath = ocConfig.agents?.defaults?.memorySearch?.store?.path;
73
+ let baseDir;
74
+ if (storePath) {
75
+ baseDir = path.dirname(storePath.replace(/\{agentId\}/g, '*'));
76
+ if (baseDir.includes('*')) {
77
+ baseDir = path.dirname(storePath.replace(/\{agentId\}.*$/, ''));
78
+ }
79
+ }
80
+ else {
81
+ baseDir = path.join(stateDir, 'memory');
82
+ }
83
+ const sqliteFiles = findFilesByExt(baseDir, '.sqlite');
84
+ if (sqliteFiles.length > 0) {
85
+ detected.push({
86
+ dbType: DB_TYPES.sqlite,
87
+ suffix: 'sqlite',
88
+ label: 'SQLite (builtin)',
89
+ filePaths: sqliteFiles,
90
+ isDirectory: false,
91
+ });
92
+ }
93
+ }
94
+ // QMD — active when memory.backend === "qmd"
95
+ if (backend === 'qmd') {
96
+ const agentsDir = path.join(stateDir, 'agents');
97
+ const qmdFiles = [];
98
+ if (fs.existsSync(agentsDir)) {
99
+ for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
100
+ if (!entry.isDirectory())
101
+ continue;
102
+ const qmdDir = path.join(agentsDir, entry.name, 'qmd');
103
+ if (fs.existsSync(qmdDir)) {
104
+ qmdFiles.push(...findFilesByExt(qmdDir, '.sqlite'));
105
+ qmdFiles.push(...findFilesByExt(qmdDir, '.db'));
106
+ }
107
+ }
108
+ }
109
+ if (qmdFiles.length > 0) {
110
+ detected.push({
111
+ dbType: DB_TYPES.qmd,
112
+ suffix: 'qmd',
113
+ label: 'QMD (experimental)',
114
+ filePaths: qmdFiles,
115
+ isDirectory: false,
116
+ });
117
+ }
118
+ }
119
+ // LanceDB — active when plugins.slots.memory === "memory-lancedb" and enabled
120
+ if (ocConfig.plugins?.slots?.memory === 'memory-lancedb') {
121
+ const lanceEntry = ocConfig.plugins?.entries?.['memory-lancedb'];
122
+ if (lanceEntry?.enabled !== false) {
123
+ const dbPath = lanceEntry?.config?.dbPath ?? path.join(stateDir, 'memory', 'lancedb');
124
+ if (fs.existsSync(dbPath) && fs.statSync(dbPath).isDirectory()) {
125
+ detected.push({
126
+ dbType: DB_TYPES.lancedb,
127
+ suffix: 'lancedb',
128
+ label: 'LanceDB (plugin)',
129
+ filePaths: [dbPath],
130
+ isDirectory: true,
131
+ });
132
+ }
133
+ }
134
+ }
135
+ return detected;
136
+ }
137
+ function findFilesByExt(dir, ext) {
138
+ if (!fs.existsSync(dir))
139
+ return [];
140
+ try {
141
+ return fs
142
+ .readdirSync(dir, { withFileTypes: true })
143
+ .filter((e) => e.isFile() && e.name.endsWith(ext))
144
+ .map((e) => path.join(dir, e.name));
145
+ }
146
+ catch {
147
+ return [];
148
+ }
149
+ }
150
+ // ---------------------------------------------------------------------------
151
+ // Backup strategies
152
+ // ---------------------------------------------------------------------------
153
+ function snapshotSqliteFile(dbPath, logger) {
154
+ const tmpPath = path.join(fs.mkdtempSync(path.join(tmpdir(), 'engramx-sqlite-')), `backup-${randomBytes(8).toString('hex')}.sqlite`);
155
+ try {
156
+ execFileSync('sqlite3', [dbPath, `.backup '${tmpPath.replace(/'/g, "''")}'`], {
157
+ timeout: SQLITE3_TIMEOUT_MS,
158
+ stdio: 'pipe',
159
+ });
160
+ return fs.readFileSync(tmpPath);
161
+ }
162
+ catch (err) {
163
+ const msg = err instanceof Error ? err.message : String(err);
164
+ if (msg.includes('ENOENT') || msg.includes('not found')) {
165
+ logger?.warn('memory-engramx: sqlite3 CLI not available, falling back to direct file read. ' +
166
+ 'WAL data may not be included.');
167
+ return fs.readFileSync(dbPath);
168
+ }
169
+ throw err;
170
+ }
171
+ finally {
172
+ try {
173
+ fs.rmSync(path.dirname(tmpPath), { recursive: true, force: true });
174
+ }
175
+ catch {
176
+ /* best-effort */
177
+ }
178
+ }
179
+ }
180
+ function tarDirectory(dirPath) {
181
+ const tmpDir = fs.mkdtempSync(path.join(tmpdir(), 'engramx-tar-'));
182
+ const tarPath = path.join(tmpDir, 'backup.tar.gz');
183
+ try {
184
+ execFileSync('tar', ['-czf', tarPath, '-C', path.dirname(dirPath), path.basename(dirPath)], {
185
+ timeout: TAR_TIMEOUT_MS,
186
+ stdio: 'pipe',
187
+ });
188
+ return fs.readFileSync(tarPath);
189
+ }
190
+ finally {
191
+ try {
192
+ fs.rmSync(tmpDir, { recursive: true, force: true });
193
+ }
194
+ catch {
195
+ /* best-effort */
196
+ }
197
+ }
198
+ }
199
+ function snapshotAndTarMultiple(filePaths, logger) {
200
+ const tmpDir = fs.mkdtempSync(path.join(tmpdir(), 'engramx-qmd-'));
201
+ try {
202
+ for (const fp of filePaths) {
203
+ const data = snapshotSqliteFile(fp, logger);
204
+ fs.writeFileSync(path.join(tmpDir, Buffer.from(fp).toString('base64url') + '.sqlite'), data);
205
+ }
206
+ const tarPath = path.join(tmpDir, 'backup.tar.gz');
207
+ execFileSync('tar', ['-czf', tarPath, '-C', tmpDir, '--exclude', 'backup.tar.gz', '.'], {
208
+ timeout: TAR_TIMEOUT_MS,
209
+ stdio: 'pipe',
210
+ });
211
+ return fs.readFileSync(tarPath);
212
+ }
213
+ finally {
214
+ try {
215
+ fs.rmSync(tmpDir, { recursive: true, force: true });
216
+ }
217
+ catch {
218
+ /* best-effort */
219
+ }
220
+ }
221
+ }
222
+ function prepareBackupData(db, logger) {
223
+ if (db.suffix === 'sqlite') {
224
+ return db.filePaths.length === 1
225
+ ? snapshotSqliteFile(db.filePaths[0], logger)
226
+ : snapshotAndTarMultiple(db.filePaths, logger);
227
+ }
228
+ if (db.suffix === 'qmd') {
229
+ return snapshotAndTarMultiple(db.filePaths, logger);
230
+ }
231
+ if (db.suffix === 'lancedb') {
232
+ return tarDirectory(db.filePaths[0]);
233
+ }
234
+ throw new Error(`Unknown database suffix: ${db.suffix}`);
235
+ }
236
+ // ---------------------------------------------------------------------------
237
+ // Helpers
238
+ // ---------------------------------------------------------------------------
239
+ function resolveMemoryDir(api, override) {
240
+ if (override)
241
+ return api.resolvePath(override);
242
+ // Default: workspace memory directory that OpenClaw watches
243
+ return api.resolvePath('memory');
244
+ }
245
+ function sha256Buffer(data) {
246
+ return createHash('sha256').update(data).digest('hex');
247
+ }
248
+ // ---------------------------------------------------------------------------
249
+ // Plugin definition
250
+ // ---------------------------------------------------------------------------
251
+ const memoryEngramXPlugin = {
252
+ id: 'memory-engramx',
253
+ name: 'Memory (EngramX)',
254
+ description: 'Hybrid memory: local SQLite search + EngramX canister as source of truth',
255
+ kind: 'memory',
256
+ configSchema: engramxConfigSchema,
257
+ register(api) {
258
+ const cfg = engramxConfigSchema.parse(api.pluginConfig);
259
+ const memoryDir = resolveMemoryDir(api, cfg.memoryDir);
260
+ const engram = new EngramClient({
261
+ canisterId: cfg.canisterId,
262
+ sessionKeyPath: cfg.sessionKeyPath,
263
+ host: cfg.host,
264
+ cacheMaxAge: 60_000,
265
+ });
266
+ api.logger.info(`memory-engramx: registered (canister: ${cfg.canisterId}, host: ${cfg.host ?? 'https://ic0.app'})`);
267
+ // ========================================================================
268
+ // 1. Local search — delegate to built-in memory-core runtime
269
+ // ========================================================================
270
+ api.registerTool((ctx) => {
271
+ const memorySearchTool = api.runtime.tools.createMemorySearchTool({
272
+ config: ctx.config,
273
+ agentSessionKey: ctx.sessionKey,
274
+ });
275
+ const memoryGetTool = api.runtime.tools.createMemoryGetTool({
276
+ config: ctx.config,
277
+ agentSessionKey: ctx.sessionKey,
278
+ });
279
+ if (!memorySearchTool || !memoryGetTool) {
280
+ return null;
281
+ }
282
+ return [memorySearchTool, memoryGetTool];
283
+ }, { names: ['memory_search', 'memory_get'] });
284
+ api.registerCli(({ program }) => {
285
+ api.runtime.tools.registerMemoryCli(program);
286
+ }, { commands: ['memory'] });
287
+ /** Pull all memory files from engram canister → local memory/ dir */
288
+ async function pullMemoryFiles() {
289
+ const remoteFiles = await engram.listMemoryFiles();
290
+ const remotePaths = new Set(remoteFiles.map((f) => f.path));
291
+ const result = { pulled: [], deleted: [] };
292
+ for (const info of remoteFiles) {
293
+ const localPath = path.join(memoryDir, info.path);
294
+ // Guard against path traversal from canister-supplied paths
295
+ const resolvedLocal = path.resolve(localPath);
296
+ if (!resolvedLocal.startsWith(memoryDir + path.sep) && resolvedLocal !== memoryDir) {
297
+ api.logger.warn(`memory-engramx: Path traversal blocked: ${info.path}`);
298
+ continue;
299
+ }
300
+ const localDir = path.dirname(localPath);
301
+ let needsPull = true;
302
+ let remoteContent = null;
303
+ try {
304
+ const remote = await engram.readMemory(info.path);
305
+ remoteContent = remote.content;
306
+ const localContent = fs.readFileSync(localPath, 'utf-8');
307
+ const localHash = createHash('sha256').update(localContent).digest('hex');
308
+ const remoteHash = createHash('sha256').update(remoteContent).digest('hex');
309
+ if (localHash === remoteHash) {
310
+ needsPull = false;
311
+ }
312
+ }
313
+ catch {
314
+ // File doesn't exist locally or read error — pull it
315
+ }
316
+ if (needsPull) {
317
+ if (!remoteContent) {
318
+ const remote = await engram.readMemory(info.path);
319
+ remoteContent = remote.content;
320
+ }
321
+ fs.mkdirSync(localDir, { recursive: true });
322
+ const lstat = fs.lstatSync(localPath, { throwIfNoEntry: false });
323
+ if (lstat && lstat.isSymbolicLink()) {
324
+ throw new Error(`Symlink not allowed: ${localPath}`);
325
+ }
326
+ fs.writeFileSync(localPath, remoteContent, 'utf-8');
327
+ result.pulled.push(info.path);
328
+ }
329
+ }
330
+ // Detect local .md files that no longer exist on the canister (owner deleted via frontend)
331
+ function collectLocalMd(dir) {
332
+ const paths = [];
333
+ if (!fs.existsSync(dir))
334
+ return paths;
335
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
336
+ if (entry.name.startsWith('.'))
337
+ continue;
338
+ const full = path.join(dir, entry.name);
339
+ if (entry.isDirectory()) {
340
+ paths.push(...collectLocalMd(full));
341
+ }
342
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
343
+ paths.push(path.relative(memoryDir, full));
344
+ }
345
+ }
346
+ return paths;
347
+ }
348
+ const localFiles = collectLocalMd(memoryDir);
349
+ const toDelete = localFiles.filter((f) => !remotePaths.has(f));
350
+ if (toDelete.length > localFiles.length / 2 && toDelete.length > 5) {
351
+ api.logger.warn(`memory-engramx: bulk deletion blocked (${toDelete.length}/${localFiles.length} files) — possible canister data loss`);
352
+ }
353
+ else {
354
+ for (const localRel of toDelete) {
355
+ const localPath = path.join(memoryDir, localRel);
356
+ try {
357
+ fs.unlinkSync(localPath);
358
+ result.deleted.push(localRel);
359
+ }
360
+ catch {
361
+ // best-effort
362
+ }
363
+ }
364
+ }
365
+ return result;
366
+ }
367
+ /** Push a local memory file → engram canister */
368
+ async function pushMemoryFile(filePath) {
369
+ const relativePath = path.relative(memoryDir, filePath);
370
+ const resolvedPush = path.resolve(memoryDir, relativePath);
371
+ if (!resolvedPush.startsWith(memoryDir + path.sep) && resolvedPush !== memoryDir)
372
+ return;
373
+ const content = fs.readFileSync(filePath, 'utf-8');
374
+ await engram.appendMemory(relativePath, content);
375
+ }
376
+ /** Full bidirectional sync */
377
+ async function syncMemoryFiles() {
378
+ // Pull remote → local
379
+ const pullResult = await pullMemoryFiles();
380
+ // Push local → remote (files that don't exist remotely)
381
+ let pushed = 0;
382
+ const pushedFiles = [];
383
+ const remoteFiles = await engram.listMemoryFiles();
384
+ const remotePaths = new Set(remoteFiles.map((f) => f.path));
385
+ const pushPromises = [];
386
+ function walkDir(dir) {
387
+ if (!fs.existsSync(dir))
388
+ return;
389
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
390
+ const full = path.join(dir, entry.name);
391
+ if (entry.isDirectory()) {
392
+ walkDir(full);
393
+ }
394
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
395
+ const rel = path.relative(memoryDir, full);
396
+ if (!remotePaths.has(rel)) {
397
+ pushPromises.push(pushMemoryFile(full)
398
+ .then(() => {
399
+ pushed++;
400
+ pushedFiles.push(rel);
401
+ })
402
+ .catch((err) => api.logger.warn(`memory-engramx: push failed for ${rel}: ${String(err)}`)));
403
+ }
404
+ }
405
+ }
406
+ }
407
+ walkDir(memoryDir);
408
+ await Promise.allSettled(pushPromises);
409
+ // Also sync top-level MEMORY.md if it exists. Operators can append-create
410
+ // a non-existent file but cannot overwrite an existing one (append-only).
411
+ // The `!remotePaths.has(...)` check above ensures this is a first-time push.
412
+ const topMemory = path.resolve(path.dirname(memoryDir), 'MEMORY.md');
413
+ if (fs.existsSync(topMemory) && !remotePaths.has('MEMORY.md')) {
414
+ try {
415
+ const content = fs.readFileSync(topMemory, 'utf-8');
416
+ await engram.appendMemory('MEMORY.md', content);
417
+ pushed++;
418
+ pushedFiles.push('MEMORY.md');
419
+ }
420
+ catch (err) {
421
+ api.logger.warn(`memory-engramx: push MEMORY.md failed: ${String(err)}`);
422
+ }
423
+ }
424
+ // Build descriptive commit message
425
+ let commitMessage = null;
426
+ const parts = [];
427
+ if (pullResult.pulled.length > 0) {
428
+ parts.push(`pulled ${pullResult.pulled.join(', ')}`);
429
+ }
430
+ if (pullResult.deleted.length > 0) {
431
+ parts.push(`deleted ${pullResult.deleted.join(', ')}`);
432
+ }
433
+ if (pushedFiles.length > 0) {
434
+ parts.push(`pushed ${pushedFiles.join(', ')}`);
435
+ }
436
+ if (parts.length > 0) {
437
+ commitMessage = `engramx: sync — ${parts.join('; ')}`;
438
+ api.logger.info(`memory-engramx: synced (${parts.join(', ')})`);
439
+ }
440
+ return { pullResult, pushed, commitMessage };
441
+ }
442
+ // ========================================================================
443
+ // 3. Git versioning (memory dir → git repo → GitHub)
444
+ // ========================================================================
445
+ function gitExec(args) {
446
+ try {
447
+ return execFileSync('git', args, {
448
+ cwd: memoryDir,
449
+ stdio: 'pipe',
450
+ timeout: 30_000,
451
+ })
452
+ .toString()
453
+ .trim();
454
+ }
455
+ catch {
456
+ return null;
457
+ }
458
+ }
459
+ function gitIsRepo() {
460
+ return gitExec(['rev-parse', '--is-inside-work-tree']) === 'true';
461
+ }
462
+ function gitHasRemote() {
463
+ const remotes = gitExec(['remote']);
464
+ return remotes !== null && remotes.length > 0;
465
+ }
466
+ function gitInit() {
467
+ if (gitIsRepo())
468
+ return true;
469
+ const result = gitExec(['init']);
470
+ if (result === null)
471
+ return false;
472
+ // Create .gitignore for non-markdown artifacts
473
+ const gitignorePath = path.join(memoryDir, '.gitignore');
474
+ if (!fs.existsSync(gitignorePath)) {
475
+ fs.writeFileSync(gitignorePath, [
476
+ '# Auto-generated by memory-engramx',
477
+ '*.sqlite',
478
+ '*.sqlite-wal',
479
+ '*.sqlite-shm',
480
+ '*.db',
481
+ '*.db-wal',
482
+ '*.db-shm',
483
+ 'lancedb/',
484
+ '.DS_Store',
485
+ '',
486
+ ].join('\n'));
487
+ }
488
+ return true;
489
+ }
490
+ function gitCommit(msg) {
491
+ // Stage all changes in memory dir
492
+ if (gitExec(['add', '-A']) === null)
493
+ return false;
494
+ // Check if there are staged changes
495
+ const status = gitExec(['diff', '--cached', '--stat']);
496
+ if (!status)
497
+ return false; // nothing to commit
498
+ const commitMsg = msg ?? `engramx: sync ${new Date().toISOString()}`;
499
+ // Use execFileSync (array form) to avoid shell injection via commit message
500
+ try {
501
+ execFileSync('git', ['commit', '-m', commitMsg], {
502
+ cwd: memoryDir,
503
+ stdio: 'pipe',
504
+ timeout: 30_000,
505
+ });
506
+ return true;
507
+ }
508
+ catch {
509
+ return false;
510
+ }
511
+ }
512
+ function gitPush() {
513
+ if (!gitHasRemote())
514
+ return false;
515
+ return gitExec(['push']) !== null;
516
+ }
517
+ // ========================================================================
518
+ // 4. Database backup (config-driven detection → engram backup API)
519
+ // ========================================================================
520
+ async function backupAllDatabases() {
521
+ const stateDir = process.env['OPENCLAW_STATE_DIR'] || path.join(process.env['HOME'] || '', '.openclaw');
522
+ const databases = detectDatabases(stateDir);
523
+ const result = { backed: [], skipped: [] };
524
+ if (databases.length === 0) {
525
+ api.logger.info('memory-engramx: no databases detected for backup');
526
+ return result;
527
+ }
528
+ for (const db of databases) {
529
+ try {
530
+ api.logger.info(`memory-engramx: preparing backup for ${db.label}...`);
531
+ const data = prepareBackupData(db, api.logger);
532
+ if (data.length > MAX_BACKUP_SIZE) {
533
+ api.logger.warn(`memory-engramx: ${db.label} backup is ${(data.length / 1024 / 1024).toFixed(0)} MiB, ` +
534
+ `exceeds ${MAX_BACKUP_SIZE / 1024 / 1024} MiB limit — skipping`);
535
+ result.skipped.push(`${db.label} (too large)`);
536
+ continue;
537
+ }
538
+ // SHA-256 dedup: skip if latest finalized backup matches
539
+ const hash = sha256Buffer(data);
540
+ const existing = await engram.latestBackupMatchesHash(db.dbType, hash);
541
+ if (existing) {
542
+ api.logger.info(`memory-engramx: ${db.label}: sha256 match (${existing.backupId}), skipping`);
543
+ result.skipped.push(`${db.label} (unchanged)`);
544
+ continue;
545
+ }
546
+ const label = cfg.backup.dbLabel || `${db.suffix}-${new Date().toISOString().slice(0, 10)}`;
547
+ const backupId = await engram.initBackup(db.dbType, { FullSnapshot: null }, null, hash, label);
548
+ let chunkIndex = 0;
549
+ for (let offset = 0; offset < data.length; offset += BACKUP_CHUNK_SIZE) {
550
+ const chunk = new Uint8Array(data.buffer, data.byteOffset + offset, Math.min(BACKUP_CHUNK_SIZE, data.length - offset));
551
+ await engram.pushBackupChunk(backupId, chunkIndex, chunk);
552
+ chunkIndex++;
553
+ }
554
+ await engram.finalizeBackup(backupId);
555
+ api.logger.info(`memory-engramx: ${db.label} backed up (${chunkIndex} chunks, id: ${backupId}, sha256: ${hash})`);
556
+ result.backed.push(db.label);
557
+ }
558
+ catch (err) {
559
+ api.logger.warn(`memory-engramx: backup failed for ${db.label}: ${String(err)}`);
560
+ result.skipped.push(`${db.label} (error)`);
561
+ }
562
+ }
563
+ return result;
564
+ }
565
+ // ========================================================================
566
+ // 5. Supplementary engram_* tools (direct canister access)
567
+ // ========================================================================
568
+ api.registerTool({
569
+ name: 'engram_read_memory',
570
+ label: 'Engram Read Memory',
571
+ description: 'Read a memory file directly from the engram canister (for cross-machine access or verification)',
572
+ parameters: Type.Object({
573
+ path: Type.String({ description: 'File path in the engram' }),
574
+ }),
575
+ async execute(_toolCallId, params) {
576
+ const { path: filePath } = params;
577
+ const file = await engram.readMemory(filePath);
578
+ return {
579
+ content: [{ type: 'text', text: file.content }],
580
+ details: { path: filePath, version: Number(file.version) },
581
+ };
582
+ },
583
+ }, { name: 'engram_read_memory' });
584
+ api.registerTool({
585
+ name: 'engram_append_memory',
586
+ label: 'Engram Append Memory',
587
+ description: 'Append content to a memory file. Writes to both the engram canister (source of truth) and local disk (for SQLite indexing). Owner-protected paths (e.g. SOUL.md) are read-only for operators.',
588
+ parameters: Type.Object({
589
+ path: Type.String({ description: 'File path' }),
590
+ content: Type.String({ description: 'Content to append' }),
591
+ }),
592
+ async execute(_toolCallId, params) {
593
+ const { path: filePath, content } = params;
594
+ // Validate path before any canister or disk writes
595
+ if (cfg.sync.pushOnWrite) {
596
+ const localPath = path.join(memoryDir, filePath);
597
+ const resolvedAppend = path.resolve(localPath);
598
+ if (!resolvedAppend.startsWith(memoryDir + path.sep) && resolvedAppend !== memoryDir) {
599
+ return {
600
+ content: [{ type: 'text', text: `Path traversal blocked: ${filePath}` }],
601
+ details: { error: 'path_traversal' },
602
+ };
603
+ }
604
+ }
605
+ // Write to engram canister (source of truth)
606
+ const version = await engram.appendMemory(filePath, content);
607
+ // Write to local disk so SQLite indexer picks it up
608
+ if (cfg.sync.pushOnWrite) {
609
+ const localPath = path.join(memoryDir, filePath);
610
+ const localDir = path.dirname(localPath);
611
+ fs.mkdirSync(localDir, { recursive: true });
612
+ // Symlink check before writing
613
+ const lstat = fs.lstatSync(localPath, { throwIfNoEntry: false });
614
+ if (lstat && lstat.isSymbolicLink()) {
615
+ throw new Error(`Symlink not allowed: ${localPath}`);
616
+ }
617
+ // Append to local file (matching what was sent to the canister)
618
+ if (fs.existsSync(localPath)) {
619
+ fs.appendFileSync(localPath, '\n' + content, 'utf-8');
620
+ }
621
+ else {
622
+ fs.writeFileSync(localPath, content, 'utf-8');
623
+ }
624
+ }
625
+ if (cfg.git.enabled) {
626
+ gitCommit(`engramx: append to ${filePath}`);
627
+ }
628
+ return {
629
+ content: [{ type: 'text', text: `Appended to ${filePath} (version ${version})` }],
630
+ details: { path: filePath, version: Number(version) },
631
+ };
632
+ },
633
+ }, { name: 'engram_append_memory' });
634
+ api.registerTool({
635
+ name: 'engram_list_memory',
636
+ label: 'Engram List Memory',
637
+ description: 'List all memory files stored in the engram canister',
638
+ parameters: Type.Object({}),
639
+ async execute() {
640
+ const files = await engram.listMemoryFiles();
641
+ if (files.length === 0) {
642
+ return {
643
+ content: [{ type: 'text', text: 'No memory files in engram.' }],
644
+ details: { count: 0 },
645
+ };
646
+ }
647
+ const text = files
648
+ .map((f) => `${f.path} (v${f.version}, modified ${new Date(Number(f.lastModifiedAt) / NS_PER_MS).toISOString()})`)
649
+ .join('\n');
650
+ return {
651
+ content: [{ type: 'text', text }],
652
+ details: { count: files.length },
653
+ };
654
+ },
655
+ }, { name: 'engram_list_memory' });
656
+ api.registerTool({
657
+ name: 'engram_wallet_balance',
658
+ label: 'Engram Wallet Balance',
659
+ description: 'Check the engram wallet balance',
660
+ parameters: Type.Object({}),
661
+ async execute() {
662
+ const balance = await engram.walletBalance();
663
+ return {
664
+ content: [
665
+ {
666
+ type: 'text',
667
+ text: `Balance: ${Number(balance) / E8S_PER_ICP} ICP`,
668
+ },
669
+ ],
670
+ details: { balanceE8s: Number(balance) },
671
+ };
672
+ },
673
+ }, { name: 'engram_wallet_balance' });
674
+ // NOTE: no `engram_transfer` tool by design. Operators are append-only with
675
+ // zero owner-level fund control; the only money-moving canister method is the
676
+ // owner-only `ownerWithdraw`, never exposed to operators. (Removed: it called a
677
+ // non-existent `operatorTransfer` and advertised a fund-transfer capability
678
+ // that violates the operator invariant.)
679
+ // ========================================================================
680
+ // 6. CLI Commands
681
+ // ========================================================================
682
+ api.registerCli(({ program }) => {
683
+ const cmd = program
684
+ .command('engram')
685
+ .description('EngramX sync, backup, and canister commands');
686
+ cmd
687
+ .command('status')
688
+ .description('Show engram canister status')
689
+ .action(async () => {
690
+ const status = await engram.status();
691
+ console.log(`Canister: ${cfg.canisterId}`);
692
+ console.log(`Host: ${cfg.host ?? 'https://ic0.app'}`);
693
+ console.log(`Memory files: ${status.memoryFileCount}`);
694
+ console.log(`Operators: ${status.operatorCount}`);
695
+ console.log(`Audit log: ${status.auditLogSize} entries`);
696
+ console.log(`Cycles: ${status.cyclesBalance}`);
697
+ console.log(`Backups: ${status.backupCount}`);
698
+ console.log(`Version: ${status.version}`);
699
+ console.log(`Writes paused: ${status.writesPaused}`);
700
+ console.log(`Payments frozen: ${status.paymentsFrozen}`);
701
+ });
702
+ cmd
703
+ .command('sync')
704
+ .description('Sync memory files between engram canister and local disk')
705
+ .action(async () => {
706
+ console.log('Syncing memory files...');
707
+ const result = await syncMemoryFiles();
708
+ const { pullResult, pushed } = result;
709
+ console.log(`Done. Pulled ${pullResult.pulled.length}, deleted ${pullResult.deleted.length}, pushed ${pushed}.`);
710
+ if (pullResult.pulled.length > 0)
711
+ console.log(` Pulled: ${pullResult.pulled.join(', ')}`);
712
+ if (pullResult.deleted.length > 0)
713
+ console.log(` Deleted: ${pullResult.deleted.join(', ')}`);
714
+ });
715
+ cmd
716
+ .command('backup')
717
+ .description('Backup detected databases to engram canister')
718
+ .action(async () => {
719
+ const stateDir = process.env['OPENCLAW_STATE_DIR'] ||
720
+ path.join(process.env['HOME'] || '', '.openclaw');
721
+ const databases = detectDatabases(stateDir);
722
+ console.log(`Detected databases (${stateDir}):`);
723
+ if (databases.length === 0) {
724
+ console.log(' (none)');
725
+ return;
726
+ }
727
+ for (const db of databases) {
728
+ const files = db.isDirectory
729
+ ? `directory: ${db.filePaths[0]}`
730
+ : `${db.filePaths.length} file(s)`;
731
+ console.log(` ${db.label} [${db.dbType}] — ${files}`);
732
+ }
733
+ console.log('');
734
+ const result = await backupAllDatabases();
735
+ console.log('');
736
+ console.log('Backup complete:');
737
+ console.log(` Backed up: ${result.backed.length > 0 ? result.backed.join(', ') : '(none)'}`);
738
+ console.log(` Skipped: ${result.skipped.length > 0 ? result.skipped.join(', ') : '(none)'}`);
739
+ });
740
+ cmd
741
+ .command('restore')
742
+ .description('List available backups for restore')
743
+ .action(async () => {
744
+ const allBackups = await engram.listBackups();
745
+ if (allBackups.length === 0) {
746
+ console.log('No backups found.');
747
+ return;
748
+ }
749
+ // Group by dbType
750
+ const grouped = new Map();
751
+ for (const backup of allBackups) {
752
+ const list = grouped.get(backup.dbType) ?? [];
753
+ list.push(backup);
754
+ grouped.set(backup.dbType, list);
755
+ }
756
+ for (const [dbType, backups] of grouped) {
757
+ console.log(`\n${dbType}:`);
758
+ const sorted = backups.sort((a, b) => Number(b.createdAt) - Number(a.createdAt));
759
+ for (const b of sorted) {
760
+ const status = 'Finalized' in b.status ? 'ok' : 'Uploading' in b.status ? 'uploading' : 'failed';
761
+ const sizeMb = (Number(b.totalSize) / 1024 / 1024).toFixed(1);
762
+ const created = new Date(Number(b.createdAt) / NS_PER_MS).toISOString();
763
+ console.log(` ${b.backupId} ${status} ${sizeMb} MiB ${b.chunkCount} chunks ${created} ${b.backupLabel ?? ''}`);
764
+ }
765
+ }
766
+ console.log('\nTo restore, download chunks and write to disk. See README for details.');
767
+ });
768
+ cmd
769
+ .command('git-status')
770
+ .description('Show git status and recent history for the memory directory')
771
+ .action(() => {
772
+ if (!cfg.git.enabled || !gitIsRepo()) {
773
+ console.log('Git versioning is not enabled.');
774
+ console.log('Run: npx @engramx/client git-setup <remote-url>');
775
+ return;
776
+ }
777
+ const status = gitExec(['status', '--short']);
778
+ if (status) {
779
+ console.log('Changes:');
780
+ console.log(status);
781
+ }
782
+ else {
783
+ console.log('Working tree clean.');
784
+ }
785
+ console.log('\nRecent commits:');
786
+ const log = gitExec(['log', '--oneline', '-5']);
787
+ console.log(log || '(no commits)');
788
+ });
789
+ cmd
790
+ .command('files')
791
+ .description('List memory files in the engram canister')
792
+ .action(async () => {
793
+ const files = await engram.listMemoryFiles();
794
+ if (files.length === 0) {
795
+ console.log('No memory files.');
796
+ return;
797
+ }
798
+ for (const f of files) {
799
+ const modified = new Date(Number(f.lastModifiedAt) / NS_PER_MS).toISOString();
800
+ console.log(`${f.path} v${f.version} ${modified}`);
801
+ }
802
+ });
803
+ }, { commands: ['engram'] });
804
+ // ========================================================================
805
+ // 7. Lifecycle Hooks
806
+ // ========================================================================
807
+ // Session sync: serialize transcript to JSONL and store as a file
808
+ // in the canister under sessions/{agentId}/{key}.jsonl
809
+ api.on('agent_end', async (event) => {
810
+ if (!event.success || !event.messages || event.messages.length === 0) {
811
+ return;
812
+ }
813
+ try {
814
+ const sessionKey = event.sessionKey;
815
+ const agentId = event.agentId;
816
+ if (typeof sessionKey !== 'string')
817
+ return;
818
+ const agentSlug = typeof agentId === 'string' ? agentId : 'default';
819
+ const lines = [];
820
+ for (const msg of event.messages) {
821
+ if (!msg || typeof msg !== 'object')
822
+ continue;
823
+ const m = msg;
824
+ const role = m.role;
825
+ const content = typeof m.content === 'string'
826
+ ? m.content
827
+ : Array.isArray(m.content)
828
+ ? m.content
829
+ .filter((b) => b.type === 'text')
830
+ .map((b) => b.text)
831
+ .join('\n')
832
+ : '';
833
+ if (!content)
834
+ continue;
835
+ lines.push(JSON.stringify({
836
+ role,
837
+ content,
838
+ ts: new Date().toISOString(),
839
+ }));
840
+ }
841
+ if (lines.length > 0) {
842
+ const jsonlChunk = lines.join('\n') + '\n';
843
+ const monthKey = new Date().toISOString().slice(0, 7); // "YYYY-MM"
844
+ const sessionPath = `sessions/${agentSlug}/${monthKey}.jsonl`;
845
+ await engram.appendMemory(sessionPath, jsonlChunk);
846
+ api.logger.info(`memory-engramx: synced ${lines.length} messages to ${sessionPath}`);
847
+ }
848
+ }
849
+ catch (err) {
850
+ api.logger.warn(`memory-engramx: session sync failed: ${String(err)}`);
851
+ }
852
+ });
853
+ // ========================================================================
854
+ // 8. Service (startup sync + periodic sync/backup)
855
+ // ========================================================================
856
+ let syncInterval = null;
857
+ let backupInterval = null;
858
+ api.registerService({
859
+ id: 'memory-engramx',
860
+ async start() {
861
+ api.logger.info(`memory-engramx: starting (canister: ${cfg.canisterId})`);
862
+ // Sync memory files on startup
863
+ let startupSyncResult = null;
864
+ if (cfg.sync.onStartup) {
865
+ try {
866
+ startupSyncResult = await syncMemoryFiles();
867
+ const { pullResult, pushed } = startupSyncResult;
868
+ api.logger.info(`memory-engramx: startup sync complete (pulled ${pullResult.pulled.length}, deleted ${pullResult.deleted.length}, pushed ${pushed})`);
869
+ }
870
+ catch (err) {
871
+ api.logger.warn(`memory-engramx: startup sync failed: ${String(err)}`);
872
+ }
873
+ }
874
+ // Initialize git versioning
875
+ if (cfg.git.enabled) {
876
+ if (!gitInit()) {
877
+ api.logger.warn('memory-engramx: failed to initialize git repo in memory directory');
878
+ }
879
+ else {
880
+ api.logger.info(`memory-engramx: git versioning active (${memoryDir})`);
881
+ // Commit any files that exist after startup sync
882
+ const msg = startupSyncResult?.commitMessage ?? 'engramx: startup sync';
883
+ if (gitCommit(msg)) {
884
+ api.logger.info('memory-engramx: git committed post-startup sync');
885
+ if (cfg.git.pushOnSync)
886
+ gitPush();
887
+ }
888
+ }
889
+ }
890
+ // Periodic memory file sync
891
+ let syncInProgress = false;
892
+ if (cfg.sync.intervalMinutes > 0) {
893
+ syncInterval = setInterval(() => {
894
+ if (syncInProgress) {
895
+ api.logger.warn('memory-engramx: previous sync still in progress, skipping');
896
+ return;
897
+ }
898
+ syncInProgress = true;
899
+ syncMemoryFiles()
900
+ .then((result) => {
901
+ if (cfg.git.enabled && result.commitMessage) {
902
+ if (gitCommit(result.commitMessage)) {
903
+ api.logger.info('memory-engramx: git committed sync changes');
904
+ if (cfg.git.pushOnSync)
905
+ gitPush();
906
+ }
907
+ }
908
+ })
909
+ .catch((err) => api.logger.warn(`memory-engramx: periodic sync failed: ${String(err)}`))
910
+ .finally(() => {
911
+ syncInProgress = false;
912
+ });
913
+ }, cfg.sync.intervalMinutes * 60 * 1000);
914
+ }
915
+ // Periodic database backup (all detected types)
916
+ if (cfg.backup.enabled && cfg.backup.intervalMinutes > 0) {
917
+ backupInterval = setInterval(() => {
918
+ backupAllDatabases().catch((err) => api.logger.warn(`memory-engramx: periodic backup failed: ${String(err)}`));
919
+ }, cfg.backup.intervalMinutes * 60 * 1000);
920
+ }
921
+ },
922
+ stop() {
923
+ if (syncInterval) {
924
+ clearInterval(syncInterval);
925
+ syncInterval = null;
926
+ }
927
+ if (backupInterval) {
928
+ clearInterval(backupInterval);
929
+ backupInterval = null;
930
+ }
931
+ // Final git commit + push on shutdown
932
+ if (cfg.git.enabled) {
933
+ if (gitCommit('engramx: shutdown snapshot')) {
934
+ api.logger.info('memory-engramx: git committed on shutdown');
935
+ }
936
+ if (cfg.git.pushOnStop && gitHasRemote()) {
937
+ if (gitPush()) {
938
+ api.logger.info('memory-engramx: git pushed to remote');
939
+ }
940
+ else {
941
+ api.logger.warn('memory-engramx: git push failed on shutdown');
942
+ }
943
+ }
944
+ }
945
+ api.logger.info('memory-engramx: stopped');
946
+ },
947
+ });
948
+ },
949
+ };
950
+ export default memoryEngramXPlugin;
951
+ //# sourceMappingURL=index.js.map