dsh-sessions-manager 3.5.0 → 3.5.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/src/markdown.js CHANGED
@@ -75,6 +75,83 @@ export function summarizeToolArguments(name, rawArguments) {
75
75
  return JSON.stringify(rest).slice(0, MAX_TOOL_ARG)
76
76
  }
77
77
 
78
+ // Shared event fold. `out` accumulates markdown lines; both the one-shot
79
+ // renderer and the streaming builder (large-log export, see export-md route)
80
+ // consume it so their output is byte-identical for the same events.
81
+ function createMarkdownFold(out, header, options) {
82
+ const includeReasoning = options.includeReasoning === true
83
+ const includeToolResults = options.includeToolResults === true
84
+ let title = typeof header.title === 'string' && header.title.trim() ? header.title.trim() : null
85
+ let turn = null
86
+ return {
87
+ get title() { return title },
88
+ // The last session/title event wins — DSH may retitle a session later on.
89
+ add(events) {
90
+ const list = Array.isArray(events) ? events : []
91
+ for (const ev of list) {
92
+ if (!ev || typeof ev !== 'object') continue
93
+ const data = ev.data && typeof ev.data === 'object' ? ev.data : {}
94
+ const type = ev.type
95
+
96
+ if (type === 'session/title' && data && typeof data.title === 'string' && data.title.trim()) {
97
+ title = data.title.trim()
98
+ continue
99
+ }
100
+
101
+ if (type === 'turn/start') {
102
+ const next = Number.isInteger(data.turn) ? data.turn : null
103
+ if (next !== null && next !== turn) {
104
+ turn = next
105
+ out.push('', `## 第 ${turn} 轮`)
106
+ }
107
+ continue
108
+ }
109
+
110
+ if (type === 'user/message') {
111
+ const blocks = blocksOf(data.content)
112
+ const text = textFromBlocks(blocks)
113
+ const images = imageCountOf(blocks)
114
+ if (!text && images === 0) continue
115
+ out.push('', '### 用户', '')
116
+ if (text) out.push(text)
117
+ for (let i = 0; i < images; i++) out.push('', `![图片 ${i + 1}](attachment)`)
118
+ continue
119
+ }
120
+
121
+ if (type === 'assistant/message') {
122
+ const message = data.message && typeof data.message === 'object' ? data.message : {}
123
+ const blocks = blocksOf(message.content)
124
+ const text = textFromBlocks(blocks)
125
+ const reasoning = includeReasoning ? reasoningFromBlocks(blocks) : ''
126
+ if (!text && !reasoning) continue
127
+ out.push('', '### 助手', '')
128
+ if (reasoning) out.push('> 思考:' + reasoning.split('\n').join('\n> '), '')
129
+ if (text) out.push(text)
130
+ continue
131
+ }
132
+
133
+ if (type === 'tool/call') {
134
+ const name = typeof data.name === 'string' && data.name ? data.name : 'tool'
135
+ const summary = summarizeToolArguments(name, data.arguments)
136
+ out.push('', `### 工具调用:\`${name}\``, '')
137
+ out.push(summary ? '```\n' + summary + '\n```' : '(无参数)')
138
+ continue
139
+ }
140
+
141
+ if (type === 'tool/result' && includeToolResults) {
142
+ const message = data.message && typeof data.message === 'object' ? data.message : {}
143
+ const blocks = blocksOf(message.content)
144
+ let text = ''
145
+ for (const block of blocks) {
146
+ if (block.type === 'tool-result') text = textFromBlocks(blocksOf(block.content))
147
+ }
148
+ if (text) out.push('', '<details><summary>工具结果</summary>', '', '```\n' + text.slice(0, 2000) + '\n```', '', '</details>')
149
+ }
150
+ }
151
+ },
152
+ }
153
+ }
154
+
78
155
  /**
79
156
  * Render one session as Markdown.
80
157
  * @param {object} meta - Session header (`{ id, cwd, createdAt, title? }`).
@@ -86,19 +163,12 @@ export function summarizeToolArguments(name, rawArguments) {
86
163
  * @returns {string} Markdown document.
87
164
  */
88
165
  export function renderSessionMarkdown(meta, events, options = {}) {
89
- const includeReasoning = options.includeReasoning === true
90
- const includeToolResults = options.includeToolResults === true
91
166
  const header = meta && typeof meta === 'object' ? meta : {}
92
- const list = Array.isArray(events) ? events : []
167
+ const out = []
93
168
 
94
- // The last session/title event wins — DSH may retitle a session later on.
95
- let title = typeof header.title === 'string' && header.title.trim() ? header.title.trim() : null
96
- for (const ev of list) {
97
- const data = ev && ev.data
98
- if (ev && ev.type === 'session/title' && data && typeof data.title === 'string' && data.title.trim()) {
99
- title = data.title.trim()
100
- }
101
- }
169
+ const fold = createMarkdownFold(out, header, options)
170
+ fold.add(events)
171
+ const title = fold.title
102
172
 
103
173
  const front = ['---']
104
174
  if (title) front.push(`title: ${yamlString(title)}`)
@@ -110,66 +180,47 @@ export function renderSessionMarkdown(meta, events, options = {}) {
110
180
  if (exported) front.push(`exportedAt: ${exported}`)
111
181
  front.push('---')
112
182
 
113
- const out = [front.join('\n')]
114
- if (title) out.push('', `# ${title}`)
183
+ const doc = [front.join('\n')]
184
+ if (title) doc.push('', `# ${title}`)
185
+ doc.push(...out, '')
186
+ return doc.join('\n')
187
+ }
115
188
 
116
- let turn = null
117
- for (const ev of list) {
118
- if (!ev || typeof ev !== 'object') continue
119
- const data = ev.data && typeof ev.data === 'object' ? ev.data : {}
120
- const type = ev.type
121
-
122
- if (type === 'turn/start') {
123
- const next = Number.isInteger(data.turn) ? data.turn : null
124
- if (next !== null && next !== turn) {
125
- turn = next
126
- out.push('', `## 第 ${turn} 轮`)
127
- }
128
- continue
129
- }
130
-
131
- if (type === 'user/message') {
132
- const blocks = blocksOf(data.content)
133
- const text = textFromBlocks(blocks)
134
- const images = imageCountOf(blocks)
135
- if (!text && images === 0) continue
136
- out.push('', '### 用户', '')
137
- if (text) out.push(text)
138
- for (let i = 0; i < images; i++) out.push('', `![图片 ${i + 1}](attachment)`)
139
- continue
140
- }
141
-
142
- if (type === 'assistant/message') {
143
- const message = data.message && typeof data.message === 'object' ? data.message : {}
144
- const blocks = blocksOf(message.content)
145
- const text = textFromBlocks(blocks)
146
- const reasoning = includeReasoning ? reasoningFromBlocks(blocks) : ''
147
- if (!text && !reasoning) continue
148
- out.push('', '### 助手', '')
149
- if (reasoning) out.push('> 思考:' + reasoning.split('\n').join('\n> '), '')
150
- if (text) out.push(text)
151
- continue
152
- }
153
-
154
- if (type === 'tool/call') {
155
- const name = typeof data.name === 'string' && data.name ? data.name : 'tool'
156
- const summary = summarizeToolArguments(name, data.arguments)
157
- out.push('', `### 工具调用:\`${name}\``, '')
158
- out.push(summary ? '```\n' + summary + '\n```' : '(无参数)')
159
- continue
160
- }
161
-
162
- if (type === 'tool/result' && includeToolResults) {
163
- const message = data.message && typeof data.message === 'object' ? data.message : {}
164
- const blocks = blocksOf(message.content)
165
- let text = ''
166
- for (const block of blocks) {
167
- if (block.type === 'tool-result') text = textFromBlocks(blocksOf(block.content))
168
- }
169
- if (text) out.push('', '<details><summary>工具结果</summary>', '', '```\n' + text.slice(0, 2000) + '\n```', '', '</details>')
170
- }
189
+ /**
190
+ * Streaming variant of {@link renderSessionMarkdown} for chunked log reads
191
+ * (SessionHandle.read offset/length). Feed event batches in log order via
192
+ * `addEvents`; call `finish()` to get the same markdown document the one-shot
193
+ * renderer would produce. Only the final title (last session/title event)
194
+ * lands in the front matter, so streaming cannot be wrong about it.
195
+ *
196
+ * `finish(metaOverride)` — when the caller streams first and only learns the
197
+ * authoritative header afterwards (adapter inspectSession returns `meta` with
198
+ * the summary), pass it here; it replaces the constructor `meta` for the front
199
+ * matter fields (id / cwd / createdAt). Title always comes from the folded
200
+ * events, never from the override.
201
+ */
202
+ export function createSessionMarkdownBuilder(meta, options = {}) {
203
+ const header = meta && typeof meta === 'object' ? meta : {}
204
+ const body = []
205
+ const fold = createMarkdownFold(body, header, options)
206
+ return {
207
+ addEvents(events) { fold.add(events) },
208
+ finish(metaOverride) {
209
+ const effective = metaOverride && typeof metaOverride === 'object' ? metaOverride : header
210
+ const title = fold.title
211
+ const front = ['---']
212
+ if (title) front.push(`title: ${yamlString(title)}`)
213
+ if (typeof effective.id === 'string' && effective.id) front.push(`sessionId: ${yamlString(effective.id)}`)
214
+ if (typeof effective.cwd === 'string' && effective.cwd) front.push(`cwd: ${yamlString(effective.cwd)}`)
215
+ const created = isoTime(effective.createdAt)
216
+ if (created) front.push(`createdAt: ${created}`)
217
+ const exported = isoTime(options.exportedAt)
218
+ if (exported) front.push(`exportedAt: ${exported}`)
219
+ front.push('---')
220
+ const doc = [front.join('\n')]
221
+ if (title) doc.push('', `# ${title}`)
222
+ doc.push(...body, '')
223
+ return doc.join('\n')
224
+ },
171
225
  }
172
-
173
- out.push('')
174
- return out.join('\n')
175
226
  }
@@ -0,0 +1,59 @@
1
+ // path-guard.js — 回收站/彻底删除前的「日志路径归属」校验(纯函数)。
2
+ //
3
+ // purgeFromTrash 在物理 unlink 前必须确认目标路径真的属于该会话,防止把
4
+ // 无关文件删掉。旧实现用 `basename(dirname(target)) === sid`,只对 POSIX
5
+ // 分隔符成立:Windows 风格路径(`C:\\…\\<sid>\\session.jsonl.zstd`)在
6
+ // POSIX 版 path.basename 下整串是一个 basename,校验会错误拒绝;反过来,
7
+ // 混合分隔符或 URL 编码路径也可能造成误放行。这里统一按两种分隔符切分,
8
+ // 并处理盘符前缀与尾部斜杠。
9
+ //
10
+ // 接受两种官方/历史布局:
11
+ // .../<sessionId>/session.jsonl.zstd (目录名 = 会话 id)
12
+ // .../<sessionId>.jsonl.zstd (旧后端:文件名含会话 id)
13
+
14
+ function splitSegments(target) {
15
+ return String(target)
16
+ .replace(/[\\/]+$/, '')
17
+ .split(/[\\/]/)
18
+ .filter((seg) => seg.length > 0)
19
+ }
20
+
21
+ // 去掉 Windows 盘符段("C:"),保留其余段。POSIX 路径不含盘符段:
22
+ // 一个名为 "C:" 的目录段在 macOS/Linux 上合法但极罕见,把它当盘符
23
+ // 处理对「会话 id 归属」判断没有影响(id 不会是 "C:")。
24
+ function stripDriveLetter(segments) {
25
+ return segments.length > 0 && /^[A-Za-z]:$/.test(segments[0]) ? segments.slice(1) : segments
26
+ }
27
+
28
+ /**
29
+ * Does `target` plausibly own `sid`'s stored log?
30
+ * @param {string} target - absolute-ish log path reported by the backend or the trash index.
31
+ * @param {string} sid - session id (already validated by isSafeSessionId: no separators).
32
+ * @returns {boolean}
33
+ */
34
+ export function pathOwnsSession(target, sid) {
35
+ if (typeof target !== 'string' || target.length === 0) return false
36
+ if (typeof sid !== 'string' || sid.length === 0) return false
37
+ const segments = stripDriveLetter(splitSegments(target))
38
+ if (segments.length === 0) return false
39
+ const file = segments[segments.length - 1]
40
+ // Layout 1: the session id owns the parent directory.
41
+ if (segments.length >= 2 && segments[segments.length - 2] === sid) return true
42
+ // Layout 2: legacy flat layout — the id is part of the file name itself.
43
+ // Only a *standalone token* match counts: `sid` embedded in a longer id
44
+ // (abc ↔ abcdef) must NOT pass, otherwise a purge of `abc` could delete
45
+ // `abcdef`'s log. Extension punctuation (".jsonl.zstd") is not id-glue.
46
+ if (file.includes(sid)) {
47
+ const ID_CHAR = /[A-Za-z0-9_-]/
48
+ let from = 0
49
+ while (true) {
50
+ const at = file.indexOf(sid, from)
51
+ if (at < 0) return false
52
+ const before = at > 0 ? file[at - 1] : ''
53
+ const after = at + sid.length < file.length ? file[at + sid.length] : ''
54
+ if (!(before && ID_CHAR.test(before)) && !(after && ID_CHAR.test(after))) return true
55
+ from = at + 1
56
+ }
57
+ }
58
+ return false
59
+ }
@@ -1,21 +1,31 @@
1
- // session-meta-cache.js — 会话「原始元数据」内存缓存(按日志文件指纹校验)。
1
+ // session-meta-cache.js — 会话「原始元数据」内存缓存(按日志内容指纹校验)。
2
2
  //
3
3
  // 背景(issue #1):列表构建原本对每条会话调用 readTitleSnapshot,而该调用会把
4
4
  // 会话日志(.jsonl.zstd)的**所有 zstd 帧**逐帧解压、逐行 JSON.parse,只为折叠出
5
5
  // 最新标题。大库(数十条会话、十万级帧)一次全表要几秒 CPU,且解码是同步块,
6
6
  // 会阻塞宿主事件循环,连累 session.history 之类的 RPC 超时。
7
7
  //
8
- // 关键观察:解码得出的元数据(title / cwd / createdAt)只随**日志文件内容**变化,
9
- // 而任何append/改名/移动都会更新日志文件的 mtime。所以只要记下当时的
10
- // (mtimeMs, size),下次 stat 到相同指纹就可以直接复用缓存,跳过整本解码。
8
+ // 指纹有两种来源(按 runtime 能力自动选择,调用方构造 stat 对象):
11
9
  //
12
- // 为什么自己实现而不用 runtime 的 prepared 缓存:插件不能假设对方的 runtime 版本
13
- // (issue 报告者是 0.1.1-rc.2,本机是 0.1.2-rc.1),runtime 侧的缓存容量/命中策略
14
- // 各版本不同。本模块只用 node 原生能力与插件已有的 sp.locate + stat,任何版本行为一致。
10
+ // 1. 文件指纹(legacy runtime):记下日志的 (mtimeMs, size)。任何
11
+ // append/改名/移动都会更新 mtime,所以「stat 相同 ⇒ 内容没变」。
12
+ // 该指纹可跨进程持久化(title-persist-index 用它做冷启动加速)。
15
13
  //
16
- // 失效策略(三重保险):
17
- // 1. 指纹校验:mtimeMs 或 size 任一变化即视为过期(append 改 mtime、移动改写 frame0 也改 mtime)
18
- // 2. TTL:防止「mtime 精度/时钟回拨」导致的长期陈旧
14
+ // 2. revision 指纹(SessionHandle 世代 runtime,0.1.3+):sessionPersistence
15
+ // 的 list()/stat() 返回 SessionPersistenceSnapshot,其 `revision` 是
16
+ // **不透明变更令牌**。官方契约:同一 service 实例、同一 session id 内,
17
+ // revision 相等可视为日志未变;除此之外 revision 不做任何承诺。
18
+ // ⚠️ 因此 revision 指纹**绝不能写入跨进程的持久缓存**(不同进程/重启后
19
+ // revision 值无意义,误用可能把陈旧数据当新鲜数据)。持久索引落盘前必须
20
+ // 用 isPersistableFingerprint() 过滤。
21
+ //
22
+ // 为什么自己实现而不用 runtime 的 prepared 缓存:插件不能假设对方的 runtime 版本,
23
+ // runtime 侧的缓存容量/命中策略各版本不同。本模块只用纯数据判定,任何版本行为一致。
24
+ //
25
+ // 失效策略:
26
+ // 1. 指纹校验:revision 不相等 / mtimeMs 或 size 任一变化即视为过期
27
+ // 2. TTL:仅对文件指纹生效(防 mtime 精度/时钟回拨);revision 相等即权威,
28
+ // 不受 TTL 影响(官方契约明文允许 treat equal revisions as unchanged)
19
29
  // 3. 显式 invalidate:删除 / 移动 / 归档等宿主操作后主动丢弃对应条目
20
30
  //
21
31
  // 纯逻辑与副作用分离:isFresh / partitionByCache 都是纯函数,便于单测。
@@ -23,11 +33,18 @@
23
33
  const DEFAULT_TTL_MS = 5 * 60 * 1000
24
34
  const DEFAULT_MAX = 4000
25
35
 
26
- // 文件指纹:只有同时拿到 mtime 与 size 才可信(两者都变才算内容变了)。
36
+ const REVISION_PREFIX = 'rev:'
37
+
38
+ // 文件指纹:只有同时拿到 mtime 与 size 才可信。
27
39
  // 拿不到 stat 信息时返回 null——表示「无法校验」,调用方必须按未命中处理,
28
40
  // 绝不能在有疑问时返回旧数据。
29
41
  export function fingerprintOf(stat) {
30
42
  if (!stat || typeof stat !== 'object') return null
43
+ // revision 指纹优先:SessionHandle 世代没有可靠的 locate/stat,
44
+ // snapshot.revision 是官方提供的唯一变更令牌。
45
+ if (typeof stat.revision === 'string' && stat.revision.length > 0) {
46
+ return REVISION_PREFIX + stat.revision
47
+ }
31
48
  const mtimeMs = stat.mtimeMs
32
49
  const size = stat.size
33
50
  if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs) || mtimeMs <= 0) return null
@@ -35,18 +52,27 @@ export function fingerprintOf(stat) {
35
52
  return `${Math.floor(mtimeMs)}:${size}`
36
53
  }
37
54
 
55
+ // revision 指纹只在当前 service 实例内有意义,绝不能落盘作为跨进程指纹。
56
+ // title-persist-index 等持久化层必须在写入前用它过滤。
57
+ export function isPersistableFingerprint(fingerprint) {
58
+ return typeof fingerprint === 'string' && fingerprint !== '' && !fingerprint.startsWith(REVISION_PREFIX)
59
+ }
60
+
38
61
  // 缓存条目是否仍然新鲜(纯函数)。
62
+ // stat 传 { revision } 或 { mtimeMs, size };两类指纹不能互相匹配。
39
63
  export function isFresh(entry, stat, now, ttlMs = DEFAULT_TTL_MS) {
40
64
  if (!entry) return false
41
65
  const fp = fingerprintOf(stat)
42
66
  if (!fp) return false
43
67
  if (entry.fingerprint !== fp) return false
44
68
  if (typeof entry.at !== 'number') return false
69
+ // revision 指纹不受 TTL 约束:契约允许把相等 revision 视为日志未变。
70
+ if (fp.startsWith(REVISION_PREFIX)) return true
45
71
  return (now - entry.at) <= ttlMs
46
72
  }
47
73
 
48
74
  // 把一批 id 分成「命中缓存」与「需要解码」两组(纯函数,便于单测)。
49
- // statsById: Map<id, {mtimeMs, size}>;cache: 与 SessionMetaCache 同构的 Map。
75
+ // statsById: Map<id, {mtimeMs, size} | {revision}>;cache: 与 SessionMetaCache 同构的 Map。
50
76
  export function partitionByCache(ids, statsById, cache, now = Date.now(), ttlMs = DEFAULT_TTL_MS) {
51
77
  const cached = new Map()
52
78
  const missing = []
@@ -87,7 +113,8 @@ export function createSessionMetaCache(opts = {}) {
87
113
  set(id, stat, meta) {
88
114
  if (!meta) return null
89
115
  const fp = fingerprintOf(stat)
90
- // 无法算出指纹(没 stat / stat 失败)时不写缓存:写进去就再也无法可靠失效。
116
+ // 无法算出指纹(没 stat / revision 缺失 / stat 失败)时不写缓存:
117
+ // 写进去就再也无法可靠失效。
91
118
  if (!fp) return null
92
119
  const key = String(id)
93
120
  map.delete(key)
@@ -26,6 +26,10 @@ const MAX_ENTRIES = 20000
26
26
 
27
27
  // 条目只保留可序列化且对列表有用的字段;指纹缺失的条目无法校验,直接丢弃——
28
28
  // 宁可下次重解码,也不能把无法失效的数据当真。
29
+ // ⚠️ revision 指纹("rev:…")只在当前 service 实例内有意义(官方 0.1.3 契约:
30
+ // opaque token, same instance + same session id),绝不能落盘作为跨进程指纹——
31
+ // 这里作为最后防线再次拦截(调用方 session-meta-cache.isPersistableFingerprint
32
+ // 已先行过滤)。
29
33
  export function normalizeEntry(raw) {
30
34
  if (!raw || typeof raw !== 'object') return null
31
35
  const title = typeof raw.title === 'string' ? raw.title : null
@@ -33,7 +37,8 @@ export function normalizeEntry(raw) {
33
37
  const createdAt = typeof raw.createdAt === 'number' ? raw.createdAt : null
34
38
  const fingerprint = typeof raw.fingerprint === 'string' && raw.fingerprint ? raw.fingerprint : null
35
39
  const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
36
- if (!fingerprint || (!title && !cwd)) return null
40
+ if (!fingerprint || fingerprint.startsWith('rev:')) return null
41
+ if (!title && !cwd) return null
37
42
  return { title, cwd, createdAt, fingerprint, updatedAt }
38
43
  }
39
44
 
package/src/zstd-frame.js CHANGED
@@ -20,33 +20,71 @@ export const ZSTD_MAGIC = 0xFD2FB528
20
20
  const CHECKSUM_OPTS = { params: { [zlib.constants.ZSTD_c_checksumFlag]: 1 } }
21
21
 
22
22
  /**
23
- * Locate real zstd frame boundaries in a concatenated-frame buffer.
24
- *
25
- * Scanning for the 4-byte magic alone produces FALSE POSITIVES: the same byte
26
- * sequence can occur inside compressed data. Every candidate is therefore
27
- * validated by attempting decompression; only offsets that decode are kept.
23
+ * Parse complete concatenated Zstandard frames without decompressing them.
24
+ * Invalid complete structure rejects; EOF inside the final frame is reported
25
+ * as torn rather than guessed from magic bytes occurring in compressed data.
28
26
  *
29
27
  * @param {Buffer} buf
30
- * @returns {number[]} ascending offsets of real frame starts
28
+ * @param {number} maxFrames
29
+ * @returns {{frames: Array<{start:number,end:number}>, tornStart?: number}}
31
30
  */
32
- export function findZstdFrameStarts(buf) {
33
- const starts = []
34
- for (let i = 0; i + 4 <= buf.length; i++) {
35
- if (buf.readUInt32LE(i) !== ZSTD_MAGIC) continue
36
- try {
37
- // Two checks are needed, not just one:
38
- // - a magic inside compressed data fails to decode and throws
39
- // - a BARE 4-byte magic at the very end of the buffer decodes to an
40
- // EMPTY result without throwing, so non-empty output is required too
41
- // Every real frame carries at least one JSON line, so neither case can
42
- // be a genuine frame start.
43
- const out = zlib.zstdDecompressSync(buf.subarray(i, i + Math.min(buf.length - i, 1000000)))
44
- if (out.length > 0) starts.push(i)
45
- } catch (_) {
46
- // Not a real frame boundary — the magic bytes occurred inside compressed data.
31
+ export function scanZstdFrames(buf, maxFrames = Number.POSITIVE_INFINITY) {
32
+ const frames = []
33
+ let offset = 0
34
+ while (offset < buf.length) {
35
+ const start = offset
36
+ if (buf.length - offset < 4) return { frames, tornStart: start }
37
+ if (buf.readUInt32LE(offset) !== ZSTD_MAGIC) {
38
+ throw new Error(`会话日志格式异常(字节 ${offset} 的 zstd magic 无效)`)
39
+ }
40
+ offset += 4
41
+ if (offset === buf.length) return { frames, tornStart: start }
42
+
43
+ const descriptor = buf.readUInt8(offset++)
44
+ if ((descriptor & 0x18) !== 0) throw new Error(`会话日志格式异常(字节 ${offset - 1} 使用保留帧头位)`)
45
+ const contentSizeFlag = descriptor >>> 6
46
+ const singleSegment = (descriptor & 0x20) !== 0
47
+ const checksum = (descriptor & 0x04) !== 0
48
+ const dictionaryFlag = descriptor & 0x03
49
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
50
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
51
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
52
+ if (buf.length - offset < remainingHeaderBytes) return { frames, tornStart: start }
53
+ offset += remainingHeaderBytes
54
+
55
+ for (;;) {
56
+ if (buf.length - offset < 3) return { frames, tornStart: start }
57
+ const blockHeader = buf.readUIntLE(offset, 3)
58
+ offset += 3
59
+ const lastBlock = (blockHeader & 1) !== 0
60
+ const blockType = (blockHeader >>> 1) & 0x03
61
+ const blockSize = blockHeader >>> 3
62
+ if (blockType === 0x03) throw new Error(`会话日志格式异常(字节 ${offset - 3} 使用保留块类型)`)
63
+ const payloadBytes = blockType === 0x01 ? 1 : blockSize
64
+ if (buf.length - offset < payloadBytes) return { frames, tornStart: start }
65
+ offset += payloadBytes
66
+ if (lastBlock) break
67
+ }
68
+ if (checksum) {
69
+ if (buf.length - offset < 4) return { frames, tornStart: start }
70
+ offset += 4
47
71
  }
72
+ frames.push({ start, end: offset })
73
+ if (frames.length === maxFrames) return { frames }
48
74
  }
49
- return starts
75
+ return { frames }
76
+ }
77
+
78
+ /** Backward-compatible frame-start view used by diagnostics and tests. */
79
+ export function findZstdFrameStarts(buf) {
80
+ return scanZstdFrames(buf).frames.map((frame) => frame.start)
81
+ }
82
+
83
+ function firstFrame(buf) {
84
+ const scan = scanZstdFrames(buf, 1)
85
+ const frame = scan.frames[0]
86
+ if (!frame) throw new Error('会话日志格式异常(无完整 zstd 帧)')
87
+ return frame
50
88
  }
51
89
 
52
90
  /**
@@ -64,10 +102,9 @@ export function findZstdFrameStarts(buf) {
64
102
  */
65
103
  export function rewriteFrame0Cwd(filePath, newCwd) {
66
104
  const buf = readFileSync(filePath)
67
- const starts = findZstdFrameStarts(buf)
68
- if (starts.length === 0) throw new Error('会话日志格式异常(无 zstd 帧)')
69
- const end0 = starts.length > 1 ? starts[1] : buf.length
70
- const frame0 = buf.subarray(starts[0], end0)
105
+ const frame = firstFrame(buf)
106
+ const end0 = frame.end
107
+ const frame0 = buf.subarray(frame.start, end0)
71
108
  const text = zlib.zstdDecompressSync(frame0).toString('utf8')
72
109
  const nl = text.indexOf('\n')
73
110
  const line = nl >= 0 ? text.slice(0, nl) : text
@@ -91,10 +128,9 @@ export function rewriteFrame0Cwd(filePath, newCwd) {
91
128
  * @returns {Buffer} rewritten log
92
129
  */
93
130
  export function rewriteFrame0CwdInMemory(buf, newCwd) {
94
- const starts = findZstdFrameStarts(buf)
95
- if (starts.length === 0) throw new Error('会话日志格式异常(无 zstd 帧)')
96
- const end0 = starts.length > 1 ? starts[1] : buf.length
97
- const frame0 = buf.subarray(starts[0], end0)
131
+ const frame = firstFrame(buf)
132
+ const end0 = frame.end
133
+ const frame0 = buf.subarray(frame.start, end0)
98
134
  const text = zlib.zstdDecompressSync(frame0).toString('utf8')
99
135
  const nl = text.indexOf('\n')
100
136
  const line = nl >= 0 ? text.slice(0, nl) : text
@@ -128,10 +164,8 @@ export function buildSessionLog(header, events = []) {
128
164
  * @returns {{obj: object, lineCount: number}}
129
165
  */
130
166
  export function readFrame0(buf) {
131
- const starts = findZstdFrameStarts(buf)
132
- if (starts.length === 0) throw new Error('会话日志格式异常(无 zstd 帧)')
133
- const end0 = starts.length > 1 ? starts[1] : buf.length
134
- const text = zlib.zstdDecompressSync(buf.subarray(starts[0], end0)).toString('utf8')
167
+ const frame = firstFrame(buf)
168
+ const text = zlib.zstdDecompressSync(buf.subarray(frame.start, frame.end)).toString('utf8')
135
169
  const lines = text.split('\n').filter((l) => l.length > 0)
136
170
  return { obj: JSON.parse(lines[0]), lineCount: lines.length }
137
171
  }