rol-websocket-channel 1.6.7 → 1.7.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.
- package/dist/index.js +127 -44
- package/dist/message-handler.js +2 -7
- package/dist/src/admin/lib/openclaw-bin.js +19 -1
- package/dist/src/admin/lib/openclaw-bin.test.js +37 -0
- package/dist/src/admin/methods/index.js +2 -2
- package/dist/src/admin/methods/mem9.js +265 -28
- package/dist/src/admin/methods/mem9.test.js +226 -1
- package/dist/src/admin/methods/system.js +18 -21
- package/index.ts +140 -45
- package/message-handler.ts +2 -7
- package/package.json +2 -2
- package/src/admin/lib/openclaw-bin.test.ts +38 -0
- package/src/admin/lib/openclaw-bin.ts +20 -1
- package/src/admin/methods/index.ts +2 -2
- package/src/admin/methods/mem9.test.ts +280 -1
- package/src/admin/methods/mem9.ts +314 -29
- package/src/admin/methods/system.ts +23 -27
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { exec, execFile } from 'node:child_process';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
|
+
import fs from 'node:fs';
|
|
4
5
|
import { pathExists, readJsonFile, writeJsonFile } from '../lib/fs.js';
|
|
5
6
|
import { resolveOpenClawBin } from '../lib/openclaw-bin.js';
|
|
6
7
|
import { JsonRpcException, JSON_RPC_ERRORS } from '../jsonrpc.js';
|
|
@@ -15,13 +16,26 @@ const MEM9_PACKAGE_ROOTS = [
|
|
|
15
16
|
path.join('npm', 'node_modules', '@mem9', 'mem9'),
|
|
16
17
|
path.join('npm', 'node_modules', 'mem9')
|
|
17
18
|
];
|
|
18
|
-
const
|
|
19
|
+
const RUNTIME_FILE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']);
|
|
20
|
+
const FALLBACK_ENTRYPOINTS = [
|
|
19
21
|
path.join('dist', 'index.js'),
|
|
20
22
|
path.join('dist', 'index.mjs'),
|
|
21
23
|
path.join('dist', 'index.cjs'),
|
|
22
24
|
'index.js',
|
|
23
25
|
'index.mjs',
|
|
24
|
-
'index.cjs'
|
|
26
|
+
'index.cjs',
|
|
27
|
+
path.join('lib', 'index.js'),
|
|
28
|
+
path.join('lib', 'index.mjs'),
|
|
29
|
+
path.join('lib', 'index.cjs'),
|
|
30
|
+
path.join('build', 'index.js'),
|
|
31
|
+
path.join('build', 'index.mjs'),
|
|
32
|
+
path.join('build', 'index.cjs'),
|
|
33
|
+
path.join('out', 'index.js'),
|
|
34
|
+
path.join('out', 'index.mjs'),
|
|
35
|
+
path.join('out', 'index.cjs'),
|
|
36
|
+
path.join('dist', 'main.js'),
|
|
37
|
+
path.join('dist', 'main.mjs'),
|
|
38
|
+
path.join('dist', 'main.cjs')
|
|
25
39
|
];
|
|
26
40
|
// ---------------------------------------------------------------------------
|
|
27
41
|
// Public API: installMem9 (idempotent, phase-based)
|
|
@@ -30,26 +44,48 @@ export async function installMem9(context) {
|
|
|
30
44
|
const config = await ensureOpenClawConfigExists(context.openclawRoot);
|
|
31
45
|
const currentState = readMem9State(config);
|
|
32
46
|
const currentEntrypoint = await findMem9RuntimeEntrypoint(context.openclawRoot, config);
|
|
33
|
-
// Phase A: Plugin not installed → install only, then restart
|
|
47
|
+
// Phase A: Plugin not installed → install only, then write backend key if available, and restart
|
|
34
48
|
if (!currentState.installed && !currentEntrypoint) {
|
|
35
49
|
await ensureOpenClawCli();
|
|
36
50
|
await ensureNodeRuntime();
|
|
37
51
|
await installMem9Plugin(context.projectRoot);
|
|
52
|
+
const backendConfig = await fetchMem9KeyFromBackend(context.openclawRoot);
|
|
53
|
+
let updatedConfigs = [];
|
|
54
|
+
if (backendConfig) {
|
|
55
|
+
updatedConfigs = await writeMem9Config(context.openclawRoot, backendConfig.apiKey, backendConfig.apiUrl);
|
|
56
|
+
}
|
|
38
57
|
const restart = await restartGateway(context.projectRoot);
|
|
39
58
|
return {
|
|
40
59
|
ok: true,
|
|
41
|
-
phase: 'installed',
|
|
60
|
+
phase: backendConfig ? 'configured' : 'installed',
|
|
42
61
|
needsRestart: true,
|
|
43
62
|
plugin: MEM9_PLUGIN_ID,
|
|
44
|
-
|
|
63
|
+
apiKey: backendConfig?.apiKey ?? null,
|
|
64
|
+
apiUrl: backendConfig?.apiUrl ?? MEM9_API_URL,
|
|
65
|
+
updated: updatedConfigs.length > 0 ? updatedConfigs : null,
|
|
66
|
+
message: backendConfig
|
|
67
|
+
? 'mem9 plugin installed and configured with backend key. Gateway is restarting.'
|
|
68
|
+
: 'mem9 plugin installed. Gateway is restarting. Send mem9Install again to complete setup.',
|
|
45
69
|
restart
|
|
46
70
|
};
|
|
47
71
|
}
|
|
48
|
-
// Phase B: Installed but no key →
|
|
72
|
+
// Phase B: Installed but no key → fetch key from backend, fallback to creating a key, write config, restart
|
|
49
73
|
if (!currentState.configured || !currentState.apiKey) {
|
|
50
74
|
const runtimeEntrypoint = await ensureMem9RuntimeEntrypoint(context.openclawRoot, config);
|
|
51
|
-
const
|
|
52
|
-
|
|
75
|
+
const backendConfig = await fetchMem9KeyFromBackend(context.openclawRoot);
|
|
76
|
+
let apiKey = backendConfig?.apiKey ?? null;
|
|
77
|
+
let apiUrl = backendConfig?.apiUrl ?? null;
|
|
78
|
+
let createdNewKey = false;
|
|
79
|
+
let reusedExistingKey = false;
|
|
80
|
+
if (!apiKey) {
|
|
81
|
+
apiKey = await createMem9Key();
|
|
82
|
+
apiUrl = MEM9_API_URL;
|
|
83
|
+
createdNewKey = true;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
reusedExistingKey = true;
|
|
87
|
+
}
|
|
88
|
+
const updated = await writeMem9Config(context.openclawRoot, apiKey, apiUrl);
|
|
53
89
|
const restart = await restartGateway(context.projectRoot);
|
|
54
90
|
return {
|
|
55
91
|
ok: true,
|
|
@@ -57,11 +93,11 @@ export async function installMem9(context) {
|
|
|
57
93
|
installed: true,
|
|
58
94
|
alreadyInstalled: true,
|
|
59
95
|
alreadyConfigured: false,
|
|
60
|
-
createdNewKey
|
|
61
|
-
reusedExistingKey
|
|
96
|
+
createdNewKey,
|
|
97
|
+
reusedExistingKey,
|
|
62
98
|
plugin: MEM9_PLUGIN_ID,
|
|
63
99
|
runtimeEntrypoint,
|
|
64
|
-
apiUrl: MEM9_API_URL,
|
|
100
|
+
apiUrl: apiUrl || MEM9_API_URL,
|
|
65
101
|
apiKey,
|
|
66
102
|
updated,
|
|
67
103
|
restart
|
|
@@ -81,7 +117,7 @@ export async function installMem9(context) {
|
|
|
81
117
|
reusedExistingKey: true,
|
|
82
118
|
plugin: MEM9_PLUGIN_ID,
|
|
83
119
|
runtimeEntrypoint,
|
|
84
|
-
apiUrl: MEM9_API_URL,
|
|
120
|
+
apiUrl: pickString(isRecord(config.plugins?.entries?.[MEM9_PLUGIN_ID]?.config) ? config.plugins.entries[MEM9_PLUGIN_ID].config.apiUrl : null) ?? MEM9_API_URL,
|
|
85
121
|
apiKey: currentState.apiKey,
|
|
86
122
|
updated,
|
|
87
123
|
restart
|
|
@@ -145,7 +181,7 @@ async function ensureOpenClawConfigExists(openclawRoot) {
|
|
|
145
181
|
async function ensureOpenClawCli() {
|
|
146
182
|
const bin = resolveOpenClawBin();
|
|
147
183
|
try {
|
|
148
|
-
await
|
|
184
|
+
await execAsync(`"${bin}" --version`);
|
|
149
185
|
}
|
|
150
186
|
catch (error) {
|
|
151
187
|
throw new JsonRpcException(JSON_RPC_ERRORS.internalError, `openclaw command is not available (tried: ${bin}). Configure the OpenClaw binary path for the Gateway service.`, { code: 'MEM9_OPENCLAW_NOT_FOUND', bin, detail: error instanceof Error ? error.message : String(error) });
|
|
@@ -153,8 +189,8 @@ async function ensureOpenClawCli() {
|
|
|
153
189
|
}
|
|
154
190
|
async function ensureNodeRuntime() {
|
|
155
191
|
try {
|
|
156
|
-
await
|
|
157
|
-
await
|
|
192
|
+
await execAsync('node --version');
|
|
193
|
+
await execAsync('npm --version');
|
|
158
194
|
}
|
|
159
195
|
catch (error) {
|
|
160
196
|
throw new JsonRpcException(JSON_RPC_ERRORS.internalError, 'node or npm command is not available', { code: 'MEM9_NODE_NOT_FOUND', detail: error instanceof Error ? error.message : String(error) });
|
|
@@ -163,7 +199,7 @@ async function ensureNodeRuntime() {
|
|
|
163
199
|
async function installMem9Plugin(cwd) {
|
|
164
200
|
const bin = resolveOpenClawBin();
|
|
165
201
|
try {
|
|
166
|
-
await
|
|
202
|
+
await execAsync(`"${bin}" plugins install ${MEM9_PLUGIN_SPEC} --force`, { cwd });
|
|
167
203
|
}
|
|
168
204
|
catch (error) {
|
|
169
205
|
throw new JsonRpcException(JSON_RPC_ERRORS.internalError, `${bin} plugins install ${MEM9_PLUGIN_SPEC} failed`, {
|
|
@@ -175,7 +211,8 @@ async function installMem9Plugin(cwd) {
|
|
|
175
211
|
}
|
|
176
212
|
export async function findMem9RuntimeEntrypoint(openclawRoot, config) {
|
|
177
213
|
for (const packageRoot of resolveMem9RuntimePackageRoots(openclawRoot, config)) {
|
|
178
|
-
|
|
214
|
+
const manifest = await readPluginManifest(packageRoot);
|
|
215
|
+
for (const entrypoint of collectEntrypointCandidates(packageRoot, manifest)) {
|
|
179
216
|
if (await pathExists(entrypoint)) {
|
|
180
217
|
return entrypoint;
|
|
181
218
|
}
|
|
@@ -190,25 +227,134 @@ async function ensureMem9RuntimeEntrypoint(openclawRoot, config) {
|
|
|
190
227
|
}
|
|
191
228
|
const installRecord = readMem9InstallRecord(config);
|
|
192
229
|
const checkedPackageRoots = resolveMem9RuntimePackageRoots(openclawRoot, config);
|
|
193
|
-
|
|
230
|
+
const checkedEntrypoints = [];
|
|
231
|
+
for (const packageRoot of checkedPackageRoots) {
|
|
232
|
+
const manifest = await readPluginManifest(packageRoot);
|
|
233
|
+
for (const candidate of collectEntrypointCandidates(packageRoot, manifest)) {
|
|
234
|
+
if (!checkedEntrypoints.includes(candidate)) {
|
|
235
|
+
checkedEntrypoints.push(candidate);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
throw new JsonRpcException(JSON_RPC_ERRORS.internalError, 'mem9 runtime not found; config exists but compiled plugin output is missing', {
|
|
194
240
|
code: 'MEM9_RUNTIME_OUTPUT_MISSING',
|
|
195
|
-
|
|
241
|
+
diagnosis: 'CONFIG_PRESENT_RUNTIME_MISSING',
|
|
242
|
+
summary: 'Mem9 is configured in openclaw.json, but no compiled JavaScript runtime was found under OpenClaw install paths.',
|
|
243
|
+
expected: FALLBACK_ENTRYPOINTS.map((item) => `./${item.replace(/\\/g, '/')}`),
|
|
196
244
|
checkedPackageRoots,
|
|
197
|
-
checkedEntrypoints
|
|
198
|
-
installRecord
|
|
245
|
+
checkedEntrypoints,
|
|
246
|
+
installRecord,
|
|
247
|
+
installState: describeMem9InstallState(openclawRoot, config),
|
|
248
|
+
suggestions: [
|
|
249
|
+
'Ensure OpenClaw has extensions/mem9 pointing to the installed @mem9/mem9 package.',
|
|
250
|
+
'If extensions/mem9 is missing or broken, run: openclaw plugins install @mem9/mem9 --force',
|
|
251
|
+
'If plugins.entries.mem9 exists but no runtime path exists, treat it as stale config residue rather than a complete install.'
|
|
252
|
+
]
|
|
199
253
|
});
|
|
200
254
|
}
|
|
255
|
+
async function readPluginManifest(packageRoot) {
|
|
256
|
+
const manifestPath = path.join(packageRoot, 'package.json');
|
|
257
|
+
if (!(await pathExists(manifestPath)))
|
|
258
|
+
return null;
|
|
259
|
+
try {
|
|
260
|
+
return await readJsonFile(manifestPath);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function isRuntimeFile(relPath) {
|
|
267
|
+
return RUNTIME_FILE_EXTENSIONS.has(path.extname(relPath).toLowerCase());
|
|
268
|
+
}
|
|
269
|
+
function normalizeRelative(value) {
|
|
270
|
+
return value.replace(/^\.[\\/]+/, '').split(/[\\/]+/).join(path.sep);
|
|
271
|
+
}
|
|
272
|
+
function collectExportsEntries(value) {
|
|
273
|
+
const out = [];
|
|
274
|
+
const visit = (node, contextKey) => {
|
|
275
|
+
if (typeof node === 'string') {
|
|
276
|
+
out.push(node);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (Array.isArray(node)) {
|
|
280
|
+
for (const child of node)
|
|
281
|
+
visit(child, contextKey);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (node && typeof node === 'object') {
|
|
285
|
+
for (const [key, child] of Object.entries(node)) {
|
|
286
|
+
if (key.startsWith('.') && key !== '.')
|
|
287
|
+
continue;
|
|
288
|
+
visit(child, key);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
visit(value, null);
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
function collectEntrypointCandidates(packageRoot, manifest) {
|
|
296
|
+
const seen = new Set();
|
|
297
|
+
const out = [];
|
|
298
|
+
const push = (relCandidate) => {
|
|
299
|
+
if (typeof relCandidate !== 'string')
|
|
300
|
+
return;
|
|
301
|
+
const normalized = normalizeRelative(relCandidate);
|
|
302
|
+
if (!normalized)
|
|
303
|
+
return;
|
|
304
|
+
if (!isRuntimeFile(normalized))
|
|
305
|
+
return;
|
|
306
|
+
const absolute = path.isAbsolute(normalized) ? normalized : path.join(packageRoot, normalized);
|
|
307
|
+
if (seen.has(absolute))
|
|
308
|
+
return;
|
|
309
|
+
seen.add(absolute);
|
|
310
|
+
out.push(absolute);
|
|
311
|
+
};
|
|
312
|
+
const declaredRuntime = manifest?.openclaw?.runtimeExtensions;
|
|
313
|
+
if (Array.isArray(declaredRuntime)) {
|
|
314
|
+
for (const item of declaredRuntime)
|
|
315
|
+
push(item);
|
|
316
|
+
}
|
|
317
|
+
if (manifest?.exports !== undefined) {
|
|
318
|
+
for (const item of collectExportsEntries(manifest.exports))
|
|
319
|
+
push(item);
|
|
320
|
+
}
|
|
321
|
+
push(manifest?.module);
|
|
322
|
+
push(manifest?.main);
|
|
323
|
+
for (const item of FALLBACK_ENTRYPOINTS)
|
|
324
|
+
push(item);
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
201
327
|
function resolveMem9RuntimePackageRoots(openclawRoot, config) {
|
|
202
328
|
const roots = [];
|
|
329
|
+
const pushRoot = (root) => {
|
|
330
|
+
if (!roots.includes(root))
|
|
331
|
+
roots.push(root);
|
|
332
|
+
};
|
|
203
333
|
const installPath = pickString(readMem9InstallRecord(config)?.installPath);
|
|
204
334
|
if (installPath) {
|
|
205
|
-
|
|
335
|
+
pushRoot(path.isAbsolute(installPath) ? installPath : path.resolve(openclawRoot, installPath));
|
|
206
336
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
337
|
+
pushRoot(path.join(openclawRoot, 'extensions', MEM9_PLUGIN_ID));
|
|
338
|
+
const projectsDir = path.join(openclawRoot, 'npm', 'projects');
|
|
339
|
+
try {
|
|
340
|
+
if (fs.existsSync(projectsDir)) {
|
|
341
|
+
const projects = fs.readdirSync(projectsDir);
|
|
342
|
+
for (const project of projects) {
|
|
343
|
+
const candidate1 = path.join(projectsDir, project, 'node_modules', '@mem9', 'mem9');
|
|
344
|
+
const candidate2 = path.join(projectsDir, project, 'node_modules', 'mem9');
|
|
345
|
+
if (fs.existsSync(candidate1))
|
|
346
|
+
pushRoot(candidate1);
|
|
347
|
+
if (fs.existsSync(candidate2))
|
|
348
|
+
pushRoot(candidate2);
|
|
349
|
+
}
|
|
210
350
|
}
|
|
211
351
|
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
console.warn('[Mem9] Failed to scan npm/projects:', err);
|
|
354
|
+
}
|
|
355
|
+
for (const fallbackRoot of MEM9_PACKAGE_ROOTS.map((item) => path.join(openclawRoot, item))) {
|
|
356
|
+
pushRoot(fallbackRoot);
|
|
357
|
+
}
|
|
212
358
|
return roots;
|
|
213
359
|
}
|
|
214
360
|
async function createMem9Key() {
|
|
@@ -238,7 +384,79 @@ async function createMem9Key() {
|
|
|
238
384
|
// ---------------------------------------------------------------------------
|
|
239
385
|
// Config writers
|
|
240
386
|
// ---------------------------------------------------------------------------
|
|
241
|
-
async function
|
|
387
|
+
async function resolveApiCoreBotEndpoint(openclawRoot, overrideBaseUrl, overrideAuthToken) {
|
|
388
|
+
const config = await readJsonFile(path.join(openclawRoot, 'openclaw.json'));
|
|
389
|
+
const pluginConfig = config.plugins?.entries?.['rol-websocket-channel']?.config?.apiCoreBot ?? {};
|
|
390
|
+
const baseUrl = (overrideBaseUrl && overrideBaseUrl.trim()) || pluginConfig.baseUrl;
|
|
391
|
+
if (!baseUrl || !baseUrl.trim()) {
|
|
392
|
+
throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'apiCoreBot.baseUrl is not configured');
|
|
393
|
+
}
|
|
394
|
+
const authToken = (overrideAuthToken && overrideAuthToken.trim()) || pluginConfig.authToken;
|
|
395
|
+
return {
|
|
396
|
+
baseUrl: baseUrl.replace(/\/+$/, ''),
|
|
397
|
+
authToken
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
async function postJson(url, body, authToken) {
|
|
401
|
+
const headers = {
|
|
402
|
+
'Content-Type': 'application/json'
|
|
403
|
+
};
|
|
404
|
+
if (authToken && authToken.trim()) {
|
|
405
|
+
headers.Authorization = `Bearer ${authToken.trim()}`;
|
|
406
|
+
}
|
|
407
|
+
const response = await fetch(url, {
|
|
408
|
+
method: 'POST',
|
|
409
|
+
headers,
|
|
410
|
+
body: JSON.stringify(body)
|
|
411
|
+
});
|
|
412
|
+
const payload = await response.json().catch(async () => await response.text());
|
|
413
|
+
if (!response.ok) {
|
|
414
|
+
throw new JsonRpcException(JSON_RPC_ERRORS.internalError, `Request failed: ${response.status}`, {
|
|
415
|
+
url,
|
|
416
|
+
status: response.status,
|
|
417
|
+
payload
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return payload;
|
|
421
|
+
}
|
|
422
|
+
async function fetchMem9KeyFromBackend(openclawRoot) {
|
|
423
|
+
try {
|
|
424
|
+
const endpointConfig = await resolveApiCoreBotEndpoint(openclawRoot);
|
|
425
|
+
if (!endpointConfig.baseUrl) {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
const response = await postJson(`${endpointConfig.baseUrl}/api-core-bot/front/mem9/key`, {}, endpointConfig.authToken);
|
|
429
|
+
const responseRecord = response;
|
|
430
|
+
const root = isRecord(responseRecord.data) ? responseRecord.data : responseRecord;
|
|
431
|
+
// Check nested structures (e.g. plugins.entries.mem9.config.apiKey)
|
|
432
|
+
let key = null;
|
|
433
|
+
let apiUrl = null;
|
|
434
|
+
if (isRecord(root.plugins?.entries?.mem9?.config)) {
|
|
435
|
+
key = pickString(root.plugins.entries.mem9.config.apiKey);
|
|
436
|
+
apiUrl = pickString(root.plugins.entries.mem9.config.apiUrl);
|
|
437
|
+
}
|
|
438
|
+
else if (isRecord(root.config)) {
|
|
439
|
+
key = pickString(root.config.apiKey);
|
|
440
|
+
apiUrl = pickString(root.config.apiUrl);
|
|
441
|
+
}
|
|
442
|
+
// Fallback to direct properties
|
|
443
|
+
if (!key) {
|
|
444
|
+
key = pickString(root.apiKey) ?? pickString(root.key) ?? pickString(root.id);
|
|
445
|
+
}
|
|
446
|
+
if (!apiUrl) {
|
|
447
|
+
apiUrl = pickString(root.apiUrl) ?? pickString(root.url);
|
|
448
|
+
}
|
|
449
|
+
if (!key) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
return { apiKey: key, apiUrl };
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
console.warn('[Mem9] Failed to fetch key from apiCoreBot backend, will fallback to Mem9 official API:', error instanceof Error ? error.message : String(error));
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
async function writeMem9Config(openclawRoot, apiKey, apiUrl) {
|
|
242
460
|
const configPath = path.join(openclawRoot, 'openclaw.json');
|
|
243
461
|
const config = await readJsonFile(configPath);
|
|
244
462
|
if (!config.plugins)
|
|
@@ -259,7 +477,7 @@ async function writeMem9Config(openclawRoot, apiKey) {
|
|
|
259
477
|
},
|
|
260
478
|
config: {
|
|
261
479
|
...existingPluginConfig,
|
|
262
|
-
apiUrl: MEM9_API_URL,
|
|
480
|
+
apiUrl: apiUrl || pickString(existingPluginConfig.apiUrl) || MEM9_API_URL,
|
|
263
481
|
apiKey
|
|
264
482
|
}
|
|
265
483
|
};
|
|
@@ -318,7 +536,7 @@ function ensurePluginsAllow(config) {
|
|
|
318
536
|
async function restartGateway(cwd) {
|
|
319
537
|
const attempts = [];
|
|
320
538
|
const bin = resolveOpenClawBin();
|
|
321
|
-
const restartCmd =
|
|
539
|
+
const restartCmd = `"${bin}" gateway restart`;
|
|
322
540
|
try {
|
|
323
541
|
const { stdout, stderr } = await execAsync(restartCmd, { cwd });
|
|
324
542
|
return {
|
|
@@ -384,6 +602,25 @@ function readMem9InstallRecord(config) {
|
|
|
384
602
|
const record = config?.plugins?.installs?.[MEM9_PLUGIN_ID];
|
|
385
603
|
return isRecord(record) ? record : null;
|
|
386
604
|
}
|
|
605
|
+
function describeMem9InstallState(openclawRoot, config) {
|
|
606
|
+
const installRecord = readMem9InstallRecord(config);
|
|
607
|
+
const entry = isRecord(config?.plugins?.entries?.[MEM9_PLUGIN_ID]) ? config?.plugins?.entries?.[MEM9_PLUGIN_ID] : {};
|
|
608
|
+
const pluginConfig = isRecord(entry.config) ? entry.config : {};
|
|
609
|
+
const allow = config?.plugins?.allow;
|
|
610
|
+
const extensionsMem9Path = path.join(openclawRoot, 'extensions', MEM9_PLUGIN_ID);
|
|
611
|
+
return {
|
|
612
|
+
hasInstallRecord: Boolean(installRecord),
|
|
613
|
+
installPath: pickString(installRecord?.installPath),
|
|
614
|
+
hasEntry: isRecord(config?.plugins?.entries?.[MEM9_PLUGIN_ID]),
|
|
615
|
+
entryEnabled: entry.enabled === true,
|
|
616
|
+
hasApiKey: Boolean(pickString(pluginConfig.apiKey)),
|
|
617
|
+
apiUrl: pickString(pluginConfig.apiUrl),
|
|
618
|
+
allowContainsMem9: Array.isArray(allow) && allow.includes(MEM9_PLUGIN_ID),
|
|
619
|
+
memorySlot: typeof config?.plugins?.slots?.memory === 'string' ? config.plugins.slots.memory : null,
|
|
620
|
+
extensionsMem9Path,
|
|
621
|
+
extensionsMem9Exists: fs.existsSync(extensionsMem9Path)
|
|
622
|
+
};
|
|
623
|
+
}
|
|
387
624
|
function readMem9State(config) {
|
|
388
625
|
const installed = Boolean((config.plugins?.installs && typeof config.plugins.installs === 'object' && MEM9_PLUGIN_ID in config.plugins.installs)
|
|
389
626
|
|| (config.plugins?.entries && typeof config.plugins.entries === 'object' && MEM9_PLUGIN_ID in config.plugins.entries));
|
|
@@ -1,10 +1,63 @@
|
|
|
1
1
|
import { describe, test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import fs from 'node:fs/promises';
|
|
4
|
+
import http from 'node:http';
|
|
4
5
|
import os from 'node:os';
|
|
5
6
|
import path from 'node:path';
|
|
6
|
-
import { findMem9RuntimeEntrypoint } from './mem9.js';
|
|
7
|
+
import { findMem9RuntimeEntrypoint, installMem9 } from './mem9.js';
|
|
7
8
|
describe('mem9 runtime compatibility', () => {
|
|
9
|
+
test('runs Windows OpenClaw command shims from paths containing spaces during install', { skip: process.platform !== 'win32' }, async () => {
|
|
10
|
+
const originalOpenClawBin = process.env.OPENCLAW_BIN;
|
|
11
|
+
const originalCallsPath = process.env.FAKE_OPENCLAW_CALLS;
|
|
12
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mem9 cli shim '));
|
|
13
|
+
const backend = await createMem9Backend();
|
|
14
|
+
try {
|
|
15
|
+
const openclawRoot = path.join(root, '.openclaw');
|
|
16
|
+
await fs.mkdir(openclawRoot, { recursive: true });
|
|
17
|
+
await fs.writeFile(path.join(openclawRoot, 'openclaw.json'), JSON.stringify({
|
|
18
|
+
plugins: {
|
|
19
|
+
entries: {
|
|
20
|
+
'rol-websocket-channel': {
|
|
21
|
+
config: {
|
|
22
|
+
apiCoreBot: {
|
|
23
|
+
baseUrl: backend.url
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}), 'utf8');
|
|
30
|
+
const callsPath = path.join(root, 'openclaw-calls.ndjson');
|
|
31
|
+
process.env.FAKE_OPENCLAW_CALLS = callsPath;
|
|
32
|
+
process.env.OPENCLAW_BIN = await createFakeOpenClawCmd(root);
|
|
33
|
+
const result = await installMem9({ projectRoot: root, openclawRoot });
|
|
34
|
+
const calls = await readCalls(callsPath);
|
|
35
|
+
assert.equal(result.ok, true);
|
|
36
|
+
assert.equal(result.phase, 'configured');
|
|
37
|
+
assert.equal(result.restart.success, true);
|
|
38
|
+
assert.deepEqual(calls, [
|
|
39
|
+
['openclaw', '--version'],
|
|
40
|
+
['openclaw', 'plugins', 'install', '@mem9/mem9', '--force'],
|
|
41
|
+
['openclaw', 'gateway', 'restart']
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
if (originalOpenClawBin === undefined) {
|
|
46
|
+
delete process.env.OPENCLAW_BIN;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
process.env.OPENCLAW_BIN = originalOpenClawBin;
|
|
50
|
+
}
|
|
51
|
+
if (originalCallsPath === undefined) {
|
|
52
|
+
delete process.env.FAKE_OPENCLAW_CALLS;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
process.env.FAKE_OPENCLAW_CALLS = originalCallsPath;
|
|
56
|
+
}
|
|
57
|
+
await backend.close();
|
|
58
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
8
61
|
test('accepts compiled runtime output under the installed package', async () => {
|
|
9
62
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mem9-runtime-'));
|
|
10
63
|
try {
|
|
@@ -18,6 +71,64 @@ describe('mem9 runtime compatibility', () => {
|
|
|
18
71
|
await fs.rm(root, { recursive: true, force: true });
|
|
19
72
|
}
|
|
20
73
|
});
|
|
74
|
+
test('resolves runtime from OpenClaw extensions/mem9', async () => {
|
|
75
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mem9-runtime-'));
|
|
76
|
+
try {
|
|
77
|
+
const packageRoot = path.join(root, '.openclaw', 'extensions', 'mem9');
|
|
78
|
+
await fs.mkdir(path.join(packageRoot, 'dist'), { recursive: true });
|
|
79
|
+
await fs.writeFile(path.join(packageRoot, 'dist', 'index.js'), 'export default {};\n', 'utf8');
|
|
80
|
+
const entrypoint = await findMem9RuntimeEntrypoint(path.join(root, '.openclaw'));
|
|
81
|
+
assert.equal(entrypoint, path.join(packageRoot, 'dist', 'index.js'));
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
test('explains missing runtime when mem9 config exists without install output', async () => {
|
|
88
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mem9-runtime-'));
|
|
89
|
+
try {
|
|
90
|
+
const openclawRoot = path.join(root, '.openclaw');
|
|
91
|
+
await fs.mkdir(openclawRoot, { recursive: true });
|
|
92
|
+
await fs.writeFile(path.join(openclawRoot, 'openclaw.json'), JSON.stringify({
|
|
93
|
+
plugins: {
|
|
94
|
+
allow: ['mem9'],
|
|
95
|
+
entries: {
|
|
96
|
+
mem9: {
|
|
97
|
+
enabled: true,
|
|
98
|
+
config: {
|
|
99
|
+
apiUrl: 'https://api.mem9.ai',
|
|
100
|
+
apiKey: 'test-key'
|
|
101
|
+
},
|
|
102
|
+
hooks: {
|
|
103
|
+
allowConversationAccess: true
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
slots: {
|
|
108
|
+
memory: 'mem9'
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}), 'utf8');
|
|
112
|
+
await assert.rejects(() => installMem9({ projectRoot: root, openclawRoot }), (error) => {
|
|
113
|
+
assert.equal(error.message, 'mem9 runtime not found; config exists but compiled plugin output is missing');
|
|
114
|
+
assert.equal(error.data?.code, 'MEM9_RUNTIME_OUTPUT_MISSING');
|
|
115
|
+
assert.equal(error.data?.diagnosis, 'CONFIG_PRESENT_RUNTIME_MISSING');
|
|
116
|
+
assert.equal(error.data?.installState?.hasInstallRecord, false);
|
|
117
|
+
assert.equal(error.data?.installState?.hasEntry, true);
|
|
118
|
+
assert.equal(error.data?.installState?.entryEnabled, true);
|
|
119
|
+
assert.equal(error.data?.installState?.hasApiKey, true);
|
|
120
|
+
assert.equal(error.data?.installState?.allowContainsMem9, true);
|
|
121
|
+
assert.equal(error.data?.installState?.memorySlot, 'mem9');
|
|
122
|
+
assert.equal(error.data?.installState?.extensionsMem9Exists, false);
|
|
123
|
+
assert.ok(error.data?.checkedPackageRoots.includes(path.join(openclawRoot, 'extensions', 'mem9')));
|
|
124
|
+
assert.ok(error.data?.suggestions.includes('Ensure OpenClaw has extensions/mem9 pointing to the installed @mem9/mem9 package.'));
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
21
132
|
test('rejects TypeScript-only mem9 packages before writing memory slot', async () => {
|
|
22
133
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mem9-runtime-'));
|
|
23
134
|
try {
|
|
@@ -31,4 +142,118 @@ describe('mem9 runtime compatibility', () => {
|
|
|
31
142
|
await fs.rm(root, { recursive: true, force: true });
|
|
32
143
|
}
|
|
33
144
|
});
|
|
145
|
+
test('honors package.json main when output lives in a non-standard directory', async () => {
|
|
146
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mem9-runtime-'));
|
|
147
|
+
try {
|
|
148
|
+
const packageRoot = path.join(root, '.openclaw', 'npm', 'node_modules', '@mem9', 'mem9');
|
|
149
|
+
await fs.mkdir(path.join(packageRoot, 'compiled'), { recursive: true });
|
|
150
|
+
await fs.writeFile(path.join(packageRoot, 'compiled', 'plugin.js'), 'export default {};\n', 'utf8');
|
|
151
|
+
await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: '@mem9/mem9', main: './compiled/plugin.js' }), 'utf8');
|
|
152
|
+
const entrypoint = await findMem9RuntimeEntrypoint(path.join(root, '.openclaw'));
|
|
153
|
+
assert.equal(entrypoint, path.join(packageRoot, 'compiled', 'plugin.js'));
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
test('resolves runtime from package.json exports conditional map', async () => {
|
|
160
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mem9-runtime-'));
|
|
161
|
+
try {
|
|
162
|
+
const packageRoot = path.join(root, '.openclaw', 'npm', 'node_modules', 'mem9');
|
|
163
|
+
await fs.mkdir(path.join(packageRoot, 'dist', 'esm'), { recursive: true });
|
|
164
|
+
await fs.writeFile(path.join(packageRoot, 'dist', 'esm', 'index.js'), 'export default {};\n', 'utf8');
|
|
165
|
+
await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({
|
|
166
|
+
name: 'mem9',
|
|
167
|
+
exports: {
|
|
168
|
+
'.': {
|
|
169
|
+
import: './dist/esm/index.js',
|
|
170
|
+
require: './dist/cjs/index.cjs'
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}), 'utf8');
|
|
174
|
+
const entrypoint = await findMem9RuntimeEntrypoint(path.join(root, '.openclaw'));
|
|
175
|
+
assert.equal(entrypoint, path.join(packageRoot, 'dist', 'esm', 'index.js'));
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
test('respects openclaw.runtimeExtensions declared by the plugin manifest', async () => {
|
|
182
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mem9-runtime-'));
|
|
183
|
+
try {
|
|
184
|
+
const packageRoot = path.join(root, '.openclaw', 'npm', 'node_modules', '@mem9', 'mem9');
|
|
185
|
+
await fs.mkdir(path.join(packageRoot, 'build'), { recursive: true });
|
|
186
|
+
await fs.writeFile(path.join(packageRoot, 'build', 'runtime.mjs'), 'export default {};\n', 'utf8');
|
|
187
|
+
await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({
|
|
188
|
+
name: '@mem9/mem9',
|
|
189
|
+
openclaw: { runtimeExtensions: ['./build/runtime.mjs'] }
|
|
190
|
+
}), 'utf8');
|
|
191
|
+
const entrypoint = await findMem9RuntimeEntrypoint(path.join(root, '.openclaw'));
|
|
192
|
+
assert.equal(entrypoint, path.join(packageRoot, 'build', 'runtime.mjs'));
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
test('falls back to lib/index.js when neither manifest nor dist exists', async () => {
|
|
199
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mem9-runtime-'));
|
|
200
|
+
try {
|
|
201
|
+
const packageRoot = path.join(root, '.openclaw', 'npm', 'node_modules', 'mem9');
|
|
202
|
+
await fs.mkdir(path.join(packageRoot, 'lib'), { recursive: true });
|
|
203
|
+
await fs.writeFile(path.join(packageRoot, 'lib', 'index.js'), 'module.exports = {};\n', 'utf8');
|
|
204
|
+
const entrypoint = await findMem9RuntimeEntrypoint(path.join(root, '.openclaw'));
|
|
205
|
+
assert.equal(entrypoint, path.join(packageRoot, 'lib', 'index.js'));
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
34
211
|
});
|
|
212
|
+
async function createMem9Backend() {
|
|
213
|
+
const server = http.createServer(async (request, response) => {
|
|
214
|
+
if (request.method !== 'POST' || request.url !== '/api-core-bot/front/mem9/key') {
|
|
215
|
+
response.writeHead(404).end();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
for await (const _chunk of request) {
|
|
219
|
+
// Drain the request body so Node can close the connection cleanly.
|
|
220
|
+
}
|
|
221
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
222
|
+
response.end(JSON.stringify({
|
|
223
|
+
data: {
|
|
224
|
+
apiKey: 'backend-mem9-key',
|
|
225
|
+
apiUrl: 'https://api.mem9.test'
|
|
226
|
+
}
|
|
227
|
+
}));
|
|
228
|
+
});
|
|
229
|
+
await new Promise((resolve) => {
|
|
230
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
231
|
+
});
|
|
232
|
+
const address = server.address();
|
|
233
|
+
assert.ok(address && typeof address === 'object');
|
|
234
|
+
return {
|
|
235
|
+
url: `http://127.0.0.1:${address.port}`,
|
|
236
|
+
close: () => new Promise((resolve, reject) => {
|
|
237
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
238
|
+
})
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
async function createFakeOpenClawCmd(root) {
|
|
242
|
+
const scriptPath = path.join(root, 'fake openclaw.cmd');
|
|
243
|
+
await fs.writeFile(scriptPath, [
|
|
244
|
+
'@echo off',
|
|
245
|
+
'node "%~dp0fake-openclaw.js" %*'
|
|
246
|
+
].join('\r\n'), 'utf8');
|
|
247
|
+
await fs.writeFile(path.join(root, 'fake-openclaw.js'), [
|
|
248
|
+
"import fs from 'node:fs';",
|
|
249
|
+
'const file = process.env.FAKE_OPENCLAW_CALLS;',
|
|
250
|
+
'if (!file) { throw new Error("FAKE_OPENCLAW_CALLS is required"); }',
|
|
251
|
+
'fs.appendFileSync(file, `${JSON.stringify(["openclaw", ...process.argv.slice(2)])}\\n`);',
|
|
252
|
+
'console.log("OpenClaw 2026.6.1 (2e08f0f)");'
|
|
253
|
+
].join('\n'), 'utf8');
|
|
254
|
+
return scriptPath;
|
|
255
|
+
}
|
|
256
|
+
async function readCalls(callsPath) {
|
|
257
|
+
const content = await fs.readFile(callsPath, 'utf8');
|
|
258
|
+
return content.trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
|
|
259
|
+
}
|