@240xu/dsh-message-ops 0.2.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.
@@ -0,0 +1,269 @@
1
+ /**
2
+ * dsh-message-ops — 可复用操作核心(纯逻辑,无 HTTP、无 dsh-tools 依赖)。
3
+ *
4
+ * HTTP 路由(src/index.js)与 Agent 工具 message_ops 共用这里的函数,
5
+ * 保证两条入口的行为(规划 / 校验 / 错误语义)完全一致。
6
+ *
7
+ * 引擎能力查证(来自安装的运行时 @deepseek-ai/dsh-session/lib/types/surface.js):
8
+ * - SurfaceOp 只有 'append' 与 'replace' 两个变体,不存在「解除遮蔽」
9
+ * 操作;surface replace 是 append-only 的永久遮蔽。
10
+ * - 运行时引擎的 replace 形状是 { op:'replace', startSeq, endSeq }
11
+ * (dsh-src 仓库较新副本已改名为 start/end;本插件面向安装的运行时,
12
+ * 写入用 startSeq/endSeq,读取端两种拼写都兼容以便前向迁移)。
13
+ * - 因此「回滚恢复」只能实现为「重放」(replay):把被遮蔽区间的
14
+ * user/assistant 消息文本重新 append 为新事件(文本加 [恢复] 前缀),
15
+ * 而不是取消遮蔽。语义差异见 README。
16
+ * @module dsh-message-ops/ops-core
17
+ */
18
+
19
+ import { readReplaceOp } from "./session-file.js";
20
+
21
+ /** 带语义状态码的操作错误(HTTP 直接映射,工具端转为失败文本)。 */
22
+ export class OpsError extends Error {
23
+ constructor(message, status = 400) {
24
+ super(message);
25
+ this.status = status;
26
+ }
27
+ }
28
+
29
+ /** 从 message 事件提取首个非空 text 块(与 listMessages 同一规则)。 */
30
+ export function messageText(e) {
31
+ const msg = e && e.data && e.data.message;
32
+ const content = msg && Array.isArray(msg.content) ? msg.content : (e && e.data && e.data.content);
33
+ if (!Array.isArray(content)) return "";
34
+ for (const c of content) {
35
+ if (c && c.type === "text" && typeof c.text === "string" && c.text.trim()) return c.text;
36
+ }
37
+ return "";
38
+ }
39
+
40
+ /** planRevert:回撤语义 = 遮蔽 targetSeq 及其之后全部可见节点(连续到末尾)。 */
41
+ export function planRevert(surface, targetSeq) {
42
+ const nodes = surface && Array.isArray(surface.nodes) ? surface.nodes : [];
43
+ const startIdx = nodes.indexOf(targetSeq);
44
+ if (startIdx === -1) throw new OpsError(`surface replace: start seq ${targetSeq} not found in surface`, 409);
45
+ const shadowedSeqs = nodes.slice(startIdx);
46
+ if (shadowedSeqs.length === 0) throw new OpsError("nothing to revert", 409);
47
+ return {
48
+ startSeq: targetSeq,
49
+ endSeq: shadowedSeqs[shadowedSeqs.length - 1],
50
+ shadowedSeqs,
51
+ };
52
+ }
53
+
54
+ /** planDelete:单条遮蔽,要求 seq 仍在当前 surface 上可见。 */
55
+ export function planDelete(surface, seq) {
56
+ const nodes = surface && Array.isArray(surface.nodes) ? surface.nodes : [];
57
+ if (!nodes.includes(seq)) throw new OpsError(`seq ${seq} not visible on current surface`, 409);
58
+ return { startSeq: seq, endSeq: seq, shadowedSeqs: [seq] };
59
+ }
60
+
61
+ // 写端 replace 拼写(前向兼容雷,评审 P1):当前运行时引擎
62
+ // (@deepseek-ai/dsh-session/lib/types/surface.js 的 isReplaceOp)要求
63
+ // {op:'replace', startSeq, endSeq} 且恰好 3 个键;dsh-src 较新副本已改名
64
+ // {op:'replace', start, end}(同样恰好 3 个键)。写端做一次运行时探测:
65
+ // 先按当前 cohort 的 startSeq/endSeq 形状写,若引擎报
66
+ // "invalid replace surfaceOp"(validateNext 在事件入 log 前抛出,失败不落
67
+ // 状态、无半写风险),降级用 {start,end} 重试一次并记住结果。读端
68
+ // (readReplaceOp/computeShadowed/planRestore)两种拼写始终兼容。
69
+ let replaceShape = null; // null=未探测 | "legacy"=startSeq/endSeq | "new"=start/end
70
+
71
+ function replaceOpFor(shape, startSeq, endSeq) {
72
+ return shape === "new"
73
+ ? { op: "replace", start: startSeq, end: endSeq }
74
+ : { op: "replace", startSeq, endSeq };
75
+ }
76
+
77
+ /** 测试专用:重置写端拼写探测缓存。 */
78
+ export function _resetReplaceShape() { replaceShape = null; }
79
+
80
+ /**
81
+ * surface replace 落定:append 一条承载 replace 的 system/message。
82
+ * sourceEventSeqs 必须覆盖被遮蔽的全部 surface 节点(引擎
83
+ * assertProvenance 强校验,缺失即抛错)。
84
+ */
85
+ export function applySurfaceReplace(session, startSeq, endSeq, sourceEventSeqs, noticeText) {
86
+ const data = { message: { role: "system", content: [{ type: "text", text: noticeText }] } };
87
+ if (replaceShape) {
88
+ return session.append("system/message", data, { surfaceOp: replaceOpFor(replaceShape, startSeq, endSeq), sourceEventSeqs });
89
+ }
90
+ try {
91
+ const event = session.append("system/message", data, { surfaceOp: replaceOpFor("legacy", startSeq, endSeq), sourceEventSeqs });
92
+ replaceShape = "legacy";
93
+ return event;
94
+ } catch (err) {
95
+ if (!/invalid replace surfaceOp/.test(String(err && err.message))) throw err;
96
+ const event = session.append("system/message", data, { surfaceOp: replaceOpFor("new", startSeq, endSeq), sourceEventSeqs });
97
+ replaceShape = "new";
98
+ return event;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * planRestore:从磁盘事件流规划「回滚恢复」。
104
+ *
105
+ * @param {Array} events 磁盘全量事件(readSessionFile 的返回)
106
+ * @param {number} restoreSeq 一次 revert/delete 落定的 system/message 事件的
107
+ * seq(其 surfaceOp 为 replace、sourceEventSeqs 记录了被遮蔽节点)
108
+ */
109
+ export function planRestore(events, restoreSeq) {
110
+ if (!Array.isArray(events)) throw new OpsError("restore: events must be an array", 500);
111
+ if (!Number.isSafeInteger(restoreSeq) || restoreSeq < 0) throw new OpsError("invalid seq", 400);
112
+ const ev = events.find((e) => e && e.seq === restoreSeq);
113
+ if (!ev) throw new OpsError(`restore: event seq ${restoreSeq} not found in log`, 404);
114
+ // 读取端拼写统一走 session-file 的 readReplaceOp(双拼写兼容的单点)。
115
+ const range = readReplaceOp(ev);
116
+ if (!range) {
117
+ throw new OpsError(`restore: seq ${restoreSeq} is not a revert/delete marker event (no well-formed replace surfaceOp)`, 409);
118
+ }
119
+ const { startSeq, endSeq } = range;
120
+ const replayable = [];
121
+ let skipped = 0;
122
+ for (const e of events) {
123
+ if (!e || typeof e.seq !== "number" || e.seq < startSeq || e.seq > endSeq) continue;
124
+ if (e.type === "user/message" || e.type === "assistant/message") {
125
+ const text = messageText(e);
126
+ if (!text.trim()) { skipped++; continue; }
127
+ replayable.push({
128
+ seq: e.seq,
129
+ type: e.type,
130
+ role: e.type === "user/message" ? "user" : "assistant",
131
+ text,
132
+ });
133
+ } else {
134
+ skipped++; // tool/call 等不可安全重放的事件:跳过并在结果里计数
135
+ }
136
+ }
137
+ if (replayable.length === 0) {
138
+ throw new OpsError(`restore: no replayable user/assistant messages in shadowed range ${startSeq}..${endSeq}`, 409);
139
+ }
140
+ return { restoreSeq, startSeq, endSeq, replayable, skipped };
141
+ }
142
+
143
+ /**
144
+ * applyRestore:把 planRestore 的重放计划落定到 live session。
145
+ * 引擎不支持取消遮蔽(SurfaceOp 无该变体),故实现为重放:
146
+ * 每条消息以原类型 append、文本加 [恢复] 前缀;先 append 一条 system 说明。
147
+ */
148
+ export function applyRestore(session, plan, { flush } = {}) {
149
+ const eventSeqs = [];
150
+ const notice = `[消息恢复] 重放 seq ${plan.startSeq}..${plan.endSeq} 的 ${plan.replayable.length} 条消息` +
151
+ (plan.skipped > 0 ? `(另有 ${plan.skipped} 条不可重放事件已跳过)` : "") +
152
+ `;原区间仍处于遮蔽状态,恢复为重放而非解除遮蔽`;
153
+ const noticeEvent = session.append(
154
+ "system/message",
155
+ { message: { role: "system", content: [{ type: "text", text: notice }] } },
156
+ { surfaceOp: "append" },
157
+ );
158
+ if (noticeEvent && noticeEvent.seq != null) eventSeqs.push(noticeEvent.seq);
159
+ for (const item of plan.replayable) {
160
+ const event = session.append(
161
+ item.type,
162
+ { message: { role: item.role, content: [{ type: "text", text: `[恢复] ${item.text}` }] } },
163
+ { surfaceOp: "append" },
164
+ );
165
+ if (event && event.seq != null) eventSeqs.push(event.seq);
166
+ }
167
+ if (flush) {
168
+ try { flush(); } catch { /* flush 失败不回滚已接受的事件 */ }
169
+ }
170
+ return { restoredCount: plan.replayable.length, skipped: plan.skipped, eventSeqs };
171
+ }
172
+
173
+ /**
174
+ * exportMarkdown:把到 upToSeq(含)为止的事件流渲染为 Markdown。
175
+ * user/assistant/system 消息按角色小节展开,工具调用折叠为单行引用。
176
+ * @param {object} header 会话 header(readSessionFile)
177
+ * @param {Array} events 全量事件
178
+ * @param {number=} upToSeq 可选上界(含);缺省导出全部
179
+ */
180
+ export function exportMarkdown(header, events, upToSeq) {
181
+ if (upToSeq !== undefined && (!Number.isSafeInteger(upToSeq) || upToSeq < 0)) {
182
+ throw new OpsError("invalid seq", 400);
183
+ }
184
+ const sessionId = header && header.id ? header.id : "unknown";
185
+ const scoped = upToSeq === undefined ? events : events.filter((e) => e && typeof e.seq === "number" && e.seq <= upToSeq);
186
+ const lines = [];
187
+ lines.push(`# DSH 会话导出:${sessionId}`);
188
+ lines.push("");
189
+ lines.push(`- 会话 id:${sessionId}`);
190
+ if (header && header.parentSession) lines.push(`- 来源分支:${header.parentSession}`);
191
+ lines.push(`- 导出范围:${upToSeq === undefined ? "全部" : `seq ≤ ${upToSeq}`}`);
192
+ lines.push(`- 生成时间:${new Date().toISOString()}`);
193
+ lines.push("");
194
+ lines.push("---");
195
+ lines.push("");
196
+ for (const e of scoped) {
197
+ if (!e || typeof e.seq !== "number") continue;
198
+ if (e.type === "user/message" || e.type === "assistant/message" || e.type === "system/message") {
199
+ const role = (e.data && e.data.message && e.data.message.role) ||
200
+ (e.type === "user/message" ? "user" : e.type === "assistant/message" ? "assistant" : "system");
201
+ const text = messageText(e);
202
+ lines.push(`## [seq ${e.seq}] ${role}`);
203
+ lines.push("");
204
+ lines.push(text || "(无文本内容)");
205
+ lines.push("");
206
+ } else if (e.type === "tool/call") {
207
+ const d = e.data || {};
208
+ const name = d.name || (d.call && d.call.name) || "tool";
209
+ const argsHint = d.call && d.call.arguments !== undefined
210
+ ? JSON.stringify(d.call.arguments).slice(0, 80)
211
+ : "";
212
+ lines.push(`> [seq ${e.seq}] 🔧 工具调用:${name}${argsHint ? ` — ${argsHint}` : ""}`);
213
+ lines.push("");
214
+ }
215
+ }
216
+ return lines.join("\n");
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // HTTP 信任围栏(评审 P0):插件经 webServer.register 挂载的路由不经过宿主
221
+ // connection RPC 面的 isTrustedApiRequest 围栏(dsh-src
222
+ // packages/client/connection/src/api-request-trust.ts),必须自建。三层:
223
+ // 1. Host 必须是回环地址 → 挡 DNS rebinding(伪造 Host 的攻击页直接 403);
224
+ // 2. sec-fetch-site: cross-site 拒绝 → 挡跨站浏览器请求;
225
+ // 3. Origin 存在时必须与 Host 同源 → 挡其余跨站 POST/GET。
226
+ // 非浏览器客户端(curl / Agent 工具 / 宿主自身)不带 sec-fetch-site 与
227
+ // Origin,且 Host 均为回环 → 天然放行,无需 token。
228
+ // ---------------------------------------------------------------------------
229
+
230
+ function headerOf(headers, name) {
231
+ const value = headers && headers[name];
232
+ return typeof value === "string" ? value : undefined;
233
+ }
234
+
235
+ function parseAuthority(authority) {
236
+ try { return new URL(`http://${authority}`); } catch { return undefined; }
237
+ }
238
+
239
+ function isLoopbackHostname(hostname) {
240
+ if (hostname === "localhost" || hostname === "[::1]") return true;
241
+ const parts = hostname.split(".");
242
+ return parts.length === 4 && parts[0] === "127" && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255);
243
+ }
244
+
245
+ /** 与 slv-check 同款的信任判定(回环 Host + 非 cross-site + Origin 同源)。 */
246
+ export function isTrustedApiRequest(req) {
247
+ const host = headerOf(req && req.headers, "host");
248
+ if (host === undefined) return false;
249
+ const hostUrl = parseAuthority(host);
250
+ if (hostUrl === undefined) return false;
251
+ if (!isLoopbackHostname(hostUrl.hostname)) return false;
252
+ if (headerOf(req.headers, "sec-fetch-site") === "cross-site") return false;
253
+ const origin = headerOf(req.headers, "origin");
254
+ if (origin === undefined) return true;
255
+ try { return new URL(origin).host === hostUrl.host; } catch { return false; }
256
+ }
257
+
258
+ /**
259
+ * 写操作 CSRF 防护:Content-Type 必须 application/json(允许 ;charset=… 后缀)。
260
+ * 为何不需要一次性 token:浏览器对**所有** POST(含 text/plain 绕预检的
261
+ * 那条路)都会附带 Origin 头,第 3 层 Origin 同源校验已覆盖跨站 POST;
262
+ * Content-Type 约束是纵深防御(阻止非 JSON 客户端误写),而自定义头
263
+ * (如 X-Requested-With)天然触发 CORS 预检,与 Origin 校验等价,故
264
+ * 引入 token 只增加握手复杂度而不增加安全性——围栏已足够(论证记录于此)。
265
+ */
266
+ export function isJsonContentType(req) {
267
+ const ct = headerOf(req && req.headers, "content-type") || "";
268
+ return ct.split(";")[0].trim().toLowerCase() === "application/json";
269
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * dsh-message-ops — 会话文件机制(纯 Node,零 npm 依赖)。
3
+ *
4
+ * DSH 的 JSONL 持久化格式:一份会话日志 = 多个独立 zstd 帧的串联,
5
+ * 帧 0 = 恰好一行 session header JSON(以 \n 结尾,帧带 checksum)
6
+ * 之后每帧 = 一批以 \n 分隔的事件行(NDJSON)
7
+ * 回写 = 重排帧:header 帧 + 事件帧。node:zlib 自 Node 23.5 起暴露
8
+ * zstdCompressSync / zstdDecompressSync,零依赖即可压缩/解压,
9
+ * Windows / Termux / Linux 行为一致。旧格式 session.jsonl.zstd(单帧
10
+ * 整文件)读取走同一帧扫描路径,单帧等效于解压整份。
11
+ * @module dsh-message-ops/session-file
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import os from "node:os";
17
+ import { constants, zstdCompressSync, zstdDecompressSync } from "node:zlib";
18
+
19
+ const ZSTD_MAGIC = 0xfd2fb528;
20
+
21
+ /** 与 DSH 宿主一致:帧带 checksum(ZSTD_c_checksumFlag=1)。 */
22
+ function compressFrame(input) {
23
+ return zstdCompressSync(Buffer.from(input, "utf8"), {
24
+ params: { [constants.ZSTD_c_checksumFlag]: 1 },
25
+ });
26
+ }
27
+
28
+ /**
29
+ * 扫描串联 zstd 流的完整帧边界(不解压块)。
30
+ * @returns {{frames: {start:number,end:number}[], tornStart?: number}}
31
+ */
32
+ export function scanZstdFrames(buf) {
33
+ const frames = [];
34
+ let offset = 0;
35
+ while (offset < buf.length) {
36
+ const start = offset;
37
+ if (buf.length - offset < 4) return { frames, tornStart: start };
38
+ if (buf.readUInt32LE(offset) !== ZSTD_MAGIC) {
39
+ throw new Error(`corrupt zstd session log: invalid frame magic at byte ${offset}`);
40
+ }
41
+ let end = buf.length;
42
+ for (let p = offset + 4; p <= buf.length - 4; p++) {
43
+ if (buf.readUInt32LE(p) === ZSTD_MAGIC) { end = p; break; }
44
+ }
45
+ frames.push({ start, end });
46
+ offset = end;
47
+ }
48
+ return { frames };
49
+ }
50
+
51
+ /**
52
+ * 读取会话日志 → { header, events }。撕裂的最终帧按宿主同款
53
+ * 「完整前缀」语义忽略(完整帧全部恢复)。
54
+ */
55
+ export function readSessionFile(file) {
56
+ const buf = fs.readFileSync(file);
57
+ const { frames } = scanZstdFrames(buf);
58
+ if (frames.length === 0) throw new Error("empty or header-less session log");
59
+ const headerText = zstdDecompressSync(buf.subarray(frames[0].start, frames[0].end)).toString("utf8");
60
+ const header = JSON.parse(headerText.trim());
61
+ if (header.type !== "session") throw new Error("first frame is not a session header");
62
+ const events = [];
63
+ for (const f of frames.slice(1)) {
64
+ const text = zstdDecompressSync(buf.subarray(f.start, f.end)).toString("utf8");
65
+ for (const line of text.split("\n")) {
66
+ const t = line.trim();
67
+ if (!t) continue;
68
+ try { events.push(JSON.parse(t)); } catch { /* torn record: skip */ }
69
+ }
70
+ }
71
+ return { header, events };
72
+ }
73
+
74
+ /** 物化整份日志:header 帧(恰一行)+ 单一事件帧(全部事件行)。 */
75
+ export function encodeSessionFile(header, events) {
76
+ const headerText = JSON.stringify(header) + "\n";
77
+ const bodyText = events.map((e) => JSON.stringify(e)).join("\n") + "\n";
78
+ return Buffer.concat([compressFrame(headerText), compressFrame(bodyText)]);
79
+ }
80
+
81
+ /** 会话根目录(尊重 DSH_HOME;Windows/Termux 通吃)。 */
82
+ export function sessionsRoot() {
83
+ const home = process.env.DSH_HOME || path.join(os.homedir(), ".dsh");
84
+ return path.join(home, "sessions");
85
+ }
86
+
87
+ const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
88
+
89
+ export function isSessionId(v) {
90
+ return typeof v === "string" && SESSION_ID_RE.test(v);
91
+ }
92
+
93
+ /** 会话 id 的两种拼写(带/不带 session- 前缀)。 */
94
+ export function sessionIdVariants(sessionId) {
95
+ const variants = new Set([sessionId]);
96
+ if (sessionId.startsWith("session-")) variants.add(sessionId.slice("session-".length));
97
+ else variants.add(`session-${sessionId}`);
98
+ return [...variants];
99
+ }
100
+
101
+ /**
102
+ * 在 sessions 根下定位会话目录:扫每个 project-slug 目录找 <slug>/<id>/,
103
+ * 避免在插件里重推 workspace 路径编码。
104
+ * @returns {{dir: string, logPath: string}[]}
105
+ */
106
+ export function findSessionDirs(sessionId) {
107
+ const root = sessionsRoot();
108
+ let slugs = [];
109
+ try { slugs = fs.readdirSync(root, { withFileTypes: true }); } catch { return []; }
110
+ const variants = sessionIdVariants(sessionId);
111
+ const found = [];
112
+ for (const slug of slugs) {
113
+ if (!slug.isDirectory()) continue;
114
+ for (const variant of variants) {
115
+ const dir = path.join(root, slug.name, variant);
116
+ try {
117
+ if (!fs.statSync(dir).isDirectory()) continue;
118
+ } catch { continue; }
119
+ const logPath = path.join(dir, "session.v3.jsonl.zstd");
120
+ try { fs.statSync(logPath); } catch { continue; }
121
+ if (!found.some((f) => f.dir === dir)) found.push({ dir, logPath });
122
+ }
123
+ }
124
+ return found;
125
+ }
126
+
127
+ /** 新建会话 id(与宿主同款 session-<uuid> 拼写)。 */
128
+ export function newSessionId() {
129
+ return `session-${crypto.randomUUID()}`;
130
+ }
131
+
132
+ /** 从事件流提取消息级列表(user/assistant/system message + tool/call 摘要)。 */
133
+ export function listMessages(events) {
134
+ const messages = [];
135
+ for (const e of events) {
136
+ if (!e || typeof e.seq !== "number") continue;
137
+ if (e.type === "user/message" || e.type === "assistant/message" || e.type === "system/message") {
138
+ const msg = e.data && e.data.message;
139
+ const content = msg && Array.isArray(msg.content) ? msg.content : (e.data && e.data.content);
140
+ let text = "";
141
+ if (Array.isArray(content)) {
142
+ for (const c of content) {
143
+ if (c && c.type === "text" && typeof c.text === "string" && c.text.trim()) {
144
+ text = c.text; break;
145
+ }
146
+ }
147
+ }
148
+ messages.push({
149
+ seq: e.seq,
150
+ type: e.type,
151
+ role: (msg && msg.role) || (e.type === "user/message" ? "user" : e.type === "assistant/message" ? "assistant" : "system"),
152
+ snippet: text.replace(/\s+/g, " ").trim().slice(0, 160),
153
+ time: e.time ?? null,
154
+ turn: (e.data && e.data.turn) ?? null,
155
+ });
156
+ } else if (e.type === "tool/call") {
157
+ const d = e.data || {};
158
+ const name = d.name || (d.call && d.call.name) || "tool";
159
+ messages.push({
160
+ seq: e.seq, type: "tool/call", role: "tool", snippet: `[tool] ${name}`,
161
+ time: e.time ?? null, turn: d.turn ?? null,
162
+ });
163
+ }
164
+ }
165
+ return messages;
166
+ }
167
+
168
+ /**
169
+ * 读取一条 replace surfaceOp 的区间(共用单点,读写端拼写必须一致)。
170
+ * 兼容两种拼写:当前运行时 {op:'replace', startSeq, endSeq} 与
171
+ * dsh-src 较新副本的 {op:'replace', start, end};其余视为非法返回 null。
172
+ */
173
+ export function readReplaceOp(e) {
174
+ const op = e && e.surfaceOp;
175
+ if (!op || typeof op !== "object" || op.op !== "replace") return null;
176
+ const startSeq = Number.isSafeInteger(op.startSeq) ? op.startSeq : op.start;
177
+ const endSeq = Number.isSafeInteger(op.endSeq) ? op.endSeq : op.end;
178
+ if (!Number.isSafeInteger(startSeq) || !Number.isSafeInteger(endSeq)) return null;
179
+ return { startSeq, endSeq };
180
+ }
181
+
182
+ /**
183
+ * 从磁盘事件流推导「被遮蔽」seq 集合:后来发生的 surface replace 事件
184
+ * 遮蔽 [startSeq..endSeq](append-only:遮蔽仍在日志但不再可见)。
185
+ */
186
+ export function computeShadowed(events) {
187
+ const shadowed = new Set();
188
+ for (const e of events) {
189
+ const range = readReplaceOp(e);
190
+ if (!range) continue;
191
+ for (let s = range.startSeq; s <= range.endSeq; s++) shadowed.add(s);
192
+ }
193
+ return shadowed;
194
+ }
195
+
196
+ const yieldToLoop = () => new Promise((resolve) => setImmediate(resolve));
197
+
198
+ /**
199
+ * readSessionFile 的异步变体:逐帧解压循环每 framesPerYield 帧向事件循环
200
+ * 让出一次(setImmediate),避免大日志把 GUI 的 tick 卡死数秒。
201
+ * 文件读取与帧边界扫描仍是同步单次系统调用/字节扫描(成本低);真正昂贵
202
+ * 的 zstdDecompressSync + JSON.parse 在让出点之间执行。
203
+ * @returns {{header, events, frameCount, partial}}
204
+ * partial:帧数超过 frameBudget(默认 500)时为 true,调用方应向
205
+ * 用户提示「仅完整读取,无截断,但本次请求耗时可能较长」。
206
+ */
207
+ export async function readSessionFileAsync(file, { framesPerYield = 8, frameBudget = 500 } = {}) {
208
+ const buf = fs.readFileSync(file);
209
+ const { frames } = scanZstdFrames(buf);
210
+ if (frames.length === 0) throw new Error("empty or header-less session log");
211
+ const headerText = zstdDecompressSync(buf.subarray(frames[0].start, frames[0].end)).toString("utf8");
212
+ const header = JSON.parse(headerText.trim());
213
+ if (header.type !== "session") throw new Error("first frame is not a session header");
214
+ const events = [];
215
+ const bodyFrames = frames.slice(1);
216
+ let framesSinceYield = 0;
217
+ for (const f of bodyFrames) {
218
+ const text = zstdDecompressSync(buf.subarray(f.start, f.end)).toString("utf8");
219
+ for (const line of text.split("\n")) {
220
+ const t = line.trim();
221
+ if (!t) continue;
222
+ try { events.push(JSON.parse(t)); } catch { /* torn record: skip */ }
223
+ }
224
+ if (++framesSinceYield >= framesPerYield) {
225
+ framesSinceYield = 0;
226
+ await yieldToLoop();
227
+ }
228
+ }
229
+ return { header, events, frameCount: bodyFrames.length, partial: bodyFrames.length > frameBudget };
230
+ }