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/LICENSE +201 -0
- package/README.en.md +126 -0
- package/README.md +126 -0
- package/cordis.patch.yml +7 -0
- package/lib/archive.js +272 -0
- package/lib/client.js +1913 -0
- package/lib/host.js +1132 -0
- package/lib/index-store.js +124 -0
- package/lib/timeline.js +257 -0
- package/package.json +51 -0
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
|
+
}
|