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/README.en.md +12 -4
- package/README.md +12 -4
- package/lib/client.js +130 -92
- package/lib/client.js.map +2 -2
- package/lib/index.js +904 -241
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +154 -90
- package/src/client/logic.js +13 -1
- package/src/compat/capabilities.js +64 -8
- package/src/compat/persistence.js +137 -18
- package/src/handle-era-ops.js +185 -0
- package/src/handle-era-paths.js +126 -0
- package/src/index.js +373 -96
- package/src/markdown.js +122 -71
- package/src/path-guard.js +59 -0
- package/src/session-meta-cache.js +40 -13
- package/src/title-persist-index.js +6 -1
- package/src/zstd-frame.js +68 -34
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('', ``)
|
|
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
|
|
167
|
+
const out = []
|
|
93
168
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
114
|
-
if (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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
//
|
|
9
|
-
// 而任何append/改名/移动都会更新日志文件的 mtime。所以只要记下当时的
|
|
10
|
-
// (mtimeMs, size),下次 stat 到相同指纹就可以直接复用缓存,跳过整本解码。
|
|
8
|
+
// 指纹有两种来源(按 runtime 能力自动选择,调用方构造 stat 对象):
|
|
11
9
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
10
|
+
// 1. 文件指纹(legacy runtime):记下日志的 (mtimeMs, size)。任何
|
|
11
|
+
// append/改名/移动都会更新 mtime,所以「stat 相同 ⇒ 内容没变」。
|
|
12
|
+
// 该指纹可跨进程持久化(title-persist-index 用它做冷启动加速)。
|
|
15
13
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
-
|
|
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 || (
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
* @
|
|
28
|
+
* @param {number} maxFrames
|
|
29
|
+
* @returns {{frames: Array<{start:number,end:number}>, tornStart?: number}}
|
|
31
30
|
*/
|
|
32
|
-
export function
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
|
68
|
-
|
|
69
|
-
const
|
|
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
|
|
95
|
-
|
|
96
|
-
const
|
|
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
|
|
132
|
-
|
|
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
|
}
|