@coclaw/openclaw-coclaw 0.22.1 → 0.22.3
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 +49 -27
- package/package.json +1 -1
- package/src/chat-history-manager/manager.js +112 -3
- package/src/file-manager/handler.js +5 -2
- package/src/model-default/index.js +3 -0
- package/src/provider-auth/index.js +3 -0
- package/src/realtime-bridge.js +7 -0
- package/src/remote-log.js +5 -0
- package/src/runtime.js +5 -1
- package/src/session-manager/manager.js +52 -23
- package/src/webrtc/webrtc-peer.js +4 -3
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';
|
|
@@ -23,6 +23,15 @@ import { registerModelDefaultHandlers } from './src/model-default/index.js';
|
|
|
23
23
|
import { getPluginVersion, __resetPluginVersion } from './src/plugin-version.js';
|
|
24
24
|
export { getPluginVersion, __resetPluginVersion };
|
|
25
25
|
|
|
26
|
+
// 收纳 register() 在 full 模式启动的 fire-and-forget 初始化任务(topic / chat-history
|
|
27
|
+
// load + reconcile)的完成信号。默认 Promise.resolve() 让 awaitPluginInit() 在 register
|
|
28
|
+
// 未跑或非 full 模式时立即返回。每次 full register 都重置——多次 register 互不串扰。
|
|
29
|
+
let __pluginInitDone = Promise.resolve();
|
|
30
|
+
|
|
31
|
+
export function awaitPluginInit() {
|
|
32
|
+
return __pluginInitDone;
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
// 侧门注册表观测:patch OpenClaw embeddedRunState.activeRuns 的 set/delete,
|
|
27
36
|
// 用于跟踪 sessionId 何时注册/注销(agent 取消流程实际读取的就是这张表)。
|
|
28
37
|
// OpenClaw 侧门形状变化时(缺失 / 抛异常),通过 remoteLog 上报为升级契约变更的早期信号。
|
|
@@ -171,13 +180,19 @@ const plugin = {
|
|
|
171
180
|
const topicManager = new TopicManager({ logger });
|
|
172
181
|
const chatHistoryManager = new ChatHistoryManager({ logger });
|
|
173
182
|
|
|
174
|
-
// 懒加载 topic / chat history 数据(best-effort
|
|
175
|
-
|
|
183
|
+
// 懒加载 topic / chat history 数据(best-effort,不阻断注册)。
|
|
184
|
+
// 两条 promise 收成一个 bundle 挂到 __pluginInitDone,让测试 / 关心 done 时机的
|
|
185
|
+
// caller 通过 awaitPluginInit() 显式等待——生产 gateway 不调即原 fire-and-forget 语义
|
|
186
|
+
const topicLoadP = topicManager.load('main').catch((err) => {
|
|
176
187
|
logger.warn?.(`[coclaw] topic manager load failed: ${String(err?.message ?? err)}`);
|
|
177
188
|
});
|
|
178
|
-
chatHistoryManager.load('main')
|
|
179
|
-
|
|
180
|
-
|
|
189
|
+
const chatHistoryLoadP = chatHistoryManager.load('main')
|
|
190
|
+
.then(() => manager.listAllEntries('main'))
|
|
191
|
+
.then((entries) => chatHistoryManager.reconcileAll('main', entries))
|
|
192
|
+
.catch((err) => {
|
|
193
|
+
logger.warn?.(`[coclaw] chat history manager load/reconcile failed: ${String(err?.message ?? err)}`);
|
|
194
|
+
});
|
|
195
|
+
__pluginInitDone = Promise.all([topicLoadP, chatHistoryLoadP]);
|
|
181
196
|
|
|
182
197
|
// 追踪 chat 因 reset 产生的 session 流水。双源回调(hook + sessions.changed)共用 helper。
|
|
183
198
|
// recordSessionTransition 内部已 __reloadFromDisk + mutex,外层无需再 cache.has + load。
|
|
@@ -195,37 +210,32 @@ const plugin = {
|
|
|
195
210
|
// 该 helper 可直接作为 bridge.onSessionCreated 回调(签名兼容;缺失字段走兜底:
|
|
196
211
|
// agentId 走 parts[1] fallback、archivedSessionId 走 manager 翻 head 路径)。
|
|
197
212
|
async function handleSessionCreated({ agentId, sessionKey, sessionId, archivedSessionId }) {
|
|
198
|
-
|
|
213
|
+
// sessionKey / sessionId 非字符串时(上游 schema 异常)直接当 missing 处理,避免 split 抛 TypeError
|
|
214
|
+
// 或脏值落盘。manager 内部入口同样会校验(深度防御)。
|
|
215
|
+
if (typeof sessionKey !== 'string' || !sessionKey
|
|
216
|
+
|| typeof sessionId !== 'string' || !sessionId) {
|
|
199
217
|
// 早返值得警惕:上游事件 schema 异常,或 topic(无 sessionKey)误入双源链路。
|
|
200
218
|
// 打 log + remoteLog 让运维能定位事件源;不影响其他通道。
|
|
219
|
+
// 日志兼容性:缺失(null/undefined)→ 'null';其它非字符串类型 → 'invalid'。
|
|
220
|
+
const formatField = (v) => v == null
|
|
221
|
+
? 'null'
|
|
222
|
+
: (typeof v === 'string' ? v : 'invalid');
|
|
201
223
|
logger.warn?.(
|
|
202
|
-
`[coclaw] chat history early-return: missing sessionKey/sessionId`,
|
|
224
|
+
`[coclaw] chat history early-return: missing/invalid sessionKey/sessionId`,
|
|
203
225
|
);
|
|
204
226
|
remoteLog(
|
|
205
|
-
`chat-history.missing-keys sessionKey=${sessionKey
|
|
227
|
+
`chat-history.missing-keys sessionKey=${formatField(sessionKey)} sessionId=${formatField(sessionId)}`,
|
|
206
228
|
);
|
|
207
229
|
return;
|
|
208
230
|
}
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
// 改首段名),需复评本守卫。
|
|
215
|
-
const parts = sessionKey.split(':');
|
|
216
|
-
if (parts[0] === 'agent' && parts[2] === 'explicit') {
|
|
217
|
-
remoteLog(`chat-history.skip-explicit sessionKey=${sessionKey}`);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
// subagent 是 OpenClaw 程序自起的子任务 run(mode=run 一次性 / mode=session 持久绑定),
|
|
221
|
-
// 形态 `agent:<id>:subagent:<uuid>`,嵌套子代理为 `agent:<id>:subagent:<uuid>:subagent:<uuid2>`。
|
|
222
|
-
// 它不是人机对话流;父 agent 的 transcript 里已含子代理最终输出(作为 user message 回流),
|
|
223
|
-
// 因此不入 chat-history。
|
|
224
|
-
// 判定从 parts[2] 起找 'subagent' 段,避免 agentId 恰好叫 'subagent' 时误伤。
|
|
225
|
-
if (parts[0] === 'agent' && parts.indexOf('subagent', 2) >= 0) {
|
|
226
|
-
remoteLog(`chat-history.skip-subagent sessionKey=${sessionKey}`);
|
|
231
|
+
// sessionKey 路由分类(explicit / subagent / cron 跳过,详见 classifyChatHistorySessionKey)。
|
|
232
|
+
// 守卫必须与启动期对账 reconcileAll 内的守卫一致——共用同一 helper 避免侧门。
|
|
233
|
+
const cls = classifyChatHistorySessionKey(sessionKey);
|
|
234
|
+
if (!cls.ok) {
|
|
235
|
+
remoteLog(`chat-history.skip-${cls.reason} sessionKey=${sessionKey}`);
|
|
227
236
|
return;
|
|
228
237
|
}
|
|
238
|
+
const parts = sessionKey.split(':');
|
|
229
239
|
let resolvedAgentId = agentId;
|
|
230
240
|
if (!resolvedAgentId) {
|
|
231
241
|
resolvedAgentId = (parts[0] === 'agent' && parts[1]) ? parts[1] : 'main';
|
|
@@ -251,6 +261,18 @@ const plugin = {
|
|
|
251
261
|
archivedSessionId: event?.resumedFrom,
|
|
252
262
|
});
|
|
253
263
|
});
|
|
264
|
+
// cron 顶替主会话 sid 时不走 session_start hook、不广播 sessions.changed reason=create。
|
|
265
|
+
// cron_changed action=finished 是 cron 完成的可感知通道(v2026.4.29+ 支持,见
|
|
266
|
+
// openclaw-repo/src/plugins/hook-types.ts)。main 模式 cron 不带 sessionId → 早返天然过滤;
|
|
267
|
+
// 真触发顶替的"显式 session:<sk>"/"current" 路径 event.sessionId/sessionKey 都齐。
|
|
268
|
+
api.on('cron_changed', async (event) => {
|
|
269
|
+
if (event?.action !== 'finished') return;
|
|
270
|
+
if (!event?.sessionId || !event?.sessionKey) return;
|
|
271
|
+
await handleSessionCreated({
|
|
272
|
+
sessionKey: event.sessionKey,
|
|
273
|
+
sessionId: event.sessionId,
|
|
274
|
+
});
|
|
275
|
+
});
|
|
254
276
|
}
|
|
255
277
|
|
|
256
278
|
// bridge 启动/重启的闭包 helper:把 onSessionCreated 接到 handleSessionCreated。
|
package/package.json
CHANGED
|
@@ -12,6 +12,38 @@ function emptyStore() {
|
|
|
12
12
|
return { version: 1 };
|
|
13
13
|
}
|
|
14
14
|
|
|
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
|
+
|
|
15
47
|
/**
|
|
16
48
|
* Chat History 管理器:追踪 chat(sessionKey)下的 session 流水。
|
|
17
49
|
*
|
|
@@ -44,7 +76,8 @@ export class ChatHistoryManager {
|
|
|
44
76
|
constructor(opts = {}) {
|
|
45
77
|
this.__resolveSessionsDir = opts.resolveSessionsDir ?? agentSessionsDir;
|
|
46
78
|
this.__logger = opts.logger ?? console;
|
|
47
|
-
|
|
79
|
+
// readFile / writeJsonFile DI 注入点用于精细 mock;不注入时默认走 fs.readFile + atomicWriteJsonFile。
|
|
80
|
+
// 默认构造路径由"通过 setRuntime 端到端落盘"测试覆盖。
|
|
48
81
|
this.__readFile = opts.readFile ?? fs.readFile;
|
|
49
82
|
this.__writeJsonFile = opts.writeJsonFile ?? atomicWriteJsonFile;
|
|
50
83
|
// 内存缓存:agentId -> { version, [sessionKey]: [...] }
|
|
@@ -131,7 +164,44 @@ export class ChatHistoryManager {
|
|
|
131
164
|
|
|
132
165
|
async __persist(agentId) {
|
|
133
166
|
const store = this.__getStore(agentId);
|
|
134
|
-
|
|
167
|
+
// 自愈守卫:list[1..](非头位)若仍有未归档项视为脏数据(cron 顶替 / 旧版本写入 / 异常 race
|
|
168
|
+
// 残留),强制补 archivedAt。放在 __persist 内是为了覆盖所有写盘路径——新增写入点自动受护。
|
|
169
|
+
this.__sanitizeAllSessionKeys(store, agentId);
|
|
170
|
+
try {
|
|
171
|
+
await this.__writeJsonFile(this.__historyFilePath(agentId), store);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
// 写盘失败时清除该 agent 的内存 cache:caller 此前在 mutex 内已对 in-place list 做过
|
|
174
|
+
// unshift / splice / sanitize,这些修改不能随后被下一次"reload 命中 cache 不读盘"
|
|
175
|
+
// 的路径误持久化。删 cache 让下次操作的 __reloadFromDisk 必须重读磁盘(或 ENOENT 降级
|
|
176
|
+
// 为 empty store),保证内存与磁盘最终一致。
|
|
177
|
+
this.__cache.delete(agentId);
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 遍历 store 内每个 sessionKey 的 list,把 `list[1..]` 中 `!archivedAt` 的项强制
|
|
184
|
+
* 写上 `archivedAt = Date.now()`。每修一条同时打本地 warn + remoteLog 暴露信号。
|
|
185
|
+
* @param {object} store
|
|
186
|
+
* @param {string} agentId
|
|
187
|
+
*/
|
|
188
|
+
__sanitizeAllSessionKeys(store, agentId) {
|
|
189
|
+
if (!store || typeof store !== 'object') return;
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
for (const [sessionKey, list] of Object.entries(store)) {
|
|
192
|
+
if (!Array.isArray(list) || list.length <= 1) continue;
|
|
193
|
+
for (let i = 1; i < list.length; i++) {
|
|
194
|
+
const item = list[i];
|
|
195
|
+
if (!item || typeof item !== 'object' || item.archivedAt) continue;
|
|
196
|
+
item.archivedAt = now;
|
|
197
|
+
this.__logger.warn?.(
|
|
198
|
+
`[coclaw] chat-history sanitize: non-head unarchived entry coerced sessionKey=${sessionKey} sid=${item.sessionId}`,
|
|
199
|
+
);
|
|
200
|
+
remoteLog(
|
|
201
|
+
`chat-history.sanitize-coerce sessionKey=${sessionKey} sid=${item.sessionId} agentId=${agentId}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
135
205
|
}
|
|
136
206
|
|
|
137
207
|
/**
|
|
@@ -153,7 +223,10 @@ export class ChatHistoryManager {
|
|
|
153
223
|
* @param {{ agentId: string, sessionKey: string, currentSessionId: string, archivedSessionId?: string }} params
|
|
154
224
|
*/
|
|
155
225
|
async recordSessionTransition({ agentId, sessionKey, currentSessionId, archivedSessionId }) {
|
|
156
|
-
|
|
226
|
+
// 严格类型校验:上游 hook payload 异常时(非字符串 sessionId / sessionKey)静默拒绝,避免把脏值落盘。
|
|
227
|
+
if (typeof sessionKey !== 'string' || !sessionKey) return;
|
|
228
|
+
if (typeof currentSessionId !== 'string' || !currentSessionId) return;
|
|
229
|
+
if (typeof archivedSessionId !== 'string' || !archivedSessionId) archivedSessionId = undefined;
|
|
157
230
|
// 规范化:archivedSessionId 与 currentSessionId 相同属上游契约异常(resumedFrom 不应等于 sessionId),
|
|
158
231
|
// 丢弃避免在空 list 起手时写出"同 sid 既是头又是归档"的双份记录。打 remoteLog 暴露信号
|
|
159
232
|
// 让运维捕捉到上游可能的回归——只在真触发时打一次,正常路径噪声为零。
|
|
@@ -206,6 +279,42 @@ export class ChatHistoryManager {
|
|
|
206
279
|
});
|
|
207
280
|
}
|
|
208
281
|
|
|
282
|
+
/**
|
|
283
|
+
* 启动期对账:把 sessions.json 当前 entries 喂进来,对每条调
|
|
284
|
+
* recordSessionTransition;现有幂等 + sanitize 自动吞重复。用于覆盖 plugin/gateway
|
|
285
|
+
* 重启窗口期 cron 顶替导致的漏归档(cron_changed hook 是主通道,但 gateway 重启不回放
|
|
286
|
+
* 已完成的 cron event,靠启动对账兜底当前 sessions.json 的 head sid)。
|
|
287
|
+
*
|
|
288
|
+
* sessions.json 里可能含 isolated cron / subagent / explicit 形态的 sessionKey
|
|
289
|
+
* 条目(上游 run-session-state.ts:57-60 证实 isolated cron 写主 sessions.json),
|
|
290
|
+
* 用 classifyChatHistorySessionKey 守卫滤掉避免污染 chat-history。
|
|
291
|
+
*
|
|
292
|
+
* 单条 entry 抛错 try/catch 隔离,不阻塞后续;caller 已在外层 .catch 兜底。
|
|
293
|
+
*
|
|
294
|
+
* @param {string} agentId
|
|
295
|
+
* @param {{ sessionKey: string, sessionId: string }[]} entries
|
|
296
|
+
*/
|
|
297
|
+
async reconcileAll(agentId, entries) {
|
|
298
|
+
if (!Array.isArray(entries)) return;
|
|
299
|
+
for (const entry of entries) {
|
|
300
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
301
|
+
const { ok } = classifyChatHistorySessionKey(entry.sessionKey);
|
|
302
|
+
if (!ok) continue;
|
|
303
|
+
try {
|
|
304
|
+
await this.recordSessionTransition({
|
|
305
|
+
agentId,
|
|
306
|
+
sessionKey: entry.sessionKey,
|
|
307
|
+
currentSessionId: entry.sessionId,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
this.__logger.warn?.(
|
|
312
|
+
`[coclaw] chat-history reconcile entry failed sessionKey=${entry.sessionKey}: ${String(err?.message ?? err)}`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
209
318
|
/**
|
|
210
319
|
* 获取指定 chat 的 session 列表(原始数组:首位可能是未归档的当前活跃 session)。
|
|
211
320
|
* 每次调用从磁盘重载,确保跨模块实例一致性
|
|
@@ -796,8 +796,10 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
796
796
|
// done 已收到但 drain 未完成 — finishUpload 中会检测 dcClosed 并清理 tmp
|
|
797
797
|
if (!finishing) finishUpload();
|
|
798
798
|
} else {
|
|
799
|
+
// 等 ws 关闭后再 unlink——fopen 未完成时直接 safeUnlink 会扑空被吞,
|
|
800
|
+
// 随后 fopen 完成创建文件却没人清,留下孤儿 tmp
|
|
801
|
+
ws.on('close', () => safeUnlink(tmpPath));
|
|
799
802
|
ws.destroy();
|
|
800
|
-
safeUnlink(tmpPath);
|
|
801
803
|
const elapsed = Date.now() - startTime;
|
|
802
804
|
remoteLog(`file.up.fail ${logTag}id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
803
805
|
log.warn?.(`[coclaw/file] [${connId ?? '?'}] up.fail id=${transferId} reason=dc-closed received=${receivedBytes}/${declaredSize} elapsed=${elapsed}ms bp=${wsBackpressureCount}`);
|
|
@@ -810,8 +812,9 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
810
812
|
wsError = true;
|
|
811
813
|
draining = false;
|
|
812
814
|
pendingQueue.length = 0;
|
|
815
|
+
// 同上:等 ws 关闭后再 unlink,避开 fopen-vs-unlink race
|
|
816
|
+
ws.on('close', () => safeUnlink(tmpPath));
|
|
813
817
|
ws.destroy();
|
|
814
|
-
safeUnlink(tmpPath);
|
|
815
818
|
const elapsed = Date.now() - startTime;
|
|
816
819
|
/* c8 ignore next -- ?? fallback for non-Error throw */
|
|
817
820
|
const errMsg = err?.message ?? String(err);
|
|
@@ -17,6 +17,9 @@ import { buildModelDefaultHandlers } from './handlers.js';
|
|
|
17
17
|
import { mainAgentDir } from '../claw-paths.js';
|
|
18
18
|
import { getClawConfig } from '../claw-config.js';
|
|
19
19
|
|
|
20
|
+
// link-UNSAFE:模块级 dedup 缓存。`--link` 模式下两实例各自 lazy-load 一次
|
|
21
|
+
// SDK(结果一致、运行无伤但去重失效)。当前仅 RPC handler 走该路径——
|
|
22
|
+
// 不要在 hook 回调里访问本模块的 export。详见 docs/module-boundaries.md。
|
|
20
23
|
let _configMutationP;
|
|
21
24
|
let _modelsP;
|
|
22
25
|
let _providerAuthP;
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
import { buildProviderAuthHandlers } from './handlers.js';
|
|
11
11
|
import { mainAgentDir } from '../claw-paths.js';
|
|
12
12
|
|
|
13
|
+
// link-UNSAFE:模块级 dedup 缓存。`--link` 模式下两实例各自 lazy-load 一次
|
|
14
|
+
// SDK(结果一致、运行无伤但去重失效)。当前仅 RPC handler 走该路径——
|
|
15
|
+
// 不要在 hook 回调里访问本模块的 export。详见 docs/module-boundaries.md。
|
|
13
16
|
let _sdkPromise;
|
|
14
17
|
|
|
15
18
|
// 默认 loader 仅作 fallback:生产路径必须由入口(plugins/openclaw/index.js)注入 loadSdk,
|
package/src/realtime-bridge.js
CHANGED
|
@@ -1717,6 +1717,13 @@ export class RealtimeBridge {
|
|
|
1717
1717
|
// restart(opts) — 无论当前状态,确保 bridge 以给定 opts 运行(幂等)
|
|
1718
1718
|
// stop() — 停止并销毁 singleton
|
|
1719
1719
|
// 调用方无需感知 singleton 是否为 null,选"要运行"或"要停止"即可。
|
|
1720
|
+
//
|
|
1721
|
+
// link-UNSAFE 警告:以下 singleton 状态与所有读 singleton 的 export(restart /
|
|
1722
|
+
// stop / waitForSessionsReady / ensureAgentSession / gatewayAgentRpc /
|
|
1723
|
+
// broadcastPluginEvent)在 `--link` 安装模式下,hook 路径与 RPC 路径可能拿
|
|
1724
|
+
// 到不同 ESM 模块实例 → 两份独立 singleton。**不要在 api.on(...) hook 回调内
|
|
1725
|
+
// 调用本文件的任何 export**。hook 内若需触发 bridge 副作用,请走 RPC
|
|
1726
|
+
// (api.callGatewayMethod('coclaw.xxx', ...))。详见 docs/module-boundaries.md。
|
|
1720
1727
|
|
|
1721
1728
|
let singleton = null;
|
|
1722
1729
|
|
package/src/remote-log.js
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 将诊断日志缓冲并通过 WS 通道推送到 CoClaw server。
|
|
5
5
|
* 单例模式——各模块直接 import { remoteLog } 使用。
|
|
6
|
+
*
|
|
7
|
+
* link-UNSAFE:buffer / sender / flushing 都是模块级状态。`--link` 模式下
|
|
8
|
+
* hook 实例可能从未被 setSender 注入 → hook 路径调 remoteLog 会落到没装
|
|
9
|
+
* sender 的实例 → 静默丢日志。**不要在 hook 回调内调用 remoteLog**;
|
|
10
|
+
* 要发就用 hook 入参里的 logger。详见 docs/module-boundaries.md。
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
13
|
const MAX_BUFFER = 1000;
|
package/src/runtime.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
// runtime 单例:在 plugin 模式下由 register()
|
|
1
|
+
// runtime 单例:在 plugin 模式下由 register() 注入。
|
|
2
|
+
// link-UNSAFE:`--link` 安装模式下 hook 与 RPC handler 可能跑在不同 ESM 实例 →
|
|
3
|
+
// 两份独立 runtime;hook 实例从未被 setRuntime → getRuntime() 返回 null。
|
|
4
|
+
// **不要在 hook 回调内调用 getRuntime()**——hook 入参 `api` 已带 runtime。
|
|
5
|
+
// 详见 docs/module-boundaries.md。
|
|
2
6
|
let runtime = null;
|
|
3
7
|
|
|
4
8
|
export function setRuntime(rt) {
|
|
@@ -37,6 +37,10 @@ async function safeReaddir(dir) {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// OpenClaw ISO 时间戳:YYYY-MM-DDTHH-MM-SS[.sss]Z(与 artifacts.ts ARCHIVE_TIMESTAMP_RE 对齐:毫秒可选)
|
|
41
|
+
// 用于过滤 rsync/备份等场景带入的非法后缀(如 .jsonl.reset.<ts>.bak)
|
|
42
|
+
const ARCHIVE_TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:\.\d{3})?Z$/;
|
|
43
|
+
|
|
40
44
|
async function safeAccess(filePath) {
|
|
41
45
|
try { await fsp.access(filePath); return true; }
|
|
42
46
|
catch (err) {
|
|
@@ -79,28 +83,46 @@ function shouldReplaceByPriority(current, next) {
|
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
export function createSessionManager(options = {}) {
|
|
82
|
-
/* c8 ignore next */
|
|
83
86
|
const logger = options.logger ?? console;
|
|
84
87
|
const resolveSessionsDir = options.resolveSessionsDir ?? agentSessionsDir;
|
|
85
88
|
const resolveStorePath = options.resolveStorePath ?? sessionStorePath;
|
|
86
89
|
const resolveTranscriptPath = options.resolveTranscriptPath ?? sessionTranscriptPath;
|
|
87
90
|
|
|
88
91
|
function sessionsDir(agentId = 'main') {
|
|
89
|
-
/* c8 ignore next */
|
|
90
92
|
const aid = typeof agentId === 'string' && agentId.trim() ? agentId.trim() : 'main';
|
|
91
93
|
return resolveSessionsDir(aid);
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
async function readIndex(agentId = 'main') {
|
|
95
|
-
/* c8 ignore next */
|
|
96
97
|
const aid = typeof agentId === 'string' && agentId.trim() ? agentId.trim() : 'main';
|
|
97
98
|
const file = resolveStorePath(aid);
|
|
98
99
|
const data = await readJsonSafe(file, {});
|
|
99
|
-
|
|
100
|
+
// readJsonSafe 抛错时返回 {}(已是 object),此处兜底 sessions.json 内容是合法 JSON
|
|
101
|
+
// 但非 object(number / string / boolean / null / array 由下游 listAllEntries 单独处理)
|
|
100
102
|
if (!data || typeof data !== 'object') return {};
|
|
101
103
|
return data;
|
|
102
104
|
}
|
|
103
105
|
|
|
106
|
+
// 启动期对账用:直接读 sessions.json 把当前所有 sessionKey -> sessionId 摘出来。
|
|
107
|
+
// 不扫 transcript 文件 / 不做 stat,因此远比 listAll 轻量;缺/坏文件返回空数组。
|
|
108
|
+
async function listAllEntries(agentId = 'main') {
|
|
109
|
+
const idx = await readIndex(agentId);
|
|
110
|
+
// sessions.json 异常被写成数组时,Object.entries 会生成 "0"/"1" 假键,
|
|
111
|
+
// 把它们当 sessionKey 喂下游会污染。直接拒绝数组形态,同时打 warn 暴露异常。
|
|
112
|
+
if (Array.isArray(idx)) {
|
|
113
|
+
logger.warn?.(`[session-manager] sessions.json for agent=${agentId} is an array, expected object — returning empty entries`);
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const [sessionKey, item] of Object.entries(idx)) {
|
|
118
|
+
const sid = item?.sessionId;
|
|
119
|
+
if (typeof sessionKey !== 'string' || !sessionKey) continue;
|
|
120
|
+
if (typeof sid !== 'string' || !sid) continue;
|
|
121
|
+
out.push({ sessionKey, sessionId: sid });
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
104
126
|
async function listAll(params = {}) {
|
|
105
127
|
const agentId = typeof params.agentId === 'string' && params.agentId.trim() ? params.agentId.trim() : 'main';
|
|
106
128
|
const limit = clamp(params.limit, 1, 200, 50);
|
|
@@ -152,7 +174,6 @@ export function createSessionManager(options = {}) {
|
|
|
152
174
|
// 补充 sessions.json 中有索引但无 transcript 文件的 session(如 reset 后未对话、新建 session)
|
|
153
175
|
for (const [sessionKey, entry] of Object.entries(index)) {
|
|
154
176
|
const sid = entry?.sessionId;
|
|
155
|
-
/* c8 ignore next -- !sid 防御性检查 */
|
|
156
177
|
if (!sid || grouped.has(sid)) continue;
|
|
157
178
|
grouped.set(sid, {
|
|
158
179
|
sessionId: sid,
|
|
@@ -160,7 +181,7 @@ export function createSessionManager(options = {}) {
|
|
|
160
181
|
indexed: true,
|
|
161
182
|
archiveType: 'live',
|
|
162
183
|
fileName: null,
|
|
163
|
-
|
|
184
|
+
// entry.updatedAt 缺失或非数字时回落 0;UI 端按 updatedAt 排序时无 transcript 项排到末位
|
|
164
185
|
updatedAt: entry.updatedAt ?? 0,
|
|
165
186
|
size: 0,
|
|
166
187
|
});
|
|
@@ -182,16 +203,24 @@ export function createSessionManager(options = {}) {
|
|
|
182
203
|
|
|
183
204
|
async function resolveTranscriptFile(agentId, sessionId) {
|
|
184
205
|
const dir = sessionsDir(agentId);
|
|
185
|
-
// live 文件优先:同一 sessionId 可能同时存在 live 和 reset
|
|
206
|
+
// live 文件优先:同一 sessionId 可能同时存在 live 和 reset/deleted 归档
|
|
186
207
|
// (OpenClaw reset 后复用 sessionId),live 代表当前活跃 transcript
|
|
187
208
|
const livePath = resolveTranscriptPath(sessionId, agentId);
|
|
188
209
|
if (await safeAccess(livePath)) return livePath;
|
|
189
210
|
|
|
190
|
-
|
|
211
|
+
// .reset.<ts> 与 .deleted.<ts> 都代表 session 的最终态,合并扫描按时间戳取最新
|
|
212
|
+
// 时间戳是 OpenClaw ISO YYYY-MM-DDTHH-MM-SS[.sss]Z(artifacts.ts 锁住毫秒可选),字典序 = 时间序
|
|
191
213
|
const resetPrefix = `${sessionId}.jsonl.reset.`;
|
|
192
|
-
const
|
|
214
|
+
const deletedPrefix = `${sessionId}.jsonl.deleted.`;
|
|
215
|
+
const files = await safeReaddir(dir);
|
|
216
|
+
const candidates = [];
|
|
193
217
|
for (const name of files) {
|
|
194
|
-
|
|
218
|
+
let archiveStamp;
|
|
219
|
+
if (name.startsWith(resetPrefix)) archiveStamp = name.slice(resetPrefix.length);
|
|
220
|
+
else if (name.startsWith(deletedPrefix)) archiveStamp = name.slice(deletedPrefix.length);
|
|
221
|
+
else continue;
|
|
222
|
+
// 严格 ISO 时间戳校验,过滤 .jsonl.reset.<ts>.bak 等带尾巴的备份/同步残留
|
|
223
|
+
if (!ARCHIVE_TS_RE.test(archiveStamp)) continue;
|
|
195
224
|
const full = nodePath.join(dir, name);
|
|
196
225
|
let stat;
|
|
197
226
|
try { stat = await fsp.stat(full); }
|
|
@@ -201,21 +230,21 @@ export function createSessionManager(options = {}) {
|
|
|
201
230
|
throw err;
|
|
202
231
|
}
|
|
203
232
|
/* c8 ignore stop */
|
|
204
|
-
|
|
233
|
+
candidates.push({
|
|
205
234
|
path: full,
|
|
206
|
-
archiveStamp
|
|
235
|
+
archiveStamp,
|
|
207
236
|
updatedAt: stat.mtimeMs,
|
|
208
237
|
});
|
|
209
238
|
}
|
|
210
|
-
|
|
239
|
+
candidates.sort((a, b) => {
|
|
211
240
|
if (a.archiveStamp !== b.archiveStamp) {
|
|
212
241
|
return b.archiveStamp.localeCompare(a.archiveStamp);
|
|
213
242
|
}
|
|
214
|
-
/* c8 ignore next --
|
|
243
|
+
/* c8 ignore next -- 同 sessionId 同 archiveStamp 的归档实测 0 case */
|
|
215
244
|
return b.updatedAt - a.updatedAt;
|
|
216
245
|
});
|
|
217
|
-
if (
|
|
218
|
-
return
|
|
246
|
+
if (candidates.length > 0) {
|
|
247
|
+
return candidates[0].path;
|
|
219
248
|
}
|
|
220
249
|
return null;
|
|
221
250
|
}
|
|
@@ -252,12 +281,10 @@ export function createSessionManager(options = {}) {
|
|
|
252
281
|
all.push(JSON.parse(line));
|
|
253
282
|
}
|
|
254
283
|
catch (err) {
|
|
255
|
-
/* c8 ignore next -- ?./?? fallback */
|
|
256
284
|
logger.warn?.(`[session-manager] bad json line skipped: ${String(err?.message ?? err)}`);
|
|
257
285
|
}
|
|
258
286
|
}
|
|
259
287
|
const messages = all.slice(cursor, cursor + limit);
|
|
260
|
-
/* c8 ignore next */
|
|
261
288
|
const nextCursor = cursor + limit < all.length ? String(cursor + limit) : null;
|
|
262
289
|
return {
|
|
263
290
|
agentId,
|
|
@@ -272,6 +299,7 @@ export function createSessionManager(options = {}) {
|
|
|
272
299
|
/**
|
|
273
300
|
* 按 sessionId 获取消息,返回完整 JSONL 行级结构。
|
|
274
301
|
* 只返回 type==="message" 且有合法 message.role 的行。
|
|
302
|
+
* limit 语义:不传/null/非 number/NaN/Infinity/<1 → 返回全部;>=1 的有限 number → 取最后 Math.trunc(limit) 条。无默认/最大值。
|
|
275
303
|
* @param {{ sessionId: string, agentId?: string, limit?: number }} params
|
|
276
304
|
* @returns {Promise<{ messages: object[] }>}
|
|
277
305
|
*/
|
|
@@ -279,7 +307,10 @@ export function createSessionManager(options = {}) {
|
|
|
279
307
|
const agentId = typeof params.agentId === 'string' && params.agentId.trim() ? params.agentId.trim() : 'main';
|
|
280
308
|
const sessionId = typeof params.sessionId === 'string' ? params.sessionId.trim() : '';
|
|
281
309
|
if (!sessionId) throw new Error('sessionId required');
|
|
282
|
-
|
|
310
|
+
// limit 类型严格:只接受 number 且 >= 1。string/bool/array 走非 number 分支被拒,
|
|
311
|
+
// 0 / 负数 / NaN / Infinity / (0,1) 区间也都视为"不限"——(0,1) 不视为不限的话 Math.trunc 后会变 0、slice(-0) 退化为全部
|
|
312
|
+
const useLimit = typeof params.limit === 'number' && Number.isFinite(params.limit) && params.limit >= 1;
|
|
313
|
+
const limitNum = useLimit ? Math.trunc(params.limit) : 0;
|
|
283
314
|
const file = await resolveTranscriptFile(agentId, sessionId);
|
|
284
315
|
if (!file) {
|
|
285
316
|
return { messages: [] };
|
|
@@ -296,14 +327,12 @@ export function createSessionManager(options = {}) {
|
|
|
296
327
|
messages.push(row);
|
|
297
328
|
}
|
|
298
329
|
catch (err) {
|
|
299
|
-
/* c8 ignore next -- ?./?? fallback */
|
|
300
330
|
logger.warn?.(`[session-manager] bad json line skipped: ${String(err?.message ?? err)}`);
|
|
301
331
|
}
|
|
302
332
|
}
|
|
303
|
-
|
|
304
|
-
const sliced = messages.length > limit ? messages.slice(-limit) : messages;
|
|
333
|
+
const sliced = (useLimit && messages.length > limitNum) ? messages.slice(-limitNum) : messages;
|
|
305
334
|
return { messages: sliced };
|
|
306
335
|
}
|
|
307
336
|
|
|
308
|
-
return { listAll, get, getById };
|
|
337
|
+
return { listAll, listAllEntries, get, getById };
|
|
309
338
|
}
|
|
@@ -996,9 +996,10 @@ export class WebRtcPeer {
|
|
|
996
996
|
// 'fbq' 模式下若 queueDir 不可用则降级到 mem,避免阻塞装配。
|
|
997
997
|
// 同 connId race 隔离(决策 4):FBQ id 加唯一后缀 ${connId}-${ts}-${nonce},
|
|
998
998
|
// 让新旧实例文件名物理不同,destroy/init 期间互不踩踏。MemoryQueue 不碰 fs,无此需求。
|
|
999
|
-
// connId
|
|
1000
|
-
//
|
|
1001
|
-
// queue
|
|
999
|
+
// connId 字符集契约:FBQ / MemoryQueue 共用 `^[A-Za-z0-9._-]+$` 校验。
|
|
1000
|
+
// 上游 server 分配 connId 形如 `c_<digits>` 符合契约;若 server 引入特殊字符,
|
|
1001
|
+
// queue 构造抛 TypeError,由 __setupDataChannel 的 .catch 兜底 warn。
|
|
1002
|
+
// 完整契约 / 违反后果 / 修复方向见 docs/connid-contract.md
|
|
1002
1003
|
const useFbq = this.__rpcQueueImpl === 'fbq' && !!this.__queueDir;
|
|
1003
1004
|
const fbqFallback = !useFbq && this.__rpcQueueImpl === 'fbq';
|
|
1004
1005
|
const queue = useFbq
|