@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 +1 -1
- package/index.js +7 -7
- package/package.json +1 -1
- package/src/auto-upgrade/updater.js +8 -0
- package/src/device-identity.js +7 -0
- package/src/realtime-bridge.js +20 -6
- package/src/session-manager/manager.js +90 -168
- package/src/utils/text-line-stream.js +60 -0
- package/src/webrtc/webrtc-peer.js +396 -213
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
|
@@ -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
|
*/
|
package/src/device-identity.js
CHANGED
|
@@ -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] - 自定义路径,默认 <state-dir>/coclaw/device-identity.json
|
|
81
88
|
* @returns {{ deviceId: string, publicKeyPem: string, privateKeyPem: string }}
|
|
82
89
|
*/
|
package/src/realtime-bridge.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1314
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
289
|
-
|
|
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 =
|
|
294
|
-
|
|
295
|
-
.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|