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.
@@ -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 RUNTIME_ENTRYPOINTS = [
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
- message: 'mem9 plugin installed. Gateway is restarting. Send mem9Install again to complete setup.',
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 → create key, write config, restart
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 apiKey = await createMem9Key();
52
- const updated = await writeMem9Config(context.openclawRoot, apiKey);
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: true,
61
- reusedExistingKey: false,
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 execFileAsync(bin, ['--version']);
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 execFileAsync('node', ['--version']);
157
- await execFileAsync('npm', ['--version']);
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 execFileAsync(bin, ['plugins', 'install', MEM9_PLUGIN_SPEC], { cwd });
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
- for (const entrypoint of RUNTIME_ENTRYPOINTS.map((item) => path.join(packageRoot, item))) {
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
- throw new JsonRpcException(JSON_RPC_ERRORS.internalError, 'mem9 plugin is installed but missing compiled runtime output', {
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
- expected: RUNTIME_ENTRYPOINTS.map((item) => `./${item.replace(/\\/g, '/')}`),
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: checkedPackageRoots.flatMap((packageRoot) => RUNTIME_ENTRYPOINTS.map((item) => path.join(packageRoot, item))),
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
- roots.push(path.isAbsolute(installPath) ? installPath : path.resolve(openclawRoot, installPath));
335
+ pushRoot(path.isAbsolute(installPath) ? installPath : path.resolve(openclawRoot, installPath));
206
336
  }
207
- for (const fallbackRoot of MEM9_PACKAGE_ROOTS.map((item) => path.join(openclawRoot, item))) {
208
- if (!roots.includes(fallbackRoot)) {
209
- roots.push(fallbackRoot);
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 writeMem9Config(openclawRoot, apiKey) {
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 = `${bin} gateway restart`;
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
+ }