@coclaw/openclaw-coclaw 0.21.4 → 0.21.5

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/README.md CHANGED
@@ -92,7 +92,7 @@ pnpm run release:versions # 显示所有已发布版本
92
92
  | `coclaw.files.delete` | 删除工作区文件/目录 |
93
93
  | `coclaw.files.mkdir` | 创建工作区目录 |
94
94
  | `coclaw.files.create` | 创建空文件 |
95
- | `nativeui.sessions.listAll` | 列出所有 session(分页 + 标题推导) |
95
+ | `nativeui.sessions.listAll` | 列出所有 session(分页) |
96
96
  | `nativeui.sessions.get` | 获取 session 原始 JSONL 行(分页) |
97
97
 
98
98
  ## Gateway Services
package/index.js CHANGED
@@ -363,21 +363,21 @@ const plugin = {
363
363
  // best-effort ensure:失败不阻断 listAll
364
364
  try { await ensureAgentSession(agentId); }
365
365
  catch {}
366
- respond(true, manager.listAll(params ?? {}));
366
+ respond(true, await manager.listAll(params ?? {}));
367
367
  }
368
368
  catch (err) {
369
369
  respondError(respond, err);
370
370
  }
371
371
  });
372
372
 
373
- api.registerGatewayMethod('nativeui.sessions.get', ({ params, respond }) => {
373
+ api.registerGatewayMethod('nativeui.sessions.get', async ({ params, respond }) => {
374
374
  try {
375
375
  const sessionId = params?.sessionId;
376
376
  if (typeof sessionId !== 'string' || sessionId.trim().length === 0) {
377
377
  respondInvalid(respond, 'sessionId required');
378
378
  return;
379
379
  }
380
- respond(true, manager.get(params ?? {}));
380
+ respond(true, await manager.get(params ?? {}));
381
381
  }
382
382
  catch (err) {
383
383
  respondError(respond, err);
@@ -474,7 +474,7 @@ const plugin = {
474
474
  }
475
475
  });
476
476
 
477
- api.registerGatewayMethod('coclaw.topics.getHistory', ({ params, respond }) => {
477
+ api.registerGatewayMethod('coclaw.topics.getHistory', async ({ params, respond }) => {
478
478
  try {
479
479
  const topicId = params?.topicId?.trim?.();
480
480
  if (!topicId) {
@@ -483,7 +483,7 @@ const plugin = {
483
483
  }
484
484
  const agentId = params?.agentId?.trim?.() || 'main';
485
485
  // 直接复用 session-manager 的 get(),topicId 即 sessionId
486
- respond(true, manager.get({ agentId, sessionId: topicId }));
486
+ respond(true, await manager.get({ agentId, sessionId: topicId }));
487
487
  }
488
488
  catch (err) {
489
489
  respondError(respond, err);
@@ -577,7 +577,7 @@ const plugin = {
577
577
  });
578
578
 
579
579
  // TODO: coclaw.topics.getHistory 未来可废弃,UI 改用 coclaw.sessions.getById
580
- api.registerGatewayMethod('coclaw.sessions.getById', ({ params, respond }) => {
580
+ api.registerGatewayMethod('coclaw.sessions.getById', async ({ params, respond }) => {
581
581
  try {
582
582
  const sessionId = params?.sessionId?.trim?.();
583
583
  if (!sessionId) {
@@ -586,7 +586,7 @@ const plugin = {
586
586
  }
587
587
  const agentId = params?.agentId?.trim?.() || 'main';
588
588
  const limit = params?.limit;
589
- respond(true, manager.getById({ agentId, sessionId, limit }));
589
+ respond(true, await manager.getById({ agentId, sessionId, limit }));
590
590
  }
591
591
  catch (err) {
592
592
  respondError(respond, err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.21.4",
3
+ "version": "0.21.5",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat. Run `openclaw coclaw enroll` after install to connect to CoClaw.",
@@ -131,6 +131,14 @@ export async function writeUpgradeLock(pid) {
131
131
  * `upgrade.ledger-read-failed` / `upgrade.ledger-parse-failed`),避免运维只
132
132
  * 看到 start() 那条 "Skipping: not an npm-installed plugin" 时误判方向。
133
133
  *
134
+ * 注:内部 `readFileSync` 为同步 IO,**有意保留**——只在升级周期决策时读一次
135
+ * 账本(整个进程生命周期通常一锤子)。改 async 必须沿 `shouldSkipAutoUpgrade`
136
+ * 等调用链向上传播,收益不抵成本。
137
+ *
138
+ * 另:OpenClaw plugin SDK 当前未暴露查询 installRecords 的 API,只能直接读
139
+ * `<state-dir>/plugins/installs.json`(与上游 `manifest-metadata-scan` 等
140
+ * 内部模块同源做法)。如果上游后续开放官方接口,可切换并删除直读分支。
141
+ *
134
142
  * @param {string} pluginId
135
143
  * @returns {object|null}
136
144
  */
@@ -77,6 +77,13 @@ function generateIdentity() {
77
77
  * 加载或创建设备身份(Ed25519 密钥对)
78
78
  *
79
79
  * 存储格式与 OpenClaw device-identity.ts 保持一致。
80
+ *
81
+ * 注:本函数及内部 `fs.existsSync` / `readFileSync` / `generateKeyPairSync` /
82
+ * `atomicWriteFileSync` 均为同步 IO,**有意保留**——本路径只在 plugin↔本机
83
+ * gateway 首次握手时命中一次(后续走 realtime-bridge 的内存缓存),属于"启动期
84
+ * 一锤子"。改 async 必须把握手链路也 async 化,复杂度全长在握手那条线上,收益
85
+ * 不抵成本。重新评估前请先确认调用频率确实发生了变化。
86
+ *
80
87
  * @param {string} [filePath] - 自定义路径,默认 &lt;state-dir&gt;/coclaw/device-identity.json
81
88
  * @returns {{ deviceId: string, publicKeyPem: string, privateKeyPem: string }}
82
89
  */
@@ -768,9 +768,13 @@ export class RealtimeBridge {
768
768
  if (this.gatewayWs !== ws) {
769
769
  return;
770
770
  }
771
+ // rawData 保留供转发链路(res 单播 / agent event 单播 / 兜底广播)透传:
772
+ // 跳过 webrtcPeer.broadcast/sendTo 内部的重新 stringify,省掉对大 payload
773
+ // (如 agent.run.event 含大 tool_result)的重复序列化主线程阻塞。
774
+ const rawData = String(event.data ?? '{}');
771
775
  let payload = null;
772
776
  try {
773
- payload = JSON.parse(String(event.data ?? '{}'));
777
+ payload = JSON.parse(rawData);
774
778
  }
775
779
  catch {
776
780
  return;
@@ -892,7 +896,9 @@ export class RealtimeBridge {
892
896
  /* c8 ignore next -- TODO: 2026-05-20 后删除 */
893
897
  this.logger.debug?.(`[coclaw/rpc-res-route] hit, reqId=${payload.id} → connId=${info.connId}`);
894
898
  // sendTo 阶段 1 改为 async(admission 决策 await);外层 listener 已是 async
895
- const delivered = await this.webrtcPeer?.sendTo(info.connId, payload);
899
+ // 透传 rawData 跳过重新 stringify:gateway → plugin → DC 转发链路上的大 payload
900
+ // (如 agent.run.event 含大 tool_result)省掉一次主线程序列化阻塞。
901
+ const delivered = await this.webrtcPeer?.sendTo(info.connId, payload, rawData);
896
902
  if (!delivered) {
897
903
  // PC 已断 / DC 未 open / 队列拒收:本地 log 丢弃,不退回广播
898
904
  this.__logDebug(
@@ -913,7 +919,8 @@ export class RealtimeBridge {
913
919
  /* c8 ignore next -- TODO: 2026-05-20 后删除 */
914
920
  this.logger.debug?.(`[coclaw/run-event-route] hit, runId=${runId} → connId=${connId}`);
915
921
  // sendTo 失败不打 log(PC 状态翻转日志已足够,drop 是正确语义)
916
- await this.webrtcPeer?.sendTo(connId, payload);
922
+ // 透传 rawData 跳过重新 stringify:agent event payload 可能很大(如 tool_result)
923
+ await this.webrtcPeer?.sendTo(connId, payload, rawData);
917
924
  return;
918
925
  }
919
926
  }
@@ -921,7 +928,8 @@ export class RealtimeBridge {
921
928
  this.logger.debug?.(`[coclaw/run-event-route] miss, broadcast, runId=${runId ?? '<missing>'}`);
922
929
  }
923
930
  // (d) 兜底广播:覆盖 event 类型 / 映射未命中场景
924
- this.webrtcPeer?.broadcast(payload);
931
+ // 透传 rawData 跳过重新 stringify:agent event 兜底广播同样可能很大
932
+ this.webrtcPeer?.broadcast(payload, rawData);
925
933
  }
926
934
  })().catch((err) => {
927
935
  this.logger.warn?.(`[coclaw] gateway ws message handler error: ${err?.message ?? err}`);
@@ -1310,8 +1318,14 @@ export class RealtimeBridge {
1310
1318
  await this.__webrtcPeerReady;
1311
1319
  await this.webrtcPeer.handleSignaling(payload);
1312
1320
  } catch (err) {
1313
- this.logger.warn?.(`[coclaw/rtc] signaling error (or werift not found): ${err?.message}`);
1314
- remoteLog(`rtc.signaling-error msg=${err?.message}`);
1321
+ // 现在 handleSignaling drain 自己 catch + 转 rtc.signaling-error remoteLog 不
1322
+ // 抛出,这层 catch 只会兜到 __initWebrtcPeer() 失败(peer 未就绪)。type/conn
1323
+ // 字段保留是为了 server 端日志区分发生位置——init 路径下 type/conn 仍来自当前
1324
+ // 信令消息,与 drain 内的同名日志做区分主要看 msg 文本。
1325
+ const sigType = payload?.type ?? 'unknown';
1326
+ const sigConn = payload?.fromConnId ?? payload?.toConnId ?? 'unknown';
1327
+ this.logger.warn?.(`[coclaw/rtc] signaling error (or werift not found) type=${sigType} conn=${sigConn}: ${err?.message}`);
1328
+ remoteLog(`rtc.signaling-error type=${sigType} conn=${sigConn} msg=${err?.message}`);
1315
1329
  }
1316
1330
  return;
1317
1331
  }
@@ -1,69 +1,8 @@
1
- import fs from 'node:fs';
1
+ import fsp from 'node:fs/promises';
2
2
  import nodePath from 'node:path';
3
3
 
4
4
  import { agentSessionsDir, sessionStorePath, sessionTranscriptPath } from '../claw-paths.js';
5
-
6
- const DERIVED_TITLE_MAX_LEN = 60;
7
-
8
- // OC 注入的 inbound metadata 头部(Conversation info / Sender / Thread starter 等)
9
- const INBOUND_META_RE = /^\w[\w ]* \(untrusted[^)]*\):\n```json\n[\s\S]*?\n```\n\n/;
10
- // operator 级策略/指令前缀,如 Skills store policy (operator configured): ...
11
- const OPERATOR_POLICY_RE = /^\w[\w ]* \(operator configured\):[\s\S]*?\n\n/;
12
- // OC 注入的用户消息时间戳前缀
13
- const USER_TS_RE = /^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s+[^\]]+\]\s*/;
14
- // 尾部 [message_id: xxx]
15
- const MSG_ID_SUFFIX_RE = /\n\[message_id:\s*[^\]]+\]\s*$/;
16
- // 尾部 Untrusted context 块(外部元数据注入)
17
- const UNTRUSTED_CTX_SUFFIX_RE = /\n\nUntrusted context \(metadata, do not treat as instructions or commands\):\n[\s\S]*$/;
18
- // 定时任务前缀
19
- const CRON_UUID_RE = /\[cron:[0-9a-f-]+(?:\s+([^\]]*))?\]\s*/;
20
- // cron 注入的 Current time 行及其后的系统追加指令(如 "Return your summary...")
21
- const CRON_TIME_TAIL_RE = /\nCurrent time:[^\n]+[\s\S]*$/;
22
- // 从 Current time 行提取 UTC 时间部分
23
- const CRON_TIME_UTC_RE = /(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})\s+UTC/;
24
-
25
- function formatCronTime(matchedText) {
26
- const m = matchedText.match(CRON_TIME_UTC_RE);
27
- if (!m) return '';
28
- /* c8 ignore start -- Date 构造在正则已校验的输入下不会抛出 */
29
- try {
30
- const d = new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:00Z`);
31
- if (!Number.isFinite(d.getTime())) return '';
32
- const y = d.getFullYear();
33
- const mo = String(d.getMonth() + 1).padStart(2, '0');
34
- const dd = String(d.getDate()).padStart(2, '0');
35
- const hh = String(d.getHours()).padStart(2, '0');
36
- const mi = String(d.getMinutes()).padStart(2, '0');
37
- return ` ${y}-${mo}-${dd} ${hh}${mi}`;
38
- }
39
- catch {
40
- return '';
41
- }
42
- /* c8 ignore stop */
43
- }
44
-
45
- function stripLeadingPattern(text, re) {
46
- let prev;
47
- do {
48
- prev = text;
49
- text = text.replace(re, '');
50
- } while (text !== prev);
51
- return text;
52
- }
53
-
54
- function cleanTitleText(text) {
55
- /* c8 ignore next */
56
- if (!text) return '';
57
- let s = stripLeadingPattern(text, INBOUND_META_RE);
58
- s = stripLeadingPattern(s, OPERATOR_POLICY_RE);
59
- s = s.replace(CRON_TIME_TAIL_RE, (match) => formatCronTime(match));
60
- return s
61
- .replace(USER_TS_RE, '')
62
- .replace(CRON_UUID_RE, (_, taskName) => taskName ? `${taskName} ` : '')
63
- .replace(UNTRUSTED_CTX_SUFFIX_RE, '')
64
- .replace(MSG_ID_SUFFIX_RE, '')
65
- .trim();
66
- }
5
+ import { iterTextLines } from '../utils/text-line-stream.js';
67
6
 
68
7
  function toNum(value, fallback) {
69
8
  const n = Number(value);
@@ -77,15 +16,36 @@ function clamp(value, min, max, fallback) {
77
16
  return n;
78
17
  }
79
18
 
80
- function readJsonSafe(filePath, fallback) {
19
+ async function readJsonSafe(filePath, fallback) {
81
20
  try {
82
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
21
+ const text = await fsp.readFile(filePath, 'utf8');
22
+ return JSON.parse(text);
83
23
  }
84
24
  catch {
85
25
  return fallback;
86
26
  }
87
27
  }
88
28
 
29
+ // readdir 与后续 stat 之间存在天然 race window(文件被并发删除/reset 归档)。
30
+ // 统一把 ENOENT/ENOTDIR 视为"目录消失即空目录",其它错误(如 EACCES)按原样上抛。
31
+ async function safeReaddir(dir) {
32
+ try { return await fsp.readdir(dir); }
33
+ catch (err) {
34
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return [];
35
+ /* c8 ignore next 2 -- 非 ENOENT/ENOTDIR 的 fs 错误按原样上抛 */
36
+ throw err;
37
+ }
38
+ }
39
+
40
+ async function safeAccess(filePath) {
41
+ try { await fsp.access(filePath); return true; }
42
+ catch (err) {
43
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return false;
44
+ /* c8 ignore next 2 -- 非 ENOENT/ENOTDIR 的 fs 错误按原样上抛 */
45
+ throw err;
46
+ }
47
+ }
48
+
89
49
  function parseSessionFileName(fileName) {
90
50
  if (typeof fileName !== 'string' || !fileName.includes('.jsonl')) return null;
91
51
  if (fileName.includes('.jsonl.delete.') || fileName.includes('.jsonl.deleted.')) return null;
@@ -118,60 +78,6 @@ function shouldReplaceByPriority(current, next) {
118
78
  return next.updatedAt > current.updatedAt;
119
79
  }
120
80
 
121
- function truncateTitle(text, maxLen = DERIVED_TITLE_MAX_LEN) {
122
- if (text.length <= maxLen) return text;
123
- const cut = text.slice(0, maxLen - 1);
124
- const lastSpace = cut.lastIndexOf(' ');
125
- if (lastSpace > maxLen * 0.6) {
126
- return `${cut.slice(0, lastSpace)}…`;
127
- }
128
- return `${cut}…`;
129
- }
130
-
131
- function extractRawTextFromContent(content) {
132
- if (typeof content === 'string') return content;
133
- /* c8 ignore next */
134
- if (!Array.isArray(content)) return undefined;
135
- for (const part of content) {
136
- if (!part || typeof part !== 'object') continue;
137
- if (part.type !== 'text') continue;
138
- /* c8 ignore next */
139
- if (typeof part.text !== 'string') continue;
140
- if (part.text.trim()) return part.text;
141
- }
142
- return undefined;
143
- }
144
-
145
- function findFirstUserRawText(filePath, logger) {
146
- const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
147
- for (const line of lines) {
148
- if (!line) continue;
149
- try {
150
- const row = JSON.parse(line);
151
- if (row?.type !== 'message') continue;
152
- if (row?.message?.role !== 'user') continue;
153
- const raw = extractRawTextFromContent(row?.message?.content);
154
- if (raw && raw.trim()) return raw;
155
- }
156
- catch (err) {
157
- /* c8 ignore next -- ?./?? fallback */
158
- logger.warn?.(`[session-manager] bad json line skipped when deriving title: ${String(err?.message ?? err)}`);
159
- }
160
- }
161
- return undefined;
162
- }
163
-
164
- function deriveTitle(filePath, logger) {
165
- const rawText = findFirstUserRawText(filePath, logger);
166
- if (!rawText) return undefined;
167
- const cleaned = cleanTitleText(rawText);
168
- if (!cleaned) return undefined;
169
- const normalized = cleaned.replace(/\s+/g, ' ').trim();
170
- /* c8 ignore next */
171
- if (!normalized) return undefined;
172
- return truncateTitle(normalized, DERIVED_TITLE_MAX_LEN);
173
- }
174
-
175
81
  export function createSessionManager(options = {}) {
176
82
  /* c8 ignore next */
177
83
  const logger = options.logger ?? console;
@@ -185,22 +91,22 @@ export function createSessionManager(options = {}) {
185
91
  return resolveSessionsDir(aid);
186
92
  }
187
93
 
188
- function readIndex(agentId = 'main') {
94
+ async function readIndex(agentId = 'main') {
189
95
  /* c8 ignore next */
190
96
  const aid = typeof agentId === 'string' && agentId.trim() ? agentId.trim() : 'main';
191
97
  const file = resolveStorePath(aid);
192
- const data = readJsonSafe(file, {});
98
+ const data = await readJsonSafe(file, {});
193
99
  /* c8 ignore next */
194
100
  if (!data || typeof data !== 'object') return {};
195
101
  return data;
196
102
  }
197
103
 
198
- function listAll(params = {}) {
104
+ async function listAll(params = {}) {
199
105
  const agentId = typeof params.agentId === 'string' && params.agentId.trim() ? params.agentId.trim() : 'main';
200
106
  const limit = clamp(params.limit, 1, 200, 50);
201
107
  const cursor = clamp(params.cursor, 0, Number.MAX_SAFE_INTEGER, 0);
202
108
  const dir = sessionsDir(agentId);
203
- const index = readIndex(agentId);
109
+ const index = await readIndex(agentId);
204
110
  const indexed = new Set(
205
111
  Object.values(index)
206
112
  .map((item) => item?.sessionId)
@@ -214,13 +120,20 @@ export function createSessionManager(options = {}) {
214
120
  }
215
121
  }
216
122
 
217
- const files = fs.existsSync(dir) ? fs.readdirSync(dir) : [];
123
+ const files = await safeReaddir(dir);
218
124
  const grouped = new Map();
219
125
  for (const file of files) {
220
126
  const parsed = parseSessionFileName(file);
221
127
  if (!parsed?.sessionId) continue;
222
128
  const full = nodePath.join(dir, file);
223
- const stat = fs.statSync(full);
129
+ let stat;
130
+ try { stat = await fsp.stat(full); }
131
+ /* c8 ignore start -- readdir→stat race window:文件被并发删除时跳过 */
132
+ catch (err) {
133
+ if (err.code === 'ENOENT') continue;
134
+ throw err;
135
+ }
136
+ /* c8 ignore stop */
224
137
  const row = {
225
138
  sessionId: parsed.sessionId,
226
139
  sessionKey: sessionKeyById.get(parsed.sessionId) ?? null,
@@ -256,20 +169,7 @@ export function createSessionManager(options = {}) {
256
169
  const rows = Array.from(grouped.values());
257
170
  rows.sort((a, b) => b.updatedAt - a.updatedAt);
258
171
 
259
- const items = rows.slice(cursor, cursor + limit).map((row) => {
260
- if (!row.fileName) {
261
- return { ...row };
262
- }
263
- const transcriptPath = nodePath.join(dir, row.fileName);
264
- const derivedTitle = deriveTitle(transcriptPath, logger);
265
- if (!derivedTitle) {
266
- return { ...row };
267
- }
268
- return {
269
- ...row,
270
- derivedTitle,
271
- };
272
- });
172
+ const items = rows.slice(cursor, cursor + limit).map((row) => ({ ...row }));
273
173
  const nextCursor = cursor + limit < rows.length ? String(cursor + limit) : null;
274
174
  return {
275
175
  agentId,
@@ -280,53 +180,74 @@ export function createSessionManager(options = {}) {
280
180
  };
281
181
  }
282
182
 
283
- function resolveTranscriptFile(agentId, sessionId) {
183
+ async function resolveTranscriptFile(agentId, sessionId) {
284
184
  const dir = sessionsDir(agentId);
285
185
  // live 文件优先:同一 sessionId 可能同时存在 live 和 reset 文件
286
186
  // (OpenClaw reset 后复用 sessionId),live 代表当前活跃 transcript
287
187
  const livePath = resolveTranscriptPath(sessionId, agentId);
288
- if (fs.existsSync(livePath)) {
289
- return livePath;
290
- }
291
- const files = fs.existsSync(dir) ? fs.readdirSync(dir) : [];
188
+ if (await safeAccess(livePath)) return livePath;
189
+
190
+ const files = await safeReaddir(dir);
292
191
  const resetPrefix = `${sessionId}.jsonl.reset.`;
293
- const resetCandidates = files
294
- .filter((name) => name.startsWith(resetPrefix))
295
- .map((name) => {
296
- const full = nodePath.join(dir, name);
297
- const stat = fs.statSync(full);
298
- return {
299
- path: full,
300
- archiveStamp: name.slice(resetPrefix.length),
301
- updatedAt: stat.mtimeMs,
302
- };
303
- })
304
- .sort((a, b) => {
305
- if (a.archiveStamp !== b.archiveStamp) {
306
- return b.archiveStamp.localeCompare(a.archiveStamp);
307
- }
308
- /* c8 ignore next -- 同一 sessionId 的 reset 文件不会有相同 archiveStamp */
309
- return b.updatedAt - a.updatedAt;
192
+ const resetCandidates = [];
193
+ for (const name of files) {
194
+ if (!name.startsWith(resetPrefix)) continue;
195
+ const full = nodePath.join(dir, name);
196
+ let stat;
197
+ try { stat = await fsp.stat(full); }
198
+ /* c8 ignore start -- readdir→stat race window */
199
+ catch (err) {
200
+ if (err.code === 'ENOENT') continue;
201
+ throw err;
202
+ }
203
+ /* c8 ignore stop */
204
+ resetCandidates.push({
205
+ path: full,
206
+ archiveStamp: name.slice(resetPrefix.length),
207
+ updatedAt: stat.mtimeMs,
310
208
  });
209
+ }
210
+ resetCandidates.sort((a, b) => {
211
+ if (a.archiveStamp !== b.archiveStamp) {
212
+ return b.archiveStamp.localeCompare(a.archiveStamp);
213
+ }
214
+ /* c8 ignore next -- 同一 sessionId 的 reset 文件不会有相同 archiveStamp */
215
+ return b.updatedAt - a.updatedAt;
216
+ });
311
217
  if (resetCandidates.length > 0) {
312
218
  return resetCandidates[0].path;
313
219
  }
314
220
  return null;
315
221
  }
316
222
 
317
- function get(params = {}) {
223
+ // transcript 全文;不存在视为空字符串。返回原始文本,由调用方走 iterTextLines
224
+ // 流式扫描 + 解析,避免大文件 split 一次性卡 event loop。
225
+ async function readTranscriptText(file) {
226
+ try {
227
+ return await fsp.readFile(file, 'utf8');
228
+ }
229
+ /* c8 ignore start -- resolveTranscriptFile→readFile race window */
230
+ catch (err) {
231
+ if (err.code === 'ENOENT') return '';
232
+ throw err;
233
+ }
234
+ /* c8 ignore stop */
235
+ }
236
+
237
+ async function get(params = {}) {
318
238
  const agentId = typeof params.agentId === 'string' && params.agentId.trim() ? params.agentId.trim() : 'main';
319
239
  const sessionId = typeof params.sessionId === 'string' ? params.sessionId.trim() : '';
320
240
  if (!sessionId) throw new Error('sessionId required');
321
241
  const limit = clamp(params.limit, 1, 500, 100);
322
242
  const cursor = clamp(params.cursor, 0, Number.MAX_SAFE_INTEGER, 0);
323
- const file = resolveTranscriptFile(agentId, sessionId);
243
+ const file = await resolveTranscriptFile(agentId, sessionId);
324
244
  if (!file) {
325
245
  return { agentId, sessionId, total: 0, cursor: String(cursor), nextCursor: null, messages: [] };
326
246
  }
327
247
 
248
+ const text = await readTranscriptText(file);
328
249
  const all = [];
329
- for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(Boolean)) {
250
+ for await (const line of iterTextLines(text)) {
330
251
  try {
331
252
  all.push(JSON.parse(line));
332
253
  }
@@ -352,20 +273,21 @@ export function createSessionManager(options = {}) {
352
273
  * 按 sessionId 获取消息,返回完整 JSONL 行级结构。
353
274
  * 只返回 type==="message" 且有合法 message.role 的行。
354
275
  * @param {{ sessionId: string, agentId?: string, limit?: number }} params
355
- * @returns {{ messages: object[] }}
276
+ * @returns {Promise<{ messages: object[] }>}
356
277
  */
357
- function getById(params = {}) {
278
+ async function getById(params = {}) {
358
279
  const agentId = typeof params.agentId === 'string' && params.agentId.trim() ? params.agentId.trim() : 'main';
359
280
  const sessionId = typeof params.sessionId === 'string' ? params.sessionId.trim() : '';
360
281
  if (!sessionId) throw new Error('sessionId required');
361
282
  const limit = clamp(params.limit, 1, 500, 500);
362
- const file = resolveTranscriptFile(agentId, sessionId);
283
+ const file = await resolveTranscriptFile(agentId, sessionId);
363
284
  if (!file) {
364
285
  return { messages: [] };
365
286
  }
366
287
 
288
+ const text = await readTranscriptText(file);
367
289
  const messages = [];
368
- for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(Boolean)) {
290
+ for await (const line of iterTextLines(text)) {
369
291
  try {
370
292
  const row = JSON.parse(line);
371
293
  if (row?.type !== 'message') continue;
@@ -0,0 +1,60 @@
1
+ export const DEFAULT_YIELD_EVERY = 100;
2
+
3
+ /**
4
+ * 流式扫描大字符串的行,分批让出 event loop。
5
+ *
6
+ * 不预先 split——按 \n 游标推进,避免大字符串一次性 split 卡主线程。
7
+ *
8
+ * 行终止与 split(/\r?\n/) 的语义差异:
9
+ * - 把 \n 视为"行终止符"而非"分隔符"。末尾 \n 之后**不产生**额外空段,即
10
+ * `'foo\n'` 仅产 `['foo']`(split 会得 `['foo','']`)。`skipEmpty: false`
11
+ * 下亦如此——空段是"两个分隔符之间没字符",末尾 \n 之后没有内容也没有下
12
+ * 一个分隔符,所以本来就不应该产空段。
13
+ * - 仅剥紧贴 \n 之前的 \r(CRLF 折行)。**孤立的 \r**(行内或末尾段无 \n)
14
+ * 按字面保留,与 split(/\r?\n/) 一致。
15
+ *
16
+ * 空行(连续 \n\n 之间的空段)按 skipEmpty 选项过滤,默认跳过。
17
+ *
18
+ * 让出策略:每处理 yieldEvery 行 await 一次 setImmediate,让 I/O 回调(如 RTC
19
+ * 数据通道帧、其它 RPC handler)有机会插入。Node 中首选 setImmediate 而非
20
+ * setTimeout(0):后者最小延迟被钳制到 1ms,让出净开销显著更大。
21
+ *
22
+ * @param {string} text - 待扫描文本
23
+ * @param {{ yieldEvery?: number, skipEmpty?: boolean }} [opts]
24
+ * @returns {AsyncGenerator<string>}
25
+ */
26
+ export async function* iterTextLines(text, opts = {}) {
27
+ if (typeof text !== 'string' || text.length === 0) return;
28
+ const yieldEvery = Number.isFinite(opts.yieldEvery) && opts.yieldEvery > 0
29
+ ? Math.trunc(opts.yieldEvery)
30
+ : DEFAULT_YIELD_EVERY;
31
+ const skipEmpty = opts.skipEmpty !== false;
32
+
33
+ const len = text.length;
34
+ let start = 0;
35
+ let count = 0;
36
+
37
+ while (start < len) {
38
+ const lfIdx = text.indexOf('\n', start);
39
+ const terminatedByLf = lfIdx !== -1;
40
+ const end = terminatedByLf ? lfIdx : len;
41
+
42
+ // 仅当本段确实由 LF 终止时才剥末尾 \r(CRLF)。
43
+ // 末尾段(无 LF)保留原文,与 split(/\r?\n/) 行为一致——
44
+ // 否则 'a\r' 会被剥成 'a' 静默丢失,违反"行为等价"约定。
45
+ let lineEnd = end;
46
+ if (terminatedByLf && lineEnd > start && text.charCodeAt(lineEnd - 1) === 13) {
47
+ lineEnd--;
48
+ }
49
+
50
+ if (!skipEmpty || lineEnd > start) {
51
+ yield text.slice(start, lineEnd);
52
+ }
53
+
54
+ start = end + 1;
55
+
56
+ if (++count % yieldEvery === 0) {
57
+ await new Promise((resolve) => setImmediate(resolve));
58
+ }
59
+ }
60
+ }