rol-websocket-channel 1.6.9 → 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 +165 -23
- package/dist/src/admin/methods/mem9.test.js +160 -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 +194 -1
- package/src/admin/methods/mem9.ts +207 -24
- package/src/admin/methods/system.ts +23 -27
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: "
|
|
260
|
-
|
|
261
|
-
|
|
262
|
+
type: "receiver",
|
|
263
|
+
source: "ai",
|
|
264
|
+
data: { text },
|
|
262
265
|
timestamp: Date.now(),
|
|
263
266
|
});
|
|
264
|
-
|
|
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: "
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
mediaUrl,
|
|
281
|
+
type: "receiver",
|
|
282
|
+
source: "ai",
|
|
283
|
+
data: { text, mediaUrl },
|
|
277
284
|
timestamp: Date.now(),
|
|
278
285
|
});
|
|
279
|
-
conn.
|
|
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:
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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(["
|
|
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 = {
|
package/dist/message-handler.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 →
|
|
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
|
|
65
|
-
|
|
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
|
|
74
|
-
reusedExistingKey
|
|
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
|
|
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
|
|
170
|
-
await
|
|
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
|
|
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
|
|
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
|
-
|
|
335
|
+
pushRoot(path.isAbsolute(installPath) ? installPath : path.resolve(openclawRoot, installPath));
|
|
301
336
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
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 =
|
|
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));
|