@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/LICENSE +190 -0
- package/README.md +277 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +100 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +951 -0
- package/dist/index.js.map +1 -0
- package/openclaw.plugin.json +135 -0
- package/package.json +47 -0
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
|