@coclaw/openclaw-coclaw 0.21.5 → 0.22.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +128 -39
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -4
- package/src/channel-plugin.js +1 -1
- package/src/chat-history-manager/manager.js +208 -22
- package/src/claw-paths.js +14 -0
- package/src/cli-registrar.js +84 -4
- package/src/common/gateway-notify.js +4 -4
- package/src/common/messages.js +42 -4
- package/src/model-default/handlers.js +177 -0
- package/src/model-default/index.js +106 -0
- package/src/model-default/persist.js +115 -0
- package/src/model-default/resolve.js +88 -0
- package/src/provider-auth/handlers.js +180 -0
- package/src/provider-auth/index.js +78 -0
- package/src/realtime-bridge.js +88 -4
- package/src/session-manager/manager.js +49 -14
- package/src/topic-manager/manager.js +12 -2
- package/src/webrtc/webrtc-peer.js +14 -4
package/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { readConfig } from './src/config.js';
|
|
|
9
9
|
import { setRuntime } from './src/runtime.js';
|
|
10
10
|
import { createSessionManager } from './src/session-manager/manager.js';
|
|
11
11
|
import { TopicManager } from './src/topic-manager/manager.js';
|
|
12
|
-
import { ChatHistoryManager } from './src/chat-history-manager/manager.js';
|
|
12
|
+
import { ChatHistoryManager, classifyChatHistorySessionKey } from './src/chat-history-manager/manager.js';
|
|
13
13
|
import { generateTitle } from './src/topic-manager/title-gen.js';
|
|
14
14
|
import { AutoUpgradeScheduler } from './src/auto-upgrade/updater.js';
|
|
15
15
|
import { getPackageInfo } from './src/auto-upgrade/updater-check.js';
|
|
@@ -17,6 +17,8 @@ import { createFileHandler } from './src/file-manager/handler.js';
|
|
|
17
17
|
import { abortAgentRun } from './src/agent-abort.js';
|
|
18
18
|
import { decideCancelResponse } from './src/agent-cancel-heuristic.js';
|
|
19
19
|
import { remoteLog } from './src/remote-log.js';
|
|
20
|
+
import { registerProviderAuthHandlers } from './src/provider-auth/index.js';
|
|
21
|
+
import { registerModelDefaultHandlers } from './src/model-default/index.js';
|
|
20
22
|
|
|
21
23
|
import { getPluginVersion, __resetPluginVersion } from './src/plugin-version.js';
|
|
22
24
|
export { getPluginVersion, __resetPluginVersion };
|
|
@@ -132,7 +134,7 @@ function respondInvalid(respond, message) {
|
|
|
132
134
|
const plugin = {
|
|
133
135
|
id: 'openclaw-coclaw',
|
|
134
136
|
name: 'CoClaw',
|
|
135
|
-
description: 'OpenClaw
|
|
137
|
+
description: 'OpenClaw plugin for remote chat over WebRTC',
|
|
136
138
|
register(api) {
|
|
137
139
|
// 按 OpenClaw SDK 入口模式分叉(参照 defineChannelPluginEntry,见上游 plugin-sdk/core.ts 的
|
|
138
140
|
// defineChannelPluginEntry 实现 与 docs/plugins/sdk-entrypoints.md):
|
|
@@ -173,36 +175,108 @@ const plugin = {
|
|
|
173
175
|
topicManager.load('main').catch((err) => {
|
|
174
176
|
logger.warn?.(`[coclaw] topic manager load failed: ${String(err?.message ?? err)}`);
|
|
175
177
|
});
|
|
176
|
-
chatHistoryManager.load('main')
|
|
177
|
-
|
|
178
|
-
|
|
178
|
+
chatHistoryManager.load('main')
|
|
179
|
+
.then(() => manager.listAllEntries('main'))
|
|
180
|
+
.then((entries) => chatHistoryManager.reconcileAll('main', entries))
|
|
181
|
+
.catch((err) => {
|
|
182
|
+
logger.warn?.(`[coclaw] chat history manager load/reconcile failed: ${String(err?.message ?? err)}`);
|
|
183
|
+
});
|
|
179
184
|
|
|
180
|
-
// 追踪 chat 因 reset
|
|
185
|
+
// 追踪 chat 因 reset 产生的 session 流水。双源回调(hook + sessions.changed)共用 helper。
|
|
186
|
+
// recordSessionTransition 内部已 __reloadFromDisk + mutex,外层无需再 cache.has + load。
|
|
187
|
+
//
|
|
188
|
+
// agentId 解析:hook 路径有 ctx.agentId(显式契约,优先用);sessions.changed 路径
|
|
189
|
+
// gateway broadcast payload 不含 agentId(见 openclaw-repo emitSessionsChanged),
|
|
190
|
+
// 只能 fallback 切 sessionKey(`agent:<agentId>:*` → parts[1])。当前 sessionKey schema
|
|
191
|
+
// 下两路径解析结果等价;多 agent topic 启用后若上游 sessionKey schema 加前缀会需复评。
|
|
192
|
+
//
|
|
193
|
+
// archivedSessionId 解析:hook 路径来自 event.resumedFrom;sessions.changed payload
|
|
194
|
+
// 不带(无 previousSessionId 字段)→ 进 manager 时为 undefined。manager 不会去
|
|
195
|
+
// "推断字段值",而是把文件首位未归档头直接翻为归档(补 archivedAt)后再 unshift
|
|
196
|
+
// 新头——等价于"以文件首位 sessionId 作为前任"。
|
|
197
|
+
//
|
|
198
|
+
// 该 helper 可直接作为 bridge.onSessionCreated 回调(签名兼容;缺失字段走兜底:
|
|
199
|
+
// agentId 走 parts[1] fallback、archivedSessionId 走 manager 翻 head 路径)。
|
|
200
|
+
async function handleSessionCreated({ agentId, sessionKey, sessionId, archivedSessionId }) {
|
|
201
|
+
// sessionKey / sessionId 非字符串时(上游 schema 异常)直接当 missing 处理,避免 split 抛 TypeError
|
|
202
|
+
// 或脏值落盘。manager 内部入口同样会校验(深度防御)。
|
|
203
|
+
if (typeof sessionKey !== 'string' || !sessionKey
|
|
204
|
+
|| typeof sessionId !== 'string' || !sessionId) {
|
|
205
|
+
// 早返值得警惕:上游事件 schema 异常,或 topic(无 sessionKey)误入双源链路。
|
|
206
|
+
// 打 log + remoteLog 让运维能定位事件源;不影响其他通道。
|
|
207
|
+
// 日志兼容性:缺失(null/undefined)→ 'null';其它非字符串类型 → 'invalid'。
|
|
208
|
+
const formatField = (v) => v == null
|
|
209
|
+
? 'null'
|
|
210
|
+
: (typeof v === 'string' ? v : 'invalid');
|
|
211
|
+
logger.warn?.(
|
|
212
|
+
`[coclaw] chat history early-return: missing/invalid sessionKey/sessionId`,
|
|
213
|
+
);
|
|
214
|
+
remoteLog(
|
|
215
|
+
`chat-history.missing-keys sessionKey=${formatField(sessionKey)} sessionId=${formatField(sessionId)}`,
|
|
216
|
+
);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// sessionKey 路由分类(explicit / subagent / cron 跳过,详见 classifyChatHistorySessionKey)。
|
|
220
|
+
// 守卫必须与启动期对账 reconcileAll 内的守卫一致——共用同一 helper 避免侧门。
|
|
221
|
+
const cls = classifyChatHistorySessionKey(sessionKey);
|
|
222
|
+
if (!cls.ok) {
|
|
223
|
+
remoteLog(`chat-history.skip-${cls.reason} sessionKey=${sessionKey}`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const parts = sessionKey.split(':');
|
|
227
|
+
let resolvedAgentId = agentId;
|
|
228
|
+
if (!resolvedAgentId) {
|
|
229
|
+
resolvedAgentId = (parts[0] === 'agent' && parts[1]) ? parts[1] : 'main';
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
await chatHistoryManager.recordSessionTransition({
|
|
233
|
+
agentId: resolvedAgentId,
|
|
234
|
+
sessionKey,
|
|
235
|
+
currentSessionId: sessionId,
|
|
236
|
+
archivedSessionId,
|
|
237
|
+
});
|
|
238
|
+
} catch (err) {
|
|
239
|
+
logger.warn?.(`[coclaw] chat history record failed: ${String(err?.message ?? err)}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
181
242
|
if (typeof api.on === 'function') {
|
|
182
243
|
api.on('session_start', async (event, ctx) => {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
244
|
+
// event.sessionId 是新 sid(必填),event.resumedFrom 是旧 sid(可选),ctx.agentId 可信
|
|
245
|
+
await handleSessionCreated({
|
|
246
|
+
agentId: ctx?.agentId,
|
|
247
|
+
sessionKey: event?.sessionKey,
|
|
248
|
+
sessionId: event?.sessionId,
|
|
249
|
+
archivedSessionId: event?.resumedFrom,
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
// cron 顶替主会话 sid 时不走 session_start hook、不广播 sessions.changed reason=create。
|
|
253
|
+
// cron_changed action=finished 是 cron 完成的可感知通道(v2026.4.29+ 支持,见
|
|
254
|
+
// openclaw-repo/src/plugins/hook-types.ts)。main 模式 cron 不带 sessionId → 早返天然过滤;
|
|
255
|
+
// 真触发顶替的"显式 session:<sk>"/"current" 路径 event.sessionId/sessionKey 都齐。
|
|
256
|
+
api.on('cron_changed', async (event) => {
|
|
257
|
+
if (event?.action !== 'finished') return;
|
|
258
|
+
if (!event?.sessionId || !event?.sessionKey) return;
|
|
259
|
+
await handleSessionCreated({
|
|
260
|
+
sessionKey: event.sessionKey,
|
|
261
|
+
sessionId: event.sessionId,
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// bridge 启动/重启的闭包 helper:把 onSessionCreated 接到 handleSessionCreated。
|
|
267
|
+
// 所有 restartRealtimeBridge 调用必须走这个 helper,避免漏接回调。
|
|
268
|
+
async function restartBridge() {
|
|
269
|
+
await restartRealtimeBridge({
|
|
270
|
+
logger,
|
|
271
|
+
pluginConfig: api.pluginConfig,
|
|
272
|
+
onSessionCreated: handleSessionCreated,
|
|
199
273
|
});
|
|
200
274
|
}
|
|
201
275
|
|
|
202
276
|
api.registerService({
|
|
203
277
|
id: 'coclaw-realtime-bridge',
|
|
204
278
|
async start() {
|
|
205
|
-
await
|
|
279
|
+
await restartBridge();
|
|
206
280
|
},
|
|
207
281
|
async stop() {
|
|
208
282
|
await stopRealtimeBridge();
|
|
@@ -239,11 +313,11 @@ const plugin = {
|
|
|
239
313
|
});
|
|
240
314
|
} catch (err) {
|
|
241
315
|
// bind 失败时恢复 bridge(best-effort,不覆盖原始错误)
|
|
242
|
-
await
|
|
316
|
+
await restartBridge().catch(() => {});
|
|
243
317
|
throw err;
|
|
244
318
|
}
|
|
245
319
|
// bind 已持久化,restart 失败不影响结果
|
|
246
|
-
await
|
|
320
|
+
await restartBridge().catch((err) => {
|
|
247
321
|
logger.warn?.(`[coclaw] bridge restart failed after bind: ${err?.message ?? err}`);
|
|
248
322
|
});
|
|
249
323
|
return result;
|
|
@@ -276,11 +350,9 @@ const plugin = {
|
|
|
276
350
|
serverUrl: params?.serverUrl,
|
|
277
351
|
});
|
|
278
352
|
respond(true, {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
previousClawId: result.previousClawId,
|
|
283
|
-
},
|
|
353
|
+
clawId: result.clawId,
|
|
354
|
+
rebound: result.rebound,
|
|
355
|
+
previousClawId: result.previousClawId,
|
|
284
356
|
});
|
|
285
357
|
}
|
|
286
358
|
catch (err) {
|
|
@@ -296,7 +368,7 @@ const plugin = {
|
|
|
296
368
|
return;
|
|
297
369
|
}
|
|
298
370
|
const result = await doUnbind({ serverUrl: params?.serverUrl });
|
|
299
|
-
respond(true, {
|
|
371
|
+
respond(true, { clawId: result.clawId });
|
|
300
372
|
}
|
|
301
373
|
catch (err) {
|
|
302
374
|
respondError(respond, err);
|
|
@@ -325,12 +397,10 @@ const plugin = {
|
|
|
325
397
|
|
|
326
398
|
// 立即返回认领码给 CLI
|
|
327
399
|
respond(true, {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
expiresMinutes,
|
|
333
|
-
},
|
|
400
|
+
code: result.code,
|
|
401
|
+
appUrl: result.appUrl,
|
|
402
|
+
expiresAt: result.expiresAt,
|
|
403
|
+
expiresMinutes,
|
|
334
404
|
});
|
|
335
405
|
|
|
336
406
|
// 后台 fire-and-forget:等待认领并保存 config + 启 bridge
|
|
@@ -341,7 +411,7 @@ const plugin = {
|
|
|
341
411
|
signal: abortController.signal,
|
|
342
412
|
}).then(async () => {
|
|
343
413
|
if (abortController.signal.aborted) return;
|
|
344
|
-
await
|
|
414
|
+
await restartBridge();
|
|
345
415
|
logger.info?.('[coclaw] enroll completed, bridge restarted');
|
|
346
416
|
}).catch((err) => {
|
|
347
417
|
if (abortController.signal.aborted) return;
|
|
@@ -691,6 +761,25 @@ const plugin = {
|
|
|
691
761
|
}
|
|
692
762
|
});
|
|
693
763
|
|
|
764
|
+
// provider 认证管理 RPC(API key 写入 / 列表 / 撤销)。SDK 走懒加载 dynamic import,
|
|
765
|
+
// 不增加本插件 cold-load 开销,也让测试环境无需 openclaw 包就能加载 index.js。
|
|
766
|
+
// loadSdk 字面量必须留在本入口源码里:OpenClaw plugin loader 只扫入口文件识别
|
|
767
|
+
// `openclaw/plugin-sdk/*` 字符串字面量、命中后才把整张依赖图过 jiti 改写到自家 dist;
|
|
768
|
+
// 字面量留在子模块里 loader 看不到 → 整张图走原生 Node 解析必败(plugin 部署目录不带 openclaw 包)
|
|
769
|
+
registerProviderAuthHandlers(api, {
|
|
770
|
+
loadSdk: () => import('openclaw/plugin-sdk/provider-auth'),
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// 模型默认配置 RPC(coclaw.model.set / list)。三个 SDK 子入口的字面量
|
|
774
|
+
// dynamic import 必须留在本入口源码——OpenClaw plugin loader 只扫入口源码
|
|
775
|
+
// 命中 `openclaw/plugin-sdk/*` 字面量并触发 jiti 重写;藏在子模块的字面量
|
|
776
|
+
// loader 看不到 → 原生 Node 解析必败。
|
|
777
|
+
registerModelDefaultHandlers(api, {
|
|
778
|
+
loadConfigMutation: () => import('openclaw/plugin-sdk/config-mutation'),
|
|
779
|
+
loadModelsProviderRuntime: () => import('openclaw/plugin-sdk/models-provider-runtime'),
|
|
780
|
+
loadProviderAuth: () => import('openclaw/plugin-sdk/provider-auth'),
|
|
781
|
+
});
|
|
782
|
+
|
|
694
783
|
const scheduler = new AutoUpgradeScheduler({ pluginId: api.id, logger });
|
|
695
784
|
api.registerService({
|
|
696
785
|
id: 'coclaw-auto-upgrade',
|
|
@@ -738,7 +827,7 @@ const plugin = {
|
|
|
738
827
|
signal: abortController.signal,
|
|
739
828
|
}).then(async () => {
|
|
740
829
|
if (abortController.signal.aborted) return;
|
|
741
|
-
await
|
|
830
|
+
await restartBridge();
|
|
742
831
|
logger.info?.('[coclaw] enroll completed via slash command, bridge restarted');
|
|
743
832
|
}).catch((err) => {
|
|
744
833
|
if (abortController.signal.aborted) return;
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-coclaw",
|
|
3
3
|
"name": "CoClaw",
|
|
4
|
-
"description": "OpenClaw
|
|
4
|
+
"description": "OpenClaw plugin for remote chat over WebRTC. Run `openclaw coclaw enroll` after install.",
|
|
5
5
|
"activation": {
|
|
6
6
|
"onStartup": true
|
|
7
7
|
},
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coclaw/openclaw-coclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
-
"description": "OpenClaw
|
|
6
|
+
"description": "OpenClaw plugin for remote chat over WebRTC. Run `openclaw coclaw enroll` after install.",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "https://github.com/coclaw/coclaw.git",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"./index.js"
|
|
42
42
|
],
|
|
43
43
|
"install": {
|
|
44
|
-
"minHostVersion": ">=2026.
|
|
44
|
+
"minHostVersion": ">=2026.3.22"
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"check": "pnpm lint && pnpm typecheck",
|
|
52
52
|
"test:plugin": "node --test src/plugin-mode.test.js",
|
|
53
53
|
"test": "c8 --check-coverage --lines 100 --functions 100 --branches 95 --statements 100 --reporter=text --reporter=lcov bash -c 'for f in src/**/*.test.js src/*.test.js index.test.js; do node --test \"$f\" || exit 1; done'",
|
|
54
|
+
"e2e": "node --test --test-concurrency=1 e2e/*.e2e.spec.js",
|
|
54
55
|
"verify": "pnpm check && pnpm test",
|
|
55
56
|
"link": "bash ./scripts/link.sh",
|
|
56
57
|
"unlink": "bash ./scripts/unlink.sh",
|
|
@@ -62,7 +63,7 @@
|
|
|
62
63
|
"release:versions": "npm view @coclaw/openclaw-coclaw versions --json --registry=https://registry.npmjs.org/ && npm view @coclaw/openclaw-coclaw versions --json"
|
|
63
64
|
},
|
|
64
65
|
"dependencies": {
|
|
65
|
-
"@coclaw/pion-node": "^0.
|
|
66
|
+
"@coclaw/pion-node": "^0.4.0",
|
|
66
67
|
"werift": "^0.19.0",
|
|
67
68
|
"ws": "^8.19.0"
|
|
68
69
|
},
|
package/src/channel-plugin.js
CHANGED
|
@@ -17,7 +17,7 @@ export const coclawChannelPlugin = {
|
|
|
17
17
|
label: 'CoClaw',
|
|
18
18
|
selectionLabel: 'CoClaw',
|
|
19
19
|
docsPath: 'https://docs.coclaw.net',
|
|
20
|
-
blurb: 'CoClaw channel plugin for remote chat',
|
|
20
|
+
blurb: 'CoClaw channel plugin for remote chat over WebRTC',
|
|
21
21
|
},
|
|
22
22
|
capabilities: {
|
|
23
23
|
chatTypes: ['direct'],
|
|
@@ -4,6 +4,7 @@ import nodePath from 'node:path';
|
|
|
4
4
|
import { agentSessionsDir } from '../claw-paths.js';
|
|
5
5
|
import { atomicWriteJsonFile } from '../utils/atomic-write.js';
|
|
6
6
|
import { createMutex } from '../utils/mutex.js';
|
|
7
|
+
import { remoteLog } from '../remote-log.js';
|
|
7
8
|
|
|
8
9
|
const HISTORY_FILE = 'coclaw-chat-history.json';
|
|
9
10
|
|
|
@@ -12,18 +13,57 @@ function emptyStore() {
|
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
|
-
*
|
|
16
|
+
* 判定 sessionKey 是否应纳入 chat-history 流水。
|
|
17
|
+
* 不纳入的三类,命中即返回对应 reason:
|
|
18
|
+
* - explicit:topic 用的伪 sessionKey,CoClaw 自管不入 chat-history
|
|
19
|
+
* - subagent:OpenClaw 程序自起的子任务(含嵌套)
|
|
20
|
+
* - cron:isolated cron 跑出的伪 sessionKey(含上游证实主 sessions.json 也会承载此类条目)
|
|
21
|
+
*
|
|
22
|
+
* 必须被所有写 chat-history 的入口(事件路径 / 启动对账等)共用,否则启动对账等绕过事件路径
|
|
23
|
+
* 的入口会把伪 sessionKey 写进 chat-history。
|
|
24
|
+
*
|
|
25
|
+
* 非字符串 / 空串 sessionKey 视为非法输入(ok=false reason=null),由 caller 自行决定怎么处理。
|
|
26
|
+
*
|
|
27
|
+
* @param {string} sessionKey
|
|
28
|
+
* @returns {{ ok: boolean, reason: 'explicit' | 'subagent' | 'cron' | null }}
|
|
29
|
+
*/
|
|
30
|
+
export function classifyChatHistorySessionKey(sessionKey) {
|
|
31
|
+
if (typeof sessionKey !== 'string' || !sessionKey) {
|
|
32
|
+
return { ok: false, reason: null };
|
|
33
|
+
}
|
|
34
|
+
const parts = sessionKey.split(':');
|
|
35
|
+
if (parts[0] !== 'agent') return { ok: true, reason: null };
|
|
36
|
+
// 三个跳过类别都用"parts[2] 严格相等",与上游 routing 一致:
|
|
37
|
+
// - isCronSessionKey / isSubagentSessionKey 都只在 rest 起始处(即 parts[2])匹配;
|
|
38
|
+
// - cron 跑出的子代理(agent:<id>:cron:<jobId>:subagent:<uuid>)上游不视作 subagent,
|
|
39
|
+
// 由 cron 守卫挡住即可,避免与 IM per-account DM accountId="cron"/"subagent" 形态冲突
|
|
40
|
+
// (accountId 仅按 [a-z0-9_-]{1,64} 校验,"cron"/"subagent" 都是合法账户名)。
|
|
41
|
+
if (parts[2] === 'explicit') return { ok: false, reason: 'explicit' };
|
|
42
|
+
if (parts[2] === 'cron') return { ok: false, reason: 'cron' };
|
|
43
|
+
if (parts[2] === 'subagent') return { ok: false, reason: 'subagent' };
|
|
44
|
+
return { ok: true, reason: null };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Chat History 管理器:追踪 chat(sessionKey)下的 session 流水。
|
|
16
49
|
*
|
|
17
50
|
* 每个 agentId 对应一份 coclaw-chat-history.json,按需懒加载到内存。
|
|
18
51
|
* 写操作通过 mutex + atomicWriteJsonFile 保证一致性。
|
|
19
52
|
*
|
|
20
|
-
*
|
|
53
|
+
* 文件结构示例(archivedAt 是 Date.now() 落的 13 位毫秒时间戳):
|
|
21
54
|
* {
|
|
22
55
|
* "version": 1,
|
|
23
56
|
* "agent:main:main": [
|
|
24
|
-
* { "sessionId": "
|
|
57
|
+
* { "sessionId": "current-sid" }, // 首位:未归档头 = 当前活跃 session
|
|
58
|
+
* { "sessionId": "older", "archivedAt": 1742003000000 } // 第二位起:已归档(新→旧)
|
|
25
59
|
* ]
|
|
26
60
|
* }
|
|
61
|
+
*
|
|
62
|
+
* 详见 plugins/openclaw/docs/architecture.md
|
|
63
|
+
*
|
|
64
|
+
* 双源事件供给:
|
|
65
|
+
* - session_start hook:event 同时含新 sid (currentSessionId) + 旧 sid (archivedSessionId)
|
|
66
|
+
* - sessions.changed reason=create:payload 含 sessionKey + 新 sid,旧 sid 从文件首位推断
|
|
27
67
|
*/
|
|
28
68
|
export class ChatHistoryManager {
|
|
29
69
|
/**
|
|
@@ -87,14 +127,29 @@ export class ChatHistoryManager {
|
|
|
87
127
|
this.__cache.set(agentId, data);
|
|
88
128
|
return;
|
|
89
129
|
}
|
|
90
|
-
} catch {
|
|
91
|
-
|
|
130
|
+
} catch (err) {
|
|
131
|
+
this.__reportLoadError(filePath, err, '__doLoad');
|
|
92
132
|
}
|
|
93
133
|
this.__cache.set(agentId, emptyStore());
|
|
94
134
|
}
|
|
95
135
|
|
|
136
|
+
/**
|
|
137
|
+
* 读盘失败分流:ENOENT 是正常情况(首次启动文件不存在)静默;
|
|
138
|
+
* 其他错误(权限、磁盘损坏、JSON 破损)有诊断价值,打 warn + remoteLog 标识可疑。
|
|
139
|
+
*/
|
|
140
|
+
__reportLoadError(filePath, err, callsite) {
|
|
141
|
+
if (err?.code === 'ENOENT') return;
|
|
142
|
+
const fname = nodePath.basename(filePath);
|
|
143
|
+
this.__logger.warn?.(
|
|
144
|
+
`[coclaw] chat-history ${callsite} read failed for ${fname}: ${String(err?.message ?? err)}`,
|
|
145
|
+
);
|
|
146
|
+
remoteLog(
|
|
147
|
+
`chat-history.reload-error site=${callsite} file=${fname} msg=${String(err?.message ?? err)}`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
96
151
|
__ensureLoaded(agentId) {
|
|
97
|
-
/* c8 ignore start --
|
|
152
|
+
/* c8 ignore start -- recordSessionTransition/list 均先 __reloadFromDisk,此分支为防御性守卫 */
|
|
98
153
|
if (!this.__cache.has(agentId)) {
|
|
99
154
|
throw new Error(`ChatHistoryManager: agent "${agentId}" not loaded, call load() first`);
|
|
100
155
|
}
|
|
@@ -108,15 +163,76 @@ export class ChatHistoryManager {
|
|
|
108
163
|
|
|
109
164
|
async __persist(agentId) {
|
|
110
165
|
const store = this.__getStore(agentId);
|
|
111
|
-
|
|
166
|
+
// 自愈守卫:list[1..](非头位)若仍有未归档项视为脏数据(cron 顶替 / 旧版本写入 / 异常 race
|
|
167
|
+
// 残留),强制补 archivedAt。放在 __persist 内是为了覆盖所有写盘路径——新增写入点自动受护。
|
|
168
|
+
this.__sanitizeAllSessionKeys(store, agentId);
|
|
169
|
+
try {
|
|
170
|
+
await this.__writeJsonFile(this.__historyFilePath(agentId), store);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
// 写盘失败时清除该 agent 的内存 cache:caller 此前在 mutex 内已对 in-place list 做过
|
|
173
|
+
// unshift / splice / sanitize,这些修改不能随后被下一次"reload 命中 cache 不读盘"
|
|
174
|
+
// 的路径误持久化。删 cache 让下次操作的 __reloadFromDisk 必须重读磁盘(或 ENOENT 降级
|
|
175
|
+
// 为 empty store),保证内存与磁盘最终一致。
|
|
176
|
+
this.__cache.delete(agentId);
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
112
179
|
}
|
|
113
180
|
|
|
114
181
|
/**
|
|
115
|
-
*
|
|
116
|
-
*
|
|
182
|
+
* 遍历 store 内每个 sessionKey 的 list,把 `list[1..]` 中 `!archivedAt` 的项强制
|
|
183
|
+
* 写上 `archivedAt = Date.now()`。每修一条同时打本地 warn + remoteLog 暴露信号。
|
|
184
|
+
* @param {object} store
|
|
185
|
+
* @param {string} agentId
|
|
117
186
|
*/
|
|
118
|
-
|
|
119
|
-
if (!
|
|
187
|
+
__sanitizeAllSessionKeys(store, agentId) {
|
|
188
|
+
if (!store || typeof store !== 'object') return;
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
for (const [sessionKey, list] of Object.entries(store)) {
|
|
191
|
+
if (!Array.isArray(list) || list.length <= 1) continue;
|
|
192
|
+
for (let i = 1; i < list.length; i++) {
|
|
193
|
+
const item = list[i];
|
|
194
|
+
if (!item || typeof item !== 'object' || item.archivedAt) continue;
|
|
195
|
+
item.archivedAt = now;
|
|
196
|
+
this.__logger.warn?.(
|
|
197
|
+
`[coclaw] chat-history sanitize: non-head unarchived entry coerced sessionKey=${sessionKey} sid=${item.sessionId}`,
|
|
198
|
+
);
|
|
199
|
+
remoteLog(
|
|
200
|
+
`chat-history.sanitize-coerce sessionKey=${sessionKey} sid=${item.sessionId} agentId=${agentId}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 记录一次 session 转换。双源事件共用:
|
|
208
|
+
* - session_start hook:
|
|
209
|
+
* currentSessionId = event.sessionId,
|
|
210
|
+
* archivedSessionId = event.resumedFrom
|
|
211
|
+
* - sessions.changed reason=create:
|
|
212
|
+
* currentSessionId = payload.sessionId,
|
|
213
|
+
* archivedSessionId 不提供(从文件首位推断)
|
|
214
|
+
*
|
|
215
|
+
* 文件首位(list[0])维护当前活跃 session(未归档),其后是已归档 item(新→旧)。
|
|
216
|
+
*
|
|
217
|
+
* 幂等:head 已是 currentSessionId 且
|
|
218
|
+
* - 无 archivedSessionId,或
|
|
219
|
+
* - archivedSessionId 已存在于 list 中
|
|
220
|
+
* 时整体 no-op(不写盘)。
|
|
221
|
+
*
|
|
222
|
+
* @param {{ agentId: string, sessionKey: string, currentSessionId: string, archivedSessionId?: string }} params
|
|
223
|
+
*/
|
|
224
|
+
async recordSessionTransition({ agentId, sessionKey, currentSessionId, archivedSessionId }) {
|
|
225
|
+
// 严格类型校验:上游 hook payload 异常时(非字符串 sessionId / sessionKey)静默拒绝,避免把脏值落盘。
|
|
226
|
+
if (typeof sessionKey !== 'string' || !sessionKey) return;
|
|
227
|
+
if (typeof currentSessionId !== 'string' || !currentSessionId) return;
|
|
228
|
+
if (typeof archivedSessionId !== 'string' || !archivedSessionId) archivedSessionId = undefined;
|
|
229
|
+
// 规范化:archivedSessionId 与 currentSessionId 相同属上游契约异常(resumedFrom 不应等于 sessionId),
|
|
230
|
+
// 丢弃避免在空 list 起手时写出"同 sid 既是头又是归档"的双份记录。打 remoteLog 暴露信号
|
|
231
|
+
// 让运维捕捉到上游可能的回归——只在真触发时打一次,正常路径噪声为零。
|
|
232
|
+
if (archivedSessionId === currentSessionId) {
|
|
233
|
+
remoteLog(`chat-history.archived-equals-current sessionKey=${sessionKey} sid=${currentSessionId}`);
|
|
234
|
+
archivedSessionId = undefined;
|
|
235
|
+
}
|
|
120
236
|
await this.__mutex(agentId).withLock(async () => {
|
|
121
237
|
// 从磁盘重载确保最新状态:list() 无锁覆写 __cache 可能导致缓存过期
|
|
122
238
|
await this.__reloadFromDisk(agentId);
|
|
@@ -124,23 +240,93 @@ export class ChatHistoryManager {
|
|
|
124
240
|
if (!Array.isArray(store[sessionKey])) {
|
|
125
241
|
store[sessionKey] = [];
|
|
126
242
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
sessionId
|
|
132
|
-
|
|
133
|
-
|
|
243
|
+
const list = store[sessionKey];
|
|
244
|
+
const head = list[0];
|
|
245
|
+
const headIsCurrent = head && !head.archivedAt && head.sessionId === currentSessionId;
|
|
246
|
+
const archivedAlreadyInList = archivedSessionId
|
|
247
|
+
&& list.some((it) => it.sessionId === archivedSessionId);
|
|
248
|
+
// 完全 no-op:head 已是 current 且无新 archived 要追加
|
|
249
|
+
if (headIsCurrent && (!archivedSessionId || archivedAlreadyInList)) return;
|
|
250
|
+
|
|
251
|
+
// head 已是 current(双源到达:第二个事件提供了之前未带的 archivedSessionId)
|
|
252
|
+
// → 不动 head,仅在第二位插入 archivedSessionId
|
|
253
|
+
if (headIsCurrent) {
|
|
254
|
+
list.splice(1, 0, { sessionId: archivedSessionId, archivedAt: Date.now() });
|
|
255
|
+
await this.__persist(agentId);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// stale 事件防御:currentSessionId 已存在于 list 其他位置(即已被归档)。
|
|
260
|
+
// 此时若继续走"翻 head"会把真正活跃的头错翻成归档,并让该 sid 在 list 中重复出现。
|
|
261
|
+
// 触发场景:A→B→C 快速连续 reset 时,hook 与 sessions.changed 跨通道乱序到达,
|
|
262
|
+
// 旧 transition 的事件晚于新 transition 的事件被处理。
|
|
263
|
+
if (list.some((it) => it.sessionId === currentSessionId)) return;
|
|
264
|
+
|
|
265
|
+
// 一般路径:翻 head 为归档(若未归档),然后处理 archivedSessionId,最后头插新 head
|
|
266
|
+
// head 已归档(老格式磁盘数据 / 已正常归档的 list)→ 跳过翻动直接 unshift
|
|
267
|
+
if (head && !head.archivedAt) {
|
|
268
|
+
head.archivedAt = Date.now();
|
|
269
|
+
}
|
|
270
|
+
// archivedSessionId 与 head 不同且不在 list → 在第二位追加(保证不丢前任记录)
|
|
271
|
+
if (archivedSessionId
|
|
272
|
+
&& archivedSessionId !== head?.sessionId
|
|
273
|
+
&& !list.some((it) => it.sessionId === archivedSessionId)) {
|
|
274
|
+
list.splice(1, 0, { sessionId: archivedSessionId, archivedAt: Date.now() });
|
|
275
|
+
}
|
|
276
|
+
list.unshift({ sessionId: currentSessionId });
|
|
134
277
|
await this.__persist(agentId);
|
|
135
278
|
});
|
|
136
279
|
}
|
|
137
280
|
|
|
138
281
|
/**
|
|
139
|
-
*
|
|
282
|
+
* 启动期对账:把 sessions.json 当前 entries 喂进来,对每条调
|
|
283
|
+
* recordSessionTransition;现有幂等 + sanitize 自动吞重复。用于覆盖 plugin/gateway
|
|
284
|
+
* 重启窗口期 cron 顶替导致的漏归档(cron_changed hook 是主通道,但 gateway 重启不回放
|
|
285
|
+
* 已完成的 cron event,靠启动对账兜底当前 sessions.json 的 head sid)。
|
|
286
|
+
*
|
|
287
|
+
* sessions.json 里可能含 isolated cron / subagent / explicit 形态的 sessionKey
|
|
288
|
+
* 条目(上游 run-session-state.ts:57-60 证实 isolated cron 写主 sessions.json),
|
|
289
|
+
* 用 classifyChatHistorySessionKey 守卫滤掉避免污染 chat-history。
|
|
290
|
+
*
|
|
291
|
+
* 单条 entry 抛错 try/catch 隔离,不阻塞后续;caller 已在外层 .catch 兜底。
|
|
292
|
+
*
|
|
293
|
+
* @param {string} agentId
|
|
294
|
+
* @param {{ sessionKey: string, sessionId: string }[]} entries
|
|
295
|
+
*/
|
|
296
|
+
async reconcileAll(agentId, entries) {
|
|
297
|
+
if (!Array.isArray(entries)) return;
|
|
298
|
+
for (const entry of entries) {
|
|
299
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
300
|
+
const { ok } = classifyChatHistorySessionKey(entry.sessionKey);
|
|
301
|
+
if (!ok) continue;
|
|
302
|
+
try {
|
|
303
|
+
await this.recordSessionTransition({
|
|
304
|
+
agentId,
|
|
305
|
+
sessionKey: entry.sessionKey,
|
|
306
|
+
currentSessionId: entry.sessionId,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
this.__logger.warn?.(
|
|
311
|
+
`[coclaw] chat-history reconcile entry failed sessionKey=${entry.sessionKey}: ${String(err?.message ?? err)}`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 获取指定 chat 的 session 列表(原始数组:首位可能是未归档的当前活跃 session)。
|
|
140
319
|
* 每次调用从磁盘重载,确保跨模块实例一致性
|
|
141
320
|
* (OpenClaw 的 hook 和 gateway method 可能在不同 ESM 模块实例中运行)。
|
|
321
|
+
*
|
|
322
|
+
* RPC 契约:`coclaw.chatHistory.list` 直接透传本返回值,不做服务端过滤;调用方
|
|
323
|
+
* (UI / 其它消费者)按 `archivedAt != null` 自行过滤未归档头与孤儿历史段。
|
|
324
|
+
*
|
|
325
|
+
* **返回值是 cache 引用,调用方禁止 mutate**(不要 splice / sort / 改 item 字段);
|
|
326
|
+
* RPC handler 立刻 JSON 序列化所以无副作用,进程内消费者若需要修改请先 deep copy。
|
|
327
|
+
*
|
|
142
328
|
* @param {{ agentId: string, sessionKey: string }} params
|
|
143
|
-
* @returns {Promise<{ history: { sessionId: string, archivedAt
|
|
329
|
+
* @returns {Promise<{ history: { sessionId: string, archivedAt?: number }[] }>}
|
|
144
330
|
*/
|
|
145
331
|
async list({ agentId, sessionKey }) {
|
|
146
332
|
await this.__reloadFromDisk(agentId);
|
|
@@ -162,8 +348,8 @@ export class ChatHistoryManager {
|
|
|
162
348
|
this.__cache.set(agentId, data);
|
|
163
349
|
return;
|
|
164
350
|
}
|
|
165
|
-
} catch {
|
|
166
|
-
|
|
351
|
+
} catch (err) {
|
|
352
|
+
this.__reportLoadError(filePath, err, '__reloadFromDisk');
|
|
167
353
|
}
|
|
168
354
|
if (!this.__cache.has(agentId)) {
|
|
169
355
|
this.__cache.set(agentId, emptyStore());
|
package/src/claw-paths.js
CHANGED
|
@@ -16,6 +16,7 @@ import nodePath from 'node:path';
|
|
|
16
16
|
import { getRuntime } from './runtime.js';
|
|
17
17
|
|
|
18
18
|
const CHANNEL_ID = 'coclaw';
|
|
19
|
+
const MAIN_AGENT_ID = 'main';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* OpenClaw 真实 state 目录
|
|
@@ -81,4 +82,17 @@ export function sessionTranscriptPath(sessionId, agentId, entry) {
|
|
|
81
82
|
return nodePath.join(agentSessionsDir(agentId), `${sessionId}.jsonl`);
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
/**
|
|
86
|
+
* main agent 的 agentDir(auth-profiles.json / auth-state.json 等凭据文件所在目录)
|
|
87
|
+
*
|
|
88
|
+
* OpenClaw provider-auth helper 的 `agentDir` 入参约定**含 `/agent` 子目录**
|
|
89
|
+
* (上游 `resolveOpenClawAgentDir` 内部就是拼到这一层),见 mental-model 陷阱 #14。
|
|
90
|
+
* 凭据相关 RPC(provider-auth setApiKey / list / remove)一律走 main agent
|
|
91
|
+
* 一份就够——所有 agent 通过 OpenClaw 的层叠合并自动可见(mental-model § 4.2-4.3)。
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
export function mainAgentDir() {
|
|
95
|
+
return nodePath.join(clawStateDir(), 'agents', MAIN_AGENT_ID, 'agent');
|
|
96
|
+
}
|
|
97
|
+
|
|
84
98
|
export { CHANNEL_ID };
|