@coclaw/openclaw-coclaw 0.22.1 → 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 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';
@@ -175,9 +175,12 @@ const plugin = {
175
175
  topicManager.load('main').catch((err) => {
176
176
  logger.warn?.(`[coclaw] topic manager load failed: ${String(err?.message ?? err)}`);
177
177
  });
178
- chatHistoryManager.load('main').catch((err) => {
179
- logger.warn?.(`[coclaw] chat history manager load failed: ${String(err?.message ?? err)}`);
180
- });
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
+ });
181
184
 
182
185
  // 追踪 chat 因 reset 产生的 session 流水。双源回调(hook + sessions.changed)共用 helper。
183
186
  // recordSessionTransition 内部已 __reloadFromDisk + mutex,外层无需再 cache.has + load。
@@ -195,37 +198,32 @@ const plugin = {
195
198
  // 该 helper 可直接作为 bridge.onSessionCreated 回调(签名兼容;缺失字段走兜底:
196
199
  // agentId 走 parts[1] fallback、archivedSessionId 走 manager 翻 head 路径)。
197
200
  async function handleSessionCreated({ agentId, sessionKey, sessionId, archivedSessionId }) {
198
- if (!sessionKey || !sessionId) {
201
+ // sessionKey / sessionId 非字符串时(上游 schema 异常)直接当 missing 处理,避免 split 抛 TypeError
202
+ // 或脏值落盘。manager 内部入口同样会校验(深度防御)。
203
+ if (typeof sessionKey !== 'string' || !sessionKey
204
+ || typeof sessionId !== 'string' || !sessionId) {
199
205
  // 早返值得警惕:上游事件 schema 异常,或 topic(无 sessionKey)误入双源链路。
200
206
  // 打 log + remoteLog 让运维能定位事件源;不影响其他通道。
207
+ // 日志兼容性:缺失(null/undefined)→ 'null';其它非字符串类型 → 'invalid'。
208
+ const formatField = (v) => v == null
209
+ ? 'null'
210
+ : (typeof v === 'string' ? v : 'invalid');
201
211
  logger.warn?.(
202
- `[coclaw] chat history early-return: missing sessionKey/sessionId`,
212
+ `[coclaw] chat history early-return: missing/invalid sessionKey/sessionId`,
203
213
  );
204
214
  remoteLog(
205
- `chat-history.missing-keys sessionKey=${sessionKey ?? 'null'} sessionId=${sessionId ?? 'null'}`,
215
+ `chat-history.missing-keys sessionKey=${formatField(sessionKey)} sessionId=${formatField(sessionId)}`,
206
216
  );
207
217
  return;
208
218
  }
209
- // topic 上游伪造的 explicit fake sessionKey(形态 `agent:<agentId>:explicit:<sid>`)
210
- // 不属于 chat 流水范畴:CoClaw 自管 topic 元信息,不应进 chat-history 桶。
211
- // 当前 F1 实验已证明该路径不触发本回调,此守卫属防御性兜底。
212
- // 前提假设:(a) sessionKey 首段是 `agent`;(b) `explicit` 占第 3 段(即 parts[2],
213
- // 0-indexed 数)。两条同时成立才命中本守卫;若上游 schema 演进(如挪位置 / 增前缀 /
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}`);
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}`);
227
224
  return;
228
225
  }
226
+ const parts = sessionKey.split(':');
229
227
  let resolvedAgentId = agentId;
230
228
  if (!resolvedAgentId) {
231
229
  resolvedAgentId = (parts[0] === 'agent' && parts[1]) ? parts[1] : 'main';
@@ -251,6 +249,18 @@ const plugin = {
251
249
  archivedSessionId: event?.resumedFrom,
252
250
  });
253
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
+ });
254
264
  }
255
265
 
256
266
  // bridge 启动/重启的闭包 helper:把 onSessionCreated 接到 handleSessionCreated。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.22.1",
3
+ "version": "0.22.2",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw plugin for remote chat over WebRTC. Run `openclaw coclaw enroll` after install.",
@@ -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
  *
@@ -131,7 +163,44 @@ export class ChatHistoryManager {
131
163
 
132
164
  async __persist(agentId) {
133
165
  const store = this.__getStore(agentId);
134
- await this.__writeJsonFile(this.__historyFilePath(agentId), store);
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
+ }
179
+ }
180
+
181
+ /**
182
+ * 遍历 store 内每个 sessionKey 的 list,把 `list[1..]` 中 `!archivedAt` 的项强制
183
+ * 写上 `archivedAt = Date.now()`。每修一条同时打本地 warn + remoteLog 暴露信号。
184
+ * @param {object} store
185
+ * @param {string} agentId
186
+ */
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
+ }
135
204
  }
136
205
 
137
206
  /**
@@ -153,7 +222,10 @@ export class ChatHistoryManager {
153
222
  * @param {{ agentId: string, sessionKey: string, currentSessionId: string, archivedSessionId?: string }} params
154
223
  */
155
224
  async recordSessionTransition({ agentId, sessionKey, currentSessionId, archivedSessionId }) {
156
- if (!sessionKey || !currentSessionId) return;
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;
157
229
  // 规范化:archivedSessionId 与 currentSessionId 相同属上游契约异常(resumedFrom 不应等于 sessionId),
158
230
  // 丢弃避免在空 list 起手时写出"同 sid 既是头又是归档"的双份记录。打 remoteLog 暴露信号
159
231
  // 让运维捕捉到上游可能的回归——只在真触发时打一次,正常路径噪声为零。
@@ -206,6 +278,42 @@ export class ChatHistoryManager {
206
278
  });
207
279
  }
208
280
 
281
+ /**
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
+
209
317
  /**
210
318
  * 获取指定 chat 的 session 列表(原始数组:首位可能是未归档的当前活跃 session)。
211
319
  * 每次调用从磁盘重载,确保跨模块实例一致性
@@ -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) {
@@ -101,6 +105,26 @@ export function createSessionManager(options = {}) {
101
105
  return data;
102
106
  }
103
107
 
108
+ // 启动期对账用:直接读 sessions.json 把当前所有 sessionKey -> sessionId 摘出来。
109
+ // 不扫 transcript 文件 / 不做 stat,因此远比 listAll 轻量;缺/坏文件返回空数组。
110
+ async function listAllEntries(agentId = 'main') {
111
+ const idx = await readIndex(agentId);
112
+ // sessions.json 异常被写成数组时,Object.entries 会生成 "0"/"1" 假键,
113
+ // 把它们当 sessionKey 喂下游会污染。直接拒绝数组形态,同时打 warn 暴露异常。
114
+ if (Array.isArray(idx)) {
115
+ logger.warn?.(`[session-manager] sessions.json for agent=${agentId} is an array, expected object — returning empty entries`);
116
+ return [];
117
+ }
118
+ const out = [];
119
+ for (const [sessionKey, item] of Object.entries(idx)) {
120
+ const sid = item?.sessionId;
121
+ if (typeof sessionKey !== 'string' || !sessionKey) continue;
122
+ if (typeof sid !== 'string' || !sid) continue;
123
+ out.push({ sessionKey, sessionId: sid });
124
+ }
125
+ return out;
126
+ }
127
+
104
128
  async function listAll(params = {}) {
105
129
  const agentId = typeof params.agentId === 'string' && params.agentId.trim() ? params.agentId.trim() : 'main';
106
130
  const limit = clamp(params.limit, 1, 200, 50);
@@ -182,16 +206,24 @@ export function createSessionManager(options = {}) {
182
206
 
183
207
  async function resolveTranscriptFile(agentId, sessionId) {
184
208
  const dir = sessionsDir(agentId);
185
- // live 文件优先:同一 sessionId 可能同时存在 live 和 reset 文件
209
+ // live 文件优先:同一 sessionId 可能同时存在 live 和 reset/deleted 归档
186
210
  // (OpenClaw reset 后复用 sessionId),live 代表当前活跃 transcript
187
211
  const livePath = resolveTranscriptPath(sessionId, agentId);
188
212
  if (await safeAccess(livePath)) return livePath;
189
213
 
190
- const files = await safeReaddir(dir);
214
+ // .reset.<ts> .deleted.<ts> 都代表 session 的最终态,合并扫描按时间戳取最新
215
+ // 时间戳是 OpenClaw ISO YYYY-MM-DDTHH-MM-SS[.sss]Z(artifacts.ts 锁住毫秒可选),字典序 = 时间序
191
216
  const resetPrefix = `${sessionId}.jsonl.reset.`;
192
- const resetCandidates = [];
217
+ const deletedPrefix = `${sessionId}.jsonl.deleted.`;
218
+ const files = await safeReaddir(dir);
219
+ const candidates = [];
193
220
  for (const name of files) {
194
- if (!name.startsWith(resetPrefix)) continue;
221
+ let archiveStamp;
222
+ if (name.startsWith(resetPrefix)) archiveStamp = name.slice(resetPrefix.length);
223
+ else if (name.startsWith(deletedPrefix)) archiveStamp = name.slice(deletedPrefix.length);
224
+ else continue;
225
+ // 严格 ISO 时间戳校验,过滤 .jsonl.reset.<ts>.bak 等带尾巴的备份/同步残留
226
+ if (!ARCHIVE_TS_RE.test(archiveStamp)) continue;
195
227
  const full = nodePath.join(dir, name);
196
228
  let stat;
197
229
  try { stat = await fsp.stat(full); }
@@ -201,21 +233,21 @@ export function createSessionManager(options = {}) {
201
233
  throw err;
202
234
  }
203
235
  /* c8 ignore stop */
204
- resetCandidates.push({
236
+ candidates.push({
205
237
  path: full,
206
- archiveStamp: name.slice(resetPrefix.length),
238
+ archiveStamp,
207
239
  updatedAt: stat.mtimeMs,
208
240
  });
209
241
  }
210
- resetCandidates.sort((a, b) => {
242
+ candidates.sort((a, b) => {
211
243
  if (a.archiveStamp !== b.archiveStamp) {
212
244
  return b.archiveStamp.localeCompare(a.archiveStamp);
213
245
  }
214
- /* c8 ignore next -- 同一 sessionId reset 文件不会有相同 archiveStamp */
246
+ /* c8 ignore next -- sessionId archiveStamp 的归档实测 0 case */
215
247
  return b.updatedAt - a.updatedAt;
216
248
  });
217
- if (resetCandidates.length > 0) {
218
- return resetCandidates[0].path;
249
+ if (candidates.length > 0) {
250
+ return candidates[0].path;
219
251
  }
220
252
  return null;
221
253
  }
@@ -272,6 +304,7 @@ export function createSessionManager(options = {}) {
272
304
  /**
273
305
  * 按 sessionId 获取消息,返回完整 JSONL 行级结构。
274
306
  * 只返回 type==="message" 且有合法 message.role 的行。
307
+ * limit 语义:不传/null/非 number/NaN/Infinity/<1 → 返回全部;>=1 的有限 number → 取最后 Math.trunc(limit) 条。无默认/最大值。
275
308
  * @param {{ sessionId: string, agentId?: string, limit?: number }} params
276
309
  * @returns {Promise<{ messages: object[] }>}
277
310
  */
@@ -279,7 +312,10 @@ export function createSessionManager(options = {}) {
279
312
  const agentId = typeof params.agentId === 'string' && params.agentId.trim() ? params.agentId.trim() : 'main';
280
313
  const sessionId = typeof params.sessionId === 'string' ? params.sessionId.trim() : '';
281
314
  if (!sessionId) throw new Error('sessionId required');
282
- const limit = clamp(params.limit, 1, 500, 500);
315
+ // limit 类型严格:只接受 number 且 >= 1。string/bool/array 走非 number 分支被拒,
316
+ // 0 / 负数 / NaN / Infinity / (0,1) 区间也都视为"不限"——(0,1) 不视为不限的话 Math.trunc 后会变 0、slice(-0) 退化为全部
317
+ const useLimit = typeof params.limit === 'number' && Number.isFinite(params.limit) && params.limit >= 1;
318
+ const limitNum = useLimit ? Math.trunc(params.limit) : 0;
283
319
  const file = await resolveTranscriptFile(agentId, sessionId);
284
320
  if (!file) {
285
321
  return { messages: [] };
@@ -300,10 +336,9 @@ export function createSessionManager(options = {}) {
300
336
  logger.warn?.(`[session-manager] bad json line skipped: ${String(err?.message ?? err)}`);
301
337
  }
302
338
  }
303
- // 取最后 limit
304
- const sliced = messages.length > limit ? messages.slice(-limit) : messages;
339
+ const sliced = (useLimit && messages.length > limitNum) ? messages.slice(-limitNum) : messages;
305
340
  return { messages: sliced };
306
341
  }
307
342
 
308
- return { listAll, get, getById };
343
+ return { listAll, listAllEntries, get, getById };
309
344
  }