rol-websocket-channel 1.6.9 → 1.7.1

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 CHANGED
@@ -249,19 +249,27 @@ const WebSocketChannel = {
249
249
  }),
250
250
  },
251
251
  outbound: {
252
+ // MQTT 是 topic-based 路由,不依赖 to 做地址解析
253
+ // 使用 "direct" 模式:AI reply 走 deliver 回调;message 工具走 sendText
252
254
  deliveryMode: "direct",
253
- sendText: async ({ to, text }) => {
255
+ sendText: async ({ to, text, sessionKey }) => {
254
256
  const conn = ConnectionManager.getGlobalConnection();
255
257
  if (!conn || !conn.ws || !conn.ws.connected) {
256
258
  return { ok: false, error: "No MQTT connection" };
257
259
  }
260
+ // 与 deliver 回调保持一致,使用 receiver 格式发布消息
258
261
  const message = JSON.stringify({
259
- type: "message",
260
- to,
261
- content: text,
262
+ type: "receiver",
263
+ source: "ai",
264
+ data: { text },
262
265
  timestamp: Date.now(),
263
266
  });
264
- conn.ws.publish(conn.topic, message);
267
+ // 将 # 替换为 bot(outbound 不知道 source_type,默认 bot)
268
+ let targetTopic = conn.topic;
269
+ if (targetTopic.endsWith("#")) {
270
+ targetTopic = targetTopic.slice(0, -1) + "bot";
271
+ }
272
+ conn.ws.publish(targetTopic, message);
265
273
  return { ok: true };
266
274
  },
267
275
  sendMedia: async ({ to, text, mediaUrl }) => {
@@ -270,16 +278,75 @@ const WebSocketChannel = {
270
278
  return { ok: false, error: "No MQTT connection" };
271
279
  }
272
280
  const message = JSON.stringify({
273
- type: "media",
274
- to,
275
- content: text,
276
- mediaUrl,
281
+ type: "receiver",
282
+ source: "ai",
283
+ data: { text, mediaUrl },
277
284
  timestamp: Date.now(),
278
285
  });
279
- conn.ws.publish(conn.topic, message);
286
+ let targetTopic = conn.topic;
287
+ if (targetTopic.endsWith("#")) {
288
+ targetTopic = targetTopic.slice(0, -1) + "bot";
289
+ }
290
+ conn.ws.publish(targetTopic, message);
280
291
  return { ok: true };
281
292
  },
282
293
  },
294
+ // ============================================
295
+ // messaging: 告知框架如何解析 target 地址
296
+ // MQTT 是 topic-based,任意非空字符串都是合法 target
297
+ // ============================================
298
+ messaging: {
299
+ // 接受任何非空字符串作为合法 target(MQTT 不做地址校验)
300
+ normalizeTarget: (raw) => {
301
+ const trimmed = raw?.trim();
302
+ return trimmed || undefined;
303
+ },
304
+ // 告知框架:deliver 目标就是原始字符串本身
305
+ targetResolver: {
306
+ looksLikeId: (_raw) => true,
307
+ },
308
+ },
309
+ // ============================================
310
+ // actions: 处理 message 工具的 send action
311
+ // 直接发布到 MQTT,完全绕过 target 解析
312
+ // ============================================
313
+ actions: {
314
+ describeMessageTool: () => ({
315
+ actions: ["send"],
316
+ }),
317
+ supportsAction: ({ action }) => action === "send",
318
+ handleAction: async (ctx) => {
319
+ const { action, params } = ctx;
320
+ if (action !== "send") {
321
+ throw new Error(`Unsupported action: ${action}`);
322
+ }
323
+ const text = typeof params.message === "string" ? params.message
324
+ : typeof params.text === "string" ? params.text
325
+ : "";
326
+ const conn = ConnectionManager.getGlobalConnection();
327
+ if (!conn || !conn.ws || !conn.ws.connected) {
328
+ return {
329
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: "No MQTT connection" }) }],
330
+ details: {},
331
+ };
332
+ }
333
+ const replyMessage = JSON.stringify({
334
+ type: "receiver",
335
+ source: "ai",
336
+ data: { text },
337
+ timestamp: Date.now(),
338
+ });
339
+ let targetTopic = conn.topic;
340
+ if (targetTopic.endsWith("#")) {
341
+ targetTopic = targetTopic.slice(0, -1) + "bot";
342
+ }
343
+ conn.ws.publish(targetTopic, replyMessage);
344
+ return {
345
+ content: [{ type: "text", text: JSON.stringify({ ok: true }) }],
346
+ details: {},
347
+ };
348
+ },
349
+ },
283
350
  gateway: {
284
351
  startAccount: async (ctx) => {
285
352
  const { log, account, abortSignal, cfg } = ctx;
@@ -435,11 +502,13 @@ async function handleIncomingMessage(payload, account, cfg, runtime, log, mqttTo
435
502
  const resolvedAccountId = targetAgentId ?? route.accountId;
436
503
  const resolvedSessionKey = targetSessionId ?? route.sessionKey;
437
504
  // 构建消息上下文
505
+ // To 必须设置为 senderId:框架用它确定 message 工具的回复目标
506
+ // From 是发送方;To 是「这条对话的对端」(即 bot 应回复给谁)
438
507
  const ctxPayload = runtime.channel.reply.finalizeInboundContext({
439
508
  Body: normalizedMessage.text,
440
509
  BodyForAgent: normalizedMessage.text,
441
510
  From: normalizedMessage.senderId,
442
- To: undefined,
511
+ To: normalizedMessage.senderId,
443
512
  SessionKey: resolvedSessionKey,
444
513
  AccountId: resolvedAccountId,
445
514
  ChatType: "direct",
@@ -451,46 +520,60 @@ async function handleIncomingMessage(payload, account, cfg, runtime, log, mqttTo
451
520
  Timestamp: Date.now(),
452
521
  });
453
522
  // 调度回复
454
- await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
455
- ctx: ctxPayload,
456
- cfg,
457
- dispatcherOptions: {
458
- deliver: async (payload) => {
459
- const conn = ConnectionManager.getGlobalConnection();
460
- if (!conn || !conn.ws || !conn.ws.connected) {
461
- throw new Error("No MQTT connection available");
462
- }
463
- const replyMessage = {
464
- type: "receiver",
465
- trace_id: traceId,
466
- source: "ai",
467
- meta: {
468
- 'agentId': resolvedAccountId,
469
- 'sessionKey': resolvedSessionKey
470
- },
471
- data: payload,
472
- timestamp: Date.now(),
473
- };
474
- // 根据 source_type 修改 topic 末尾的 #
475
- let targetTopic = mqttTopic;
476
- const sourceType = innerData?.source_type;
477
- if (targetTopic.endsWith("#")) {
478
- const replacement = sourceType === "device" ? "device" : "bot";
479
- targetTopic = targetTopic.slice(0, -1) + replacement;
480
- }
481
- conn.ws.publish(targetTopic, JSON.stringify(replyMessage));
482
- },
483
- onError: (err) => {
484
- log?.error(`[rol-websocket-channel] Delivery error: ${err.message}`);
485
- },
523
+ // withReplyDispatcher({ dispatcher, run }) 是底层封装:
524
+ // - dispatcher 由 createReplyDispatcherWithTyping 创建(负责 deliver 和分批发送)
525
+ // - run 调用 dispatchReplyFromConfig(负责路由和 AI 回复逻辑)
526
+ // - markComplete 由框架内部在 run 完成后自动调用,不需要外部传入
527
+ const { dispatcher, markRunComplete, markDispatchIdle } = runtime.channel.reply.createReplyDispatcherWithTyping({
528
+ deliver: async (payload) => {
529
+ const conn = ConnectionManager.getGlobalConnection();
530
+ if (!conn || !conn.ws || !conn.ws.connected) {
531
+ throw new Error("No MQTT connection available");
532
+ }
533
+ const replyMessage = {
534
+ type: "receiver",
535
+ trace_id: traceId,
536
+ source: "ai",
537
+ meta: {
538
+ 'agentId': resolvedAccountId,
539
+ 'sessionKey': resolvedSessionKey
540
+ },
541
+ data: payload,
542
+ timestamp: Date.now(),
543
+ };
544
+ // 根据 source_type 修改 topic 末尾的 #
545
+ let targetTopic = mqttTopic;
546
+ const sourceType = innerData?.source_type;
547
+ if (targetTopic.endsWith("#")) {
548
+ const replacement = sourceType === "device" ? "device" : "bot";
549
+ targetTopic = targetTopic.slice(0, -1) + replacement;
550
+ }
551
+ conn.ws.publish(targetTopic, JSON.stringify(replyMessage));
552
+ },
553
+ onError: (err) => {
554
+ log?.error(`[rol-websocket-channel] Delivery error: ${err.message} ${err.stack}`);
486
555
  },
487
556
  });
557
+ try {
558
+ await runtime.channel.reply.withReplyDispatcher({
559
+ dispatcher,
560
+ run: () => runtime.channel.reply.dispatchReplyFromConfig({
561
+ ctx: ctxPayload,
562
+ cfg,
563
+ dispatcher,
564
+ }),
565
+ });
566
+ }
567
+ finally {
568
+ markRunComplete?.();
569
+ markDispatchIdle?.();
570
+ }
488
571
  }
489
572
  catch (err) {
490
573
  log?.error(`[rol-websocket-channel] Failed to process message: ${err instanceof Error ? err.message : String(err)}`);
491
574
  }
492
575
  }
493
- const immediateAckMessageTypes = new Set(["openclawUpdate", "pluginSelfUpdate"]);
576
+ const immediateAckMessageTypes = new Set(["pluginSelfUpdate"]);
494
577
  export async function handleCustomMessageType(msgType, innerData, traceId, accountId, mqttTopic) {
495
578
  const isSkillInstallFlow = msgType === "skillsInstallFromClawHub" || msgType === "skillsUpdateFromClawHub";
496
579
  const response = {
@@ -18,7 +18,7 @@ import { uninstallSkill } from './src/admin/methods/skills-extended.js';
18
18
  import { toggleSkill } from './src/admin/methods/skills-toggle.js';
19
19
  import { listMemoryFiles, getMemoryFile, backupMemory, exportMemoryZip, getMemoryPresignedPost, createMemoryBackupRecord, importMemoryZip, } from './src/admin/methods/memory.js';
20
20
  import { getMem9Config, installMem9, reconnectMem9 } from './src/admin/methods/mem9.js';
21
- import { currentVersion, doctorFix, logs, openclawUpdate, pluginSelfUpdate, restart, stop } from './src/admin/methods/system.js';
21
+ import { currentVersion, doctorFix, logs, pluginSelfUpdate, restart, stop } from './src/admin/methods/system.js';
22
22
  export class MessageHandler {
23
23
  /**
24
24
  * 示例方法:处理 ping 类型的消息
@@ -497,12 +497,7 @@ export class MessageHandler {
497
497
  return await logs(data, context);
498
498
  });
499
499
  }
500
- async openclawUpdate(data) {
501
- return wrapAdminCall(async () => {
502
- const context = getContext();
503
- return await openclawUpdate(data, context);
504
- });
505
- }
500
+ // OpenClaw core updates are managed outside this plugin.
506
501
  async pluginSelfUpdate(data) {
507
502
  return wrapAdminCall(async () => {
508
503
  const context = getContext();
@@ -1,3 +1,21 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import fs from 'node:fs';
1
4
  export function resolveOpenClawBin() {
2
- return process.env.OPENCLAW_BIN || 'openclaw';
5
+ if (process.env.OPENCLAW_BIN) {
6
+ return process.env.OPENCLAW_BIN;
7
+ }
8
+ if (process.platform === 'win32') {
9
+ const appData = process.env.APPDATA;
10
+ const candidates = [
11
+ appData ? path.join(appData, 'npm', 'openclaw.cmd') : null,
12
+ path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'openclaw.cmd')
13
+ ];
14
+ for (const candidate of candidates) {
15
+ if (candidate && fs.existsSync(candidate)) {
16
+ return candidate;
17
+ }
18
+ }
19
+ }
20
+ return 'openclaw';
3
21
  }
@@ -0,0 +1,37 @@
1
+ import { describe, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { resolveOpenClawBin } from './openclaw-bin.js';
7
+ describe('resolveOpenClawBin', () => {
8
+ test('uses APPDATA npm shim on Windows when OPENCLAW_BIN is unset', { skip: process.platform !== 'win32' }, async () => {
9
+ const originalOpenClawBin = process.env.OPENCLAW_BIN;
10
+ const originalAppData = process.env.APPDATA;
11
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'openclaw-bin-'));
12
+ try {
13
+ const appData = path.join(root, 'Roaming');
14
+ const shim = path.join(appData, 'npm', 'openclaw.cmd');
15
+ await fs.mkdir(path.dirname(shim), { recursive: true });
16
+ await fs.writeFile(shim, '@echo off\r\n', 'utf8');
17
+ delete process.env.OPENCLAW_BIN;
18
+ process.env.APPDATA = appData;
19
+ assert.equal(resolveOpenClawBin(), shim);
20
+ }
21
+ finally {
22
+ if (originalOpenClawBin === undefined) {
23
+ delete process.env.OPENCLAW_BIN;
24
+ }
25
+ else {
26
+ process.env.OPENCLAW_BIN = originalOpenClawBin;
27
+ }
28
+ if (originalAppData === undefined) {
29
+ delete process.env.APPDATA;
30
+ }
31
+ else {
32
+ process.env.APPDATA = originalAppData;
33
+ }
34
+ await fs.rm(root, { recursive: true, force: true });
35
+ }
36
+ });
37
+ });
@@ -11,7 +11,7 @@ import { listSessions } from './sessions.js';
11
11
  import { getSession, prepareMessage, attachSkill } from './sessions-extended.js';
12
12
  import { installSkillFromClawHub, installSkillFromNpm, listInstalledSkills, searchClawHubSkills, updateSkillFromClawHub } from './skills.js';
13
13
  import { getInstalledSkill, uninstallSkill } from './skills-extended.js';
14
- import { currentVersion, doctorFix, logs, openclawUpdate, ping, pluginSelfUpdate, restart, stop } from './system.js';
14
+ import { currentVersion, doctorFix, logs, ping, pluginSelfUpdate, restart, stop } from './system.js';
15
15
  import { getUsageBreakdown, getUsagePageSummary, getUsageSummary, getUsageTimeseries } from './usage.js';
16
16
  const methods = new Map([
17
17
  // System
@@ -20,7 +20,7 @@ const methods = new Map([
20
20
  ['system.stop', stop],
21
21
  ['system.doctorFix', doctorFix],
22
22
  ['system.logs', logs],
23
- ['system.openclawUpdate', openclawUpdate],
23
+ // OpenClaw core updates are managed outside this plugin.
24
24
  ['system.pluginSelfUpdate', pluginSelfUpdate],
25
25
  ['system.currentVersion', currentVersion],
26
26
  // Agents
@@ -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';
@@ -43,26 +44,48 @@ export async function installMem9(context) {
43
44
  const config = await ensureOpenClawConfigExists(context.openclawRoot);
44
45
  const currentState = readMem9State(config);
45
46
  const currentEntrypoint = await findMem9RuntimeEntrypoint(context.openclawRoot, config);
46
- // 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
47
48
  if (!currentState.installed && !currentEntrypoint) {
48
49
  await ensureOpenClawCli();
49
50
  await ensureNodeRuntime();
50
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
+ }
51
57
  const restart = await restartGateway(context.projectRoot);
52
58
  return {
53
59
  ok: true,
54
- phase: 'installed',
60
+ phase: backendConfig ? 'configured' : 'installed',
55
61
  needsRestart: true,
56
62
  plugin: MEM9_PLUGIN_ID,
57
- 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.',
58
69
  restart
59
70
  };
60
71
  }
61
- // 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
62
73
  if (!currentState.configured || !currentState.apiKey) {
63
74
  const runtimeEntrypoint = await ensureMem9RuntimeEntrypoint(context.openclawRoot, config);
64
- const apiKey = await createMem9Key();
65
- 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);
66
89
  const restart = await restartGateway(context.projectRoot);
67
90
  return {
68
91
  ok: true,
@@ -70,11 +93,11 @@ export async function installMem9(context) {
70
93
  installed: true,
71
94
  alreadyInstalled: true,
72
95
  alreadyConfigured: false,
73
- createdNewKey: true,
74
- reusedExistingKey: false,
96
+ createdNewKey,
97
+ reusedExistingKey,
75
98
  plugin: MEM9_PLUGIN_ID,
76
99
  runtimeEntrypoint,
77
- apiUrl: MEM9_API_URL,
100
+ apiUrl: apiUrl || MEM9_API_URL,
78
101
  apiKey,
79
102
  updated,
80
103
  restart
@@ -94,7 +117,7 @@ export async function installMem9(context) {
94
117
  reusedExistingKey: true,
95
118
  plugin: MEM9_PLUGIN_ID,
96
119
  runtimeEntrypoint,
97
- 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,
98
121
  apiKey: currentState.apiKey,
99
122
  updated,
100
123
  restart
@@ -158,7 +181,7 @@ async function ensureOpenClawConfigExists(openclawRoot) {
158
181
  async function ensureOpenClawCli() {
159
182
  const bin = resolveOpenClawBin();
160
183
  try {
161
- await execFileAsync(bin, ['--version']);
184
+ await execAsync(`"${bin}" --version`);
162
185
  }
163
186
  catch (error) {
164
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) });
@@ -166,8 +189,8 @@ async function ensureOpenClawCli() {
166
189
  }
167
190
  async function ensureNodeRuntime() {
168
191
  try {
169
- await execFileAsync('node', ['--version']);
170
- await execFileAsync('npm', ['--version']);
192
+ await execAsync('node --version');
193
+ await execAsync('npm --version');
171
194
  }
172
195
  catch (error) {
173
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) });
@@ -176,7 +199,7 @@ async function ensureNodeRuntime() {
176
199
  async function installMem9Plugin(cwd) {
177
200
  const bin = resolveOpenClawBin();
178
201
  try {
179
- await execFileAsync(bin, ['plugins', 'install', MEM9_PLUGIN_SPEC], { cwd });
202
+ await execAsync(`"${bin}" plugins install ${MEM9_PLUGIN_SPEC} --force`, { cwd });
180
203
  }
181
204
  catch (error) {
182
205
  throw new JsonRpcException(JSON_RPC_ERRORS.internalError, `${bin} plugins install ${MEM9_PLUGIN_SPEC} failed`, {
@@ -213,12 +236,20 @@ async function ensureMem9RuntimeEntrypoint(openclawRoot, config) {
213
236
  }
214
237
  }
215
238
  }
216
- throw new JsonRpcException(JSON_RPC_ERRORS.internalError, 'mem9 plugin is installed but missing compiled runtime output', {
239
+ throw new JsonRpcException(JSON_RPC_ERRORS.internalError, 'mem9 runtime not found; config exists but compiled plugin output is missing', {
217
240
  code: 'MEM9_RUNTIME_OUTPUT_MISSING',
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.',
218
243
  expected: FALLBACK_ENTRYPOINTS.map((item) => `./${item.replace(/\\/g, '/')}`),
219
244
  checkedPackageRoots,
220
245
  checkedEntrypoints,
221
- installRecord
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
+ ]
222
253
  });
223
254
  }
224
255
  async function readPluginManifest(packageRoot) {
@@ -295,15 +326,35 @@ function collectEntrypointCandidates(packageRoot, manifest) {
295
326
  }
296
327
  function resolveMem9RuntimePackageRoots(openclawRoot, config) {
297
328
  const roots = [];
329
+ const pushRoot = (root) => {
330
+ if (!roots.includes(root))
331
+ roots.push(root);
332
+ };
298
333
  const installPath = pickString(readMem9InstallRecord(config)?.installPath);
299
334
  if (installPath) {
300
- roots.push(path.isAbsolute(installPath) ? installPath : path.resolve(openclawRoot, installPath));
335
+ pushRoot(path.isAbsolute(installPath) ? installPath : path.resolve(openclawRoot, installPath));
301
336
  }
302
- for (const fallbackRoot of MEM9_PACKAGE_ROOTS.map((item) => path.join(openclawRoot, item))) {
303
- if (!roots.includes(fallbackRoot)) {
304
- 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
+ }
305
350
  }
306
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
+ }
307
358
  return roots;
308
359
  }
309
360
  async function createMem9Key() {
@@ -333,7 +384,79 @@ async function createMem9Key() {
333
384
  // ---------------------------------------------------------------------------
334
385
  // Config writers
335
386
  // ---------------------------------------------------------------------------
336
- 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) {
337
460
  const configPath = path.join(openclawRoot, 'openclaw.json');
338
461
  const config = await readJsonFile(configPath);
339
462
  if (!config.plugins)
@@ -354,7 +477,7 @@ async function writeMem9Config(openclawRoot, apiKey) {
354
477
  },
355
478
  config: {
356
479
  ...existingPluginConfig,
357
- apiUrl: MEM9_API_URL,
480
+ apiUrl: apiUrl || pickString(existingPluginConfig.apiUrl) || MEM9_API_URL,
358
481
  apiKey
359
482
  }
360
483
  };
@@ -413,7 +536,7 @@ function ensurePluginsAllow(config) {
413
536
  async function restartGateway(cwd) {
414
537
  const attempts = [];
415
538
  const bin = resolveOpenClawBin();
416
- const restartCmd = `${bin} gateway restart`;
539
+ const restartCmd = `"${bin}" gateway restart`;
417
540
  try {
418
541
  const { stdout, stderr } = await execAsync(restartCmd, { cwd });
419
542
  return {
@@ -479,6 +602,25 @@ function readMem9InstallRecord(config) {
479
602
  const record = config?.plugins?.installs?.[MEM9_PLUGIN_ID];
480
603
  return isRecord(record) ? record : null;
481
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
+ }
482
624
  function readMem9State(config) {
483
625
  const installed = Boolean((config.plugins?.installs && typeof config.plugins.installs === 'object' && MEM9_PLUGIN_ID in config.plugins.installs)
484
626
  || (config.plugins?.entries && typeof config.plugins.entries === 'object' && MEM9_PLUGIN_ID in config.plugins.entries));