dsh-session-flow 0.1.0

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/lib/archive.js ADDED
@@ -0,0 +1,272 @@
1
+ // lib/archive.js — 会话存档核心:扫描 / 解码 / 解析 / 统计。
2
+ //
3
+ // 数据来源:~/.dsh/sessions/<workspace>/session-<uuid>/session.jsonl.zstd
4
+ // (zstd 压缩的多帧 JSONL;compression: none 时为明文 session.jsonl)。
5
+ //
6
+ // 解码使用 Node 内置 node:zlib 的 zstd 支持(Node >= 22.19),零第三方依赖;
7
+ // 帧扫描算法借鉴自 @deepseek-ai/dsh-session-persistence-jsonl (MIT)。
8
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { zstdDecompressSync } from 'node:zlib'
11
+
12
+ const ZSTD_MAGIC = 4247762216 // 0xFD2FB528 little-endian
13
+
14
+ // ── 路径提取(M5c 内容级检索的索引基础)──────────────────────────
15
+ const PATH_KEY_RE = /^(file|path|dir|directory|local|remote|target|source|destination|workdir|remotePath|localPath|file_path|output)(Path|Dir|File)?$/i
16
+
17
+ /** 字符串是否像文件路径(防噪:排除命令文本/通配符/超长串)。 */
18
+ export function looksLikePath(value) {
19
+ if (value.length < 2 || value.length > 200) return false
20
+ if (value.includes('*') || value.includes('?')) return false
21
+ if (/^[A-Za-z]:[\\/]/.test(value)) return true // 盘符绝对路径
22
+ if (/^[\\/]|^\.{1,2}[\\/]|^~[\\/]/.test(value)) return true // 根/相对/家目录
23
+ if (value.includes(' ')) return false // 含空格基本是命令文本
24
+ if (value.includes('\\') || value.includes('/')) return true
25
+ if (/^[\w.-]+(\.[A-Za-z0-9]{1,6})$/.test(value)) return true // 裸文件名带扩展名
26
+ return false
27
+ }
28
+
29
+ /** 从工具调用参数 JSON 里提取疑似文件路径(启发式,防噪:只收字符串值)。 */
30
+ export function extractPaths(name, argumentsText) {
31
+ const out = new Set()
32
+ if (!argumentsText) return out
33
+ let args
34
+ try { args = JSON.parse(argumentsText) } catch { return out }
35
+ const walk = (value, key) => {
36
+ if (typeof value === 'string' && value.length > 0 && value.length < 512) {
37
+ if ((key !== undefined && PATH_KEY_RE.test(key)) || looksLikePath(value)) {
38
+ if (out.size < 40) out.add(value)
39
+ }
40
+ } else if (Array.isArray(value)) {
41
+ for (const item of value) walk(item, key)
42
+ } else if (value !== null && typeof value === 'object') {
43
+ for (const [k, v] of Object.entries(value)) walk(v, k)
44
+ }
45
+ }
46
+ walk(args, undefined)
47
+ return out
48
+ }
49
+
50
+ /** 定位完整 zstd 帧范围(不解码 block),来自 dsh-session-persistence-jsonl (MIT)。 */
51
+ export function scanZstdFrames(buffer) {
52
+ const frames = []
53
+ let offset = 0
54
+ while (offset < buffer.length) {
55
+ const start = offset
56
+ if (buffer.length - offset < 4) return { frames, tornStart: start }
57
+ if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) throw new Error(`corrupt Zstandard session log: invalid frame magic at byte ${offset}`)
58
+ offset += 4
59
+ if (offset === buffer.length) return { frames, tornStart: start }
60
+ const descriptor = buffer.readUInt8(offset)
61
+ offset += 1
62
+ if ((descriptor & 24) !== 0) throw new Error(`corrupt Zstandard session log: reserved frame-header bit at byte ${offset - 1}`)
63
+ const contentSizeFlag = descriptor >>> 6
64
+ const singleSegment = (descriptor & 32) !== 0
65
+ const checksum = (descriptor & 4) !== 0
66
+ const dictionaryFlag = descriptor & 3
67
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
68
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
69
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
70
+ if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start }
71
+ offset += remainingHeaderBytes
72
+ for (;;) {
73
+ if (buffer.length - offset < 3) return { frames, tornStart: start }
74
+ const blockHeader = buffer.readUIntLE(offset, 3)
75
+ offset += 3
76
+ const lastBlock = (blockHeader & 1) !== 0
77
+ const blockType = blockHeader >>> 1 & 3
78
+ const blockSize = blockHeader >>> 3
79
+ if (blockType === 3) throw new Error('corrupt Zstandard session log: reserved block type')
80
+ const payloadBytes = blockType === 1 ? 1 : blockSize
81
+ if (buffer.length - offset < payloadBytes) return { frames, tornStart: start }
82
+ offset += payloadBytes
83
+ if (lastBlock) break
84
+ }
85
+ if (checksum) {
86
+ if (buffer.length - offset < 4) return { frames, tornStart: start }
87
+ offset += 4
88
+ }
89
+ frames.push({ start, end: offset })
90
+ }
91
+ return { frames }
92
+ }
93
+
94
+ /** 解码一个会话文件(zstd 多帧或明文),返回 JSONL 全文。 */
95
+ export function decodeFile(file) {
96
+ if (file.endsWith('.zstd')) {
97
+ const buffer = readFileSync(file)
98
+ const { frames } = scanZstdFrames(buffer)
99
+ if (frames.length === 0) throw new Error(`empty or header-less Zstandard session log: ${file}`)
100
+ return frames.map((fr) => zstdDecompressSync(buffer.subarray(fr.start, fr.end))).join('')
101
+ }
102
+ return readFileSync(file, 'utf8')
103
+ }
104
+
105
+ /** 解析 JSONL 全文 → { header, title, events };events 保持文件顺序(== seq 顺序)。 */
106
+ export function parseSession(plaintext) {
107
+ const events = []
108
+ let header = null
109
+ let title = null
110
+ for (const line of plaintext.split('\n')) {
111
+ const t = line.trim()
112
+ if (t.length === 0) continue
113
+ let rec
114
+ try { rec = JSON.parse(t) } catch { continue }
115
+ if (rec === null || typeof rec !== 'object') continue
116
+ if (rec.type === 'session') { header = rec; continue }
117
+ if (rec.type === 'session/title') { title = rec.data; continue }
118
+ events.push({
119
+ seq: typeof rec.seq === 'number' ? rec.seq : events.length,
120
+ time: typeof rec.time === 'number' ? rec.time : 0,
121
+ type: String(rec.type || 'unknown'),
122
+ data: rec.data === undefined ? null : rec.data,
123
+ surfaceOp: rec.surfaceOp === undefined ? null : rec.surfaceOp,
124
+ })
125
+ }
126
+ return { header, title, events }
127
+ }
128
+
129
+ const isErrorBlock = (block) =>
130
+ block !== null && typeof block === 'object' && block.type === 'tool-result' && block.isError === true
131
+
132
+ /** 从解析结果派生会话摘要(索引用,纯规则统计)。 */
133
+ export function summarizeParsed({ header, title, events }) {
134
+ const counts = {}
135
+ let lastTime = null
136
+ let lastType = null
137
+ let lastSeq = -1
138
+ let toolCalls = 0
139
+ let toolErrors = 0
140
+ let userMessages = 0
141
+ let assistantMessages = 0
142
+ let turns = 0
143
+ let steps = 0
144
+ let todos = 0
145
+ let lastError = null
146
+ const toolNames = new Set()
147
+ const artifactPaths = new Set()
148
+ for (const ev of events) {
149
+ counts[ev.type] = (counts[ev.type] || 0) + 1
150
+ if (ev.seq > lastSeq) lastSeq = ev.seq
151
+ if (ev.time !== null && ev.time !== undefined && (lastTime === null || ev.time > lastTime)) {
152
+ lastTime = ev.time
153
+ lastType = ev.type
154
+ }
155
+ switch (ev.type) {
156
+ case 'tool/call':
157
+ toolCalls++
158
+ if (ev.data && typeof ev.data.name === 'string' && ev.data.name.length > 0) toolNames.add(ev.data.name)
159
+ // M5c:从参数提取路径进索引(跨会话检索「哪个会话动过 X」)。
160
+ for (const p of extractPaths(ev.data && ev.data.name, ev.data && ev.data.arguments)) {
161
+ if (artifactPaths.size < 60) artifactPaths.add(p)
162
+ }
163
+ break
164
+ case 'tool/result':
165
+ if (Array.isArray(ev.data && ev.data.message && ev.data.message.content) &&
166
+ ev.data.message.content.some(isErrorBlock)) {
167
+ toolErrors++
168
+ lastError = { seq: ev.seq, time: ev.time }
169
+ }
170
+ break
171
+ case 'user/message': userMessages++; break
172
+ case 'assistant/message': assistantMessages++; break
173
+ case 'turn/start': turns++; break
174
+ case 'step/start': steps++; break
175
+ case 'todo/write': todos++; break
176
+ default: break
177
+ }
178
+ }
179
+ return {
180
+ id: header && header.id !== undefined ? header.id : null,
181
+ version: header && header.version !== undefined ? header.version : null,
182
+ createdAt: header && header.createdAt !== undefined ? header.createdAt : null,
183
+ cwd: header && header.cwd !== undefined ? header.cwd : null,
184
+ agentPreset: header && header.agentPreset !== undefined ? header.agentPreset : null,
185
+ parentSession: header && header.parentSession !== undefined ? header.parentSession : null,
186
+ delegationDepth: header && header.delegationDepth !== undefined ? header.delegationDepth : 0,
187
+ title: title && title.title !== undefined ? title.title : null,
188
+ titleSource: title && title.source && title.source.kind !== undefined ? title.source.kind : null,
189
+ recordCount: events.length,
190
+ lastSeq,
191
+ counts,
192
+ toolCalls,
193
+ toolErrors,
194
+ userMessages,
195
+ assistantMessages,
196
+ turns,
197
+ steps,
198
+ todos,
199
+ lastEventTime: lastTime,
200
+ lastEventType: lastType,
201
+ lastError,
202
+ // 去重工具名列表(M5b 档案统计 / M5c 内容检索的索引基础)。
203
+ toolNames: [...toolNames].sort(),
204
+ // 去重路径列表(M5c 文件检索索引,上限 60 条)。
205
+ artifactPaths: [...artifactPaths],
206
+ // 空会话:新建后未发生任何实质对话(无用户消息、无工具调用、无回合)。
207
+ empty: userMessages === 0 && toolCalls === 0 && turns === 0,
208
+ }
209
+ }
210
+
211
+ /** 定位一个会话目录里的存档文件(优先 zstd,回退明文)。 */
212
+ export function sessionFileOf(sessionDir) {
213
+ const zstd = join(sessionDir, 'session.jsonl.zstd')
214
+ if (existsSync(zstd)) return zstd
215
+ const plain = join(sessionDir, 'session.jsonl')
216
+ if (existsSync(plain)) return plain
217
+ return null
218
+ }
219
+
220
+ /** 会话目录判定:`session-<uuid>`(根会话)或裸 UUID(子代理会话,落盘时目录名 = 子代理 ID)。 */
221
+ export function isSessionDirName(name) {
222
+ return name.startsWith('session-') ||
223
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(name)
224
+ }
225
+
226
+ /** 列出 ~/.dsh/sessions 下的工作区目录。 */
227
+ export function listWorkspaces(home) {
228
+ const root = join(home, 'sessions')
229
+ if (!existsSync(root)) return []
230
+ const out = []
231
+ for (const name of readdirSync(root)) {
232
+ const dir = join(root, name)
233
+ let st
234
+ try { st = statSync(dir) } catch { continue }
235
+ if (!st.isDirectory()) continue
236
+ let sessionCount = 0
237
+ try {
238
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
239
+ if (e.isDirectory() && isSessionDirName(e.name)) sessionCount++
240
+ }
241
+ } catch {}
242
+ out.push({ name, dir, sessionCount })
243
+ }
244
+ out.sort((a, b) => a.name.localeCompare(b.name))
245
+ return out
246
+ }
247
+
248
+ /** 列出工作区内所有会话目录(含存档文件路径与 stat)。 */
249
+ export function listSessionDirs(wsDir) {
250
+ if (!existsSync(wsDir)) return []
251
+ const out = []
252
+ for (const entry of readdirSync(wsDir, { withFileTypes: true })) {
253
+ if (!entry.isDirectory() || !isSessionDirName(entry.name)) continue
254
+ const dir = join(wsDir, entry.name)
255
+ const file = sessionFileOf(dir)
256
+ if (file === null) continue
257
+ let st
258
+ try { st = statSync(file) } catch { continue }
259
+ out.push({ id: entry.name, dir, file, mtimeMs: st.mtimeMs, sizeBytes: st.size })
260
+ }
261
+ out.sort((a, b) => a.id.localeCompare(b.id))
262
+ return out
263
+ }
264
+
265
+ /** 解码 + 解析 + 统计一个会话文件,附加文件元信息。 */
266
+ export function summarizeSessionFile(file, meta = {}) {
267
+ const parsed = parseSession(decodeFile(file))
268
+ const summary = summarizeParsed(parsed)
269
+ summary.fileMtimeMs = meta.mtimeMs !== undefined ? meta.mtimeMs : null
270
+ summary.sizeBytes = meta.sizeBytes !== undefined ? meta.sizeBytes : null
271
+ return { summary, parsed }
272
+ }