@zhushanwen/pi-session-reader 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/index.ts +1 -0
- package/package.json +47 -0
- package/src/__tests__/family.test.ts +207 -0
- package/src/__tests__/find.test.ts +214 -0
- package/src/__tests__/hash-provider.test.ts +498 -0
- package/src/__tests__/index.test.ts +193 -0
- package/src/__tests__/parser.test.ts +166 -0
- package/src/__tests__/real-data.ts +53 -0
- package/src/__tests__/render.test.ts +367 -0
- package/src/__tests__/roots.test.ts +132 -0
- package/src/__tests__/session-command.test.ts +198 -0
- package/src/__tests__/subagents.test.ts +430 -0
- package/src/__tests__/tool-handler.test.ts +510 -0
- package/src/__tests__/toolcall.test.ts +256 -0
- package/src/__tests__/tree.test.ts +101 -0
- package/src/__tests__/turns.test.ts +142 -0
- package/src/core/family.ts +240 -0
- package/src/core/parser.ts +160 -0
- package/src/core/render.ts +584 -0
- package/src/core/toolcall.ts +168 -0
- package/src/core/tree.ts +93 -0
- package/src/core/turns.ts +97 -0
- package/src/discovery/find.ts +247 -0
- package/src/discovery/roots.ts +92 -0
- package/src/discovery/subagents.ts +470 -0
- package/src/index.ts +189 -0
- package/src/tool-handler.ts +1160 -0
- package/src/tui/hash-provider.ts +230 -0
- package/src/tui/session-command.ts +85 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { Entry } from './parser.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 家族索引与解析(design §3.3 D-7)。
|
|
5
|
+
*
|
|
6
|
+
* M1 范围:纯逻辑层。buildFamilyIndex 接收已读入的 entry 数组(不做文件 IO),
|
|
7
|
+
* 文件扫描/首行读取归 M2 discovery 层(roots.ts/find.ts/subagents.ts)。
|
|
8
|
+
*
|
|
9
|
+
* M1 占位约定(M2 discovery 层补全真实值,逻辑不变):
|
|
10
|
+
* - SessionRef.fileName:session header 推不出文件路径 → 占位空串
|
|
11
|
+
* - SessionRef.mtime/sizeBytes:从 fileStats 取(M1 key=sessionId),取不到为 0
|
|
12
|
+
* - SubagentRef.sessionId:identity entry 不含 subagent session 的 id → 占位用 entry.id
|
|
13
|
+
* - SubagentRef.cwd:identity entry(custom 类型)无 cwd → 占位空串
|
|
14
|
+
* - fileStats 的 key:M1 用 sessionId(M2 改用真实文件路径),cleanedUp 逻辑 M1/M2 通用
|
|
15
|
+
*
|
|
16
|
+
* 隔代关联规则(design §3.3 D-7 Q1)见 resolveFamily 注释。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface SessionRef {
|
|
20
|
+
sessionId: string
|
|
21
|
+
fileName: string
|
|
22
|
+
mtime: number
|
|
23
|
+
sizeBytes: number
|
|
24
|
+
cwd: string
|
|
25
|
+
/** fork 文件指向来源的路径(来自 header 的 parentSession,原始文件路径字符串) */
|
|
26
|
+
parentSession?: string
|
|
27
|
+
name?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SubagentRef extends SessionRef {
|
|
31
|
+
rootSessionId: string
|
|
32
|
+
slug: string
|
|
33
|
+
/** identity 在但文件已被 30 天 TTL GC(design §3.3 D-7 边界 / 失败路径 F3) */
|
|
34
|
+
cleanedUp?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface WorkflowRef {
|
|
38
|
+
runId: string
|
|
39
|
+
stateFile: string
|
|
40
|
+
calls: SessionRef[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface Family {
|
|
44
|
+
root: SessionRef
|
|
45
|
+
/** fork 父链(root 往上,最近在前) */
|
|
46
|
+
parents: SessionRef[]
|
|
47
|
+
/** fork 直接子代 */
|
|
48
|
+
forks: SessionRef[]
|
|
49
|
+
/** 含隔代(design §3.3 D-7 Q1:对 fork 链每个节点查 subagentsByRoot 合并) */
|
|
50
|
+
subagents: SubagentRef[]
|
|
51
|
+
/** M1 恒返回 [],workflow 腿需读 workflow-state 文件(IO)归 M2 */
|
|
52
|
+
workflows: WorkflowRef[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface FamilyIndex {
|
|
56
|
+
byId: Map<string, SessionRef>
|
|
57
|
+
/**
|
|
58
|
+
* 父 sessionId(经 parentSession 文件路径反查得到)→ 直接 fork 子代。
|
|
59
|
+
* 注意 key 是 sessionId 不是文件路径:parentSession 是文件路径,buildFamilyIndex
|
|
60
|
+
* 反查映射回父 sessionId 后建此表,供 resolveFamily 用 root.sessionId 直接查。
|
|
61
|
+
*/
|
|
62
|
+
childrenOf: Map<string, SessionRef[]>
|
|
63
|
+
/** rootSessionId → 该 root 直接发起的 subagent(隔代合并在 resolveFamily 跨链节点做) */
|
|
64
|
+
subagentsByRoot: Map<string, SubagentRef[]>
|
|
65
|
+
/** 建索引时的文件元信息快照,供 cleanedUp 判断(subagent 文件 GC 检测,design §3.3 D-7) */
|
|
66
|
+
fileStats: Map<string, { mtime: number; size: number }>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---- 类型守卫:从 unknown 的 entry.data 提取 subagent identity 字段 ----
|
|
70
|
+
|
|
71
|
+
interface SubagentIdentityData {
|
|
72
|
+
rootSessionId: string
|
|
73
|
+
slug: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isSubagentIdentityData(v: unknown): v is SubagentIdentityData {
|
|
77
|
+
if (typeof v !== 'object' || v === null) return false
|
|
78
|
+
const obj = v as Record<string, unknown>
|
|
79
|
+
return typeof obj.rootSessionId === 'string' && typeof obj.slug === 'string'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 把 parentSession(文件路径)反查回父 sessionId。
|
|
84
|
+
*
|
|
85
|
+
* parentSession 是 fork 文件首行 header 指向来源的**文件路径**(非 session id),
|
|
86
|
+
* 文件名格式 `<timestamp>_<sessionId>.jsonl`,故路径字符串含父 sessionId。
|
|
87
|
+
* 遍历已知 sessionId 做子串匹配反查。
|
|
88
|
+
*
|
|
89
|
+
* 兼容 parentSession 直接就是 sessionId 的简化场景(测试 fixture 常用)。
|
|
90
|
+
* 假设 sessionId 互不为子串(pi 用 UUID,满足);M2 可优化为 fileName→sessionId
|
|
91
|
+
* 索引反查(O(1)),当前遍历 O(N),家族索引文件数通常几十到几百,可接受。
|
|
92
|
+
*/
|
|
93
|
+
function resolveParentSessionId(
|
|
94
|
+
parentSession: string | undefined,
|
|
95
|
+
byId: Map<string, SessionRef>,
|
|
96
|
+
): string | null {
|
|
97
|
+
if (!parentSession) return null
|
|
98
|
+
if (byId.has(parentSession)) return parentSession // 直接是 sessionId(简化场景)
|
|
99
|
+
for (const sid of byId.keys()) {
|
|
100
|
+
if (parentSession.includes(sid)) return sid
|
|
101
|
+
}
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 从已读入的 session headers + subagent identity entries 建家族索引(纯逻辑,无 IO)。
|
|
107
|
+
*
|
|
108
|
+
* - headers(type=session)→ byId + childrenOf(parentSession 文件路径反查父 sessionId)
|
|
109
|
+
* - subagentIdentities(type=custom, customType=subagent-identity)→ subagentsByRoot
|
|
110
|
+
* - fileStats 原样存入 index,供 cleanedUp 判断
|
|
111
|
+
*
|
|
112
|
+
* 坏数据容错:identity 缺 rootSessionId/slug 跳过;parentSession 反查不到父(父文件
|
|
113
|
+
* 未被扫描到)该 entry 不进 childrenOf——均不报错,符合 pi 坏 session 容错(design §2)。
|
|
114
|
+
*/
|
|
115
|
+
export function buildFamilyIndex(
|
|
116
|
+
headers: Entry[],
|
|
117
|
+
subagentIdentities: Entry[],
|
|
118
|
+
fileStats: Map<string, { mtime: number; size: number }>,
|
|
119
|
+
): FamilyIndex {
|
|
120
|
+
const byId = new Map<string, SessionRef>()
|
|
121
|
+
|
|
122
|
+
// 1. headers → byId
|
|
123
|
+
for (const h of headers) {
|
|
124
|
+
const stat = fileStats.get(h.id)
|
|
125
|
+
const ref: SessionRef = {
|
|
126
|
+
sessionId: h.id,
|
|
127
|
+
fileName: '', // M1 占位:M2 discovery 补真实文件路径
|
|
128
|
+
mtime: stat?.mtime ?? 0,
|
|
129
|
+
sizeBytes: stat?.size ?? 0,
|
|
130
|
+
cwd: h.cwd ?? '',
|
|
131
|
+
}
|
|
132
|
+
if (h.parentSession) ref.parentSession = h.parentSession
|
|
133
|
+
byId.set(h.id, ref)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 2. childrenOf:parentSession(文件路径)→ 反查父 sessionId → key 用父 sessionId
|
|
137
|
+
const childrenOf = new Map<string, SessionRef[]>()
|
|
138
|
+
for (const h of headers) {
|
|
139
|
+
if (!h.parentSession) continue
|
|
140
|
+
const parentSid = resolveParentSessionId(h.parentSession, byId)
|
|
141
|
+
if (parentSid === null) continue // 反查不到父 → 无法建反查关系,跳过
|
|
142
|
+
const childRef = byId.get(h.id)
|
|
143
|
+
if (!childRef) continue
|
|
144
|
+
const list = childrenOf.get(parentSid) ?? []
|
|
145
|
+
list.push(childRef)
|
|
146
|
+
childrenOf.set(parentSid, list)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 3. subagentIdentities → subagentsByRoot
|
|
150
|
+
const subagentsByRoot = new Map<string, SubagentRef[]>()
|
|
151
|
+
for (const ident of subagentIdentities) {
|
|
152
|
+
if (!isSubagentIdentityData(ident.data)) continue // 坏数据(缺 rootSessionId/slug)跳过
|
|
153
|
+
const stat = fileStats.get(ident.id)
|
|
154
|
+
const ref: SubagentRef = {
|
|
155
|
+
// M1 占位:identity entry 不含 subagent session 的 id,用 entry.id 顶替;
|
|
156
|
+
// M2 discovery 读 subagent 文件首行 header.id 得到真实 subagent sessionId
|
|
157
|
+
sessionId: ident.id,
|
|
158
|
+
rootSessionId: ident.data.rootSessionId,
|
|
159
|
+
slug: ident.data.slug,
|
|
160
|
+
fileName: '', // M1 占位
|
|
161
|
+
mtime: stat?.mtime ?? 0,
|
|
162
|
+
sizeBytes: stat?.size ?? 0,
|
|
163
|
+
cwd: '', // identity entry 无 cwd;M2 从 subagent 文件 header 补
|
|
164
|
+
// M1: fileStats key=sessionId;M2 改用 subagent 真实文件路径(SubagentRef.fileName)查 fileStats
|
|
165
|
+
cleanedUp: !fileStats.has(ident.id),
|
|
166
|
+
}
|
|
167
|
+
const list = subagentsByRoot.get(ident.data.rootSessionId) ?? []
|
|
168
|
+
list.push(ref)
|
|
169
|
+
subagentsByRoot.set(ident.data.rootSessionId, list)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { byId, childrenOf, subagentsByRoot, fileStats }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 解析某 session 的家族。
|
|
177
|
+
*
|
|
178
|
+
* 隔代关联规则(design §3.3 D-7 Q1,核心):subagent 的 rootSessionId 指向其**直接
|
|
179
|
+
* 发起 session**,可能是 fork 链中间节点而非家族根。故不能只查 root 的 subagentsByRoot——
|
|
180
|
+
* 会漏隔代 subagent(从家族根出发会漏掉挂在 fork 子代下的 subagent)。
|
|
181
|
+
*
|
|
182
|
+
* 实现:建好 fork 链后,对链上**每个**节点 id(root + 所有 parents + 直接 forks)
|
|
183
|
+
* 查 subagentsByRoot,按 sessionId 去重合并。
|
|
184
|
+
*
|
|
185
|
+
* 范围限定:M1 的 chainIds 只含直接 forks(childrenOf[root]),不递归孙代——
|
|
186
|
+
* 多层 fork 后代上的 subagent 递归关联不在 M1 范围(Q1 真实场景为单层 fork)。
|
|
187
|
+
*
|
|
188
|
+
* @throws session 不在 index.byId 时抛 Error
|
|
189
|
+
*/
|
|
190
|
+
export function resolveFamily(sessionId: string, index: FamilyIndex): Family {
|
|
191
|
+
const root = index.byId.get(sessionId)
|
|
192
|
+
if (!root) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`session not found in family index: "${sessionId}". ` +
|
|
195
|
+
`Ensure buildFamilyIndex received this session's header entry (type=session, id="${sessionId}").`,
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 2. fork 父链 parents(root 沿 parentSession 往上,最近在前)
|
|
200
|
+
const parents: SessionRef[] = []
|
|
201
|
+
{
|
|
202
|
+
const seen = new Set<string>([root.sessionId]) // 环防御(坏数据 A→B→A)
|
|
203
|
+
let cur: SessionRef = root
|
|
204
|
+
while (cur.parentSession) {
|
|
205
|
+
const parentSid = resolveParentSessionId(cur.parentSession, index.byId)
|
|
206
|
+
if (parentSid === null) break // 反查不到父 → 链断
|
|
207
|
+
const parentRef = index.byId.get(parentSid)
|
|
208
|
+
if (!parentRef) break // 父不在 byId(未扫描到)→ 链断
|
|
209
|
+
if (seen.has(parentSid)) break // 环防御
|
|
210
|
+
seen.add(parentSid)
|
|
211
|
+
parents.push(parentRef)
|
|
212
|
+
cur = parentRef
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 3. fork 直接子代
|
|
217
|
+
const forks: SessionRef[] = index.childrenOf.get(root.sessionId) ?? []
|
|
218
|
+
|
|
219
|
+
// 4. 隔代 subagent:对 fork 链每个节点 id 查 subagentsByRoot,按 sessionId 去重合并
|
|
220
|
+
const chainIds = new Set<string>([root.sessionId])
|
|
221
|
+
for (const p of parents) chainIds.add(p.sessionId)
|
|
222
|
+
for (const f of forks) chainIds.add(f.sessionId)
|
|
223
|
+
|
|
224
|
+
const subagents: SubagentRef[] = []
|
|
225
|
+
const seenSubagent = new Set<string>()
|
|
226
|
+
for (const sid of chainIds) {
|
|
227
|
+
const subs = index.subagentsByRoot.get(sid)
|
|
228
|
+
if (!subs) continue
|
|
229
|
+
for (const s of subs) {
|
|
230
|
+
if (seenSubagent.has(s.sessionId)) continue
|
|
231
|
+
seenSubagent.add(s.sessionId)
|
|
232
|
+
subagents.push(s)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 5. workflows:M1 恒 [],workflow 腿需读 workflow-state 文件(IO)归 M2
|
|
237
|
+
const workflows: WorkflowRef[] = []
|
|
238
|
+
|
|
239
|
+
return { root, parents, forks, subagents, workflows }
|
|
240
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* session JSONL 文件解析后的单行 entry(冻结接口,M2-M5 契约)。
|
|
5
|
+
*
|
|
6
|
+
* 字段对齐 pi session JSONL 各 type 的 payload:
|
|
7
|
+
* - type / id / parentId:树结构字段。root(session header)在文件里无 parentId,
|
|
8
|
+
* 归一化为 null(design §3.5 算法 2 的 root 判定依据)。
|
|
9
|
+
* - message:仅 type=message(role/content/toolCalls)。
|
|
10
|
+
* toolName/toolCallId:仅 role=toolResult 的 message 透出(probe 实测 515/515 带,
|
|
11
|
+
* O2/O3 据此精确关联同 turn 内 toolCall 取参数,替代 v1 脆弱的顺序关联)。
|
|
12
|
+
* - customType / data:仅 type=custom。
|
|
13
|
+
* - parentSession / cwd:仅 type=session;parentSession 是 fork 文件指向来源的路径指针。
|
|
14
|
+
* - summary:仅 type=compaction。
|
|
15
|
+
*
|
|
16
|
+
* 注:冻结接口未列的 per-type 附加字段(如 model_change.provider、
|
|
17
|
+
* compaction.firstKeptEntryId、session.version)不在此暴露——M2+ 若需消费,
|
|
18
|
+
* 扩展 Entry 接口并同步 design.md §3.4,不在 parser 层私自保留。
|
|
19
|
+
*/
|
|
20
|
+
export interface Entry {
|
|
21
|
+
type: string
|
|
22
|
+
id: string
|
|
23
|
+
parentId: string | null
|
|
24
|
+
timestamp?: string
|
|
25
|
+
message?: {
|
|
26
|
+
role: 'user' | 'assistant' | 'toolResult'
|
|
27
|
+
content: unknown
|
|
28
|
+
toolCalls?: unknown[]
|
|
29
|
+
/** toolResult 的工具名(probe 实测 515/515 带,仅 role=toolResult 时存在) */
|
|
30
|
+
toolName?: string
|
|
31
|
+
/** toolResult 关联的 toolCall.id(probe 实测 515/515 全部匹配 toolCall.id) */
|
|
32
|
+
toolCallId?: string
|
|
33
|
+
}
|
|
34
|
+
customType?: string
|
|
35
|
+
data?: unknown
|
|
36
|
+
parentSession?: string
|
|
37
|
+
cwd?: string
|
|
38
|
+
summary?: unknown
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ParseResult {
|
|
42
|
+
entries: Entry[]
|
|
43
|
+
/** JSON 解析失败(含缺必填结构字段)的行数 */
|
|
44
|
+
skippedLines: number
|
|
45
|
+
totalBytes: number
|
|
46
|
+
/** 最后一行疑似半行(活跃 session 写入中),区别于中间坏行 */
|
|
47
|
+
lastLinePartial: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isMessageRole(v: unknown): v is 'user' | 'assistant' | 'toolResult' {
|
|
51
|
+
return v === 'user' || v === 'assistant' || v === 'toolResult'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 把单个已 JSON.parse 成功的原始对象归一化为 Entry。
|
|
56
|
+
* 缺必填结构字段(type/id)返回 undefined,调用方计为坏行(skippedLines++)。
|
|
57
|
+
*/
|
|
58
|
+
function toEntry(raw: unknown): Entry | undefined {
|
|
59
|
+
if (typeof raw !== 'object' || raw === null) return undefined
|
|
60
|
+
const obj = raw as Record<string, unknown>
|
|
61
|
+
if (typeof obj.type !== 'string') return undefined
|
|
62
|
+
|
|
63
|
+
// id 解析:顶层 id 优先;custom entry 无顶层 id 时 fallback 到 data.id
|
|
64
|
+
//(pi 的 subagent-identity 等 custom entry 把 id 放在 data.id,非顶层——真实样本确认)
|
|
65
|
+
let id: unknown = obj.id
|
|
66
|
+
if (typeof id !== 'string' && obj.type === 'custom') {
|
|
67
|
+
const data = obj.data
|
|
68
|
+
if (typeof data === 'object' && data !== null && typeof (data as Record<string, unknown>).id === 'string') {
|
|
69
|
+
id = (data as Record<string, unknown>).id
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (typeof id !== 'string') return undefined
|
|
73
|
+
|
|
74
|
+
const entry: Entry = {
|
|
75
|
+
type: obj.type,
|
|
76
|
+
id: id as string,
|
|
77
|
+
parentId: typeof obj.parentId === 'string' ? obj.parentId : null,
|
|
78
|
+
}
|
|
79
|
+
if (typeof obj.timestamp === 'string') entry.timestamp = obj.timestamp
|
|
80
|
+
|
|
81
|
+
// message:role 经值守卫收窄,缺/非法 role 时丢弃 message 字段(接口 role 必填)
|
|
82
|
+
if (obj.message !== null && typeof obj.message === 'object') {
|
|
83
|
+
const m = obj.message as Record<string, unknown>
|
|
84
|
+
if (isMessageRole(m.role)) {
|
|
85
|
+
const message: NonNullable<Entry['message']> = { role: m.role, content: m.content }
|
|
86
|
+
if (Array.isArray(m.toolCalls)) message.toolCalls = m.toolCalls
|
|
87
|
+
// toolResult 自带的工具关联字段(O2/O3 用,additive,实测 515/515 存在)
|
|
88
|
+
if (typeof m.toolName === 'string') message.toolName = m.toolName
|
|
89
|
+
if (typeof m.toolCallId === 'string') message.toolCallId = m.toolCallId
|
|
90
|
+
entry.message = message
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (typeof obj.customType === 'string') entry.customType = obj.customType
|
|
95
|
+
if (obj.data !== undefined) entry.data = obj.data
|
|
96
|
+
if (typeof obj.parentSession === 'string') entry.parentSession = obj.parentSession
|
|
97
|
+
if (typeof obj.cwd === 'string') entry.cwd = obj.cwd
|
|
98
|
+
if (obj.summary !== undefined) entry.summary = obj.summary
|
|
99
|
+
|
|
100
|
+
return entry
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 解析 session JSONL 文本为 entries。
|
|
105
|
+
*
|
|
106
|
+
* 逐行 JSON.parse,坏行(语法错误或缺必填结构字段)计入 skippedLines 并跳过,
|
|
107
|
+
* 不中断整体解析——pi 坏 session 容错(design §2 失败模式)。
|
|
108
|
+
*
|
|
109
|
+
* 末尾换行产生的空行忽略(不计 skipped、不计 partial)。最后一行 parse 失败时
|
|
110
|
+
* lastLinePartial=true(活跃 session 写到一半的半行),区别于中间坏行。
|
|
111
|
+
*/
|
|
112
|
+
export function parseSessionContent(content: string): ParseResult {
|
|
113
|
+
const entries: Entry[] = []
|
|
114
|
+
let skippedLines = 0
|
|
115
|
+
let lastLinePartial = false
|
|
116
|
+
|
|
117
|
+
const lines = content.split('\n')
|
|
118
|
+
// 移除末尾因 trailing newline 产生的空行(非真实行)
|
|
119
|
+
while (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
120
|
+
lines.pop()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (let i = 0; i < lines.length; i++) {
|
|
124
|
+
const line = lines[i]
|
|
125
|
+
const isLast = i === lines.length - 1
|
|
126
|
+
|
|
127
|
+
// 中间空行容错:pi 正常 jsonl 无空行,防御文件损坏;不计 skipped
|
|
128
|
+
if (line.trim() === '') continue
|
|
129
|
+
|
|
130
|
+
let raw: unknown
|
|
131
|
+
try {
|
|
132
|
+
raw = JSON.parse(line)
|
|
133
|
+
} catch {
|
|
134
|
+
skippedLines++
|
|
135
|
+
if (isLast) lastLinePartial = true
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const entry = toEntry(raw)
|
|
140
|
+
if (entry === undefined) {
|
|
141
|
+
skippedLines++
|
|
142
|
+
if (isLast) lastLinePartial = true
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
entries.push(entry)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
entries,
|
|
150
|
+
skippedLines,
|
|
151
|
+
totalBytes: Buffer.byteLength(content, 'utf8'),
|
|
152
|
+
lastLinePartial,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 读取 session 文件并解析。文件不存在按 Node fs 原生错误抛出(ENOENT)。 */
|
|
157
|
+
export async function parseSessionFile(filePath: string): Promise<ParseResult> {
|
|
158
|
+
const content = await readFile(filePath, 'utf8')
|
|
159
|
+
return parseSessionContent(content)
|
|
160
|
+
}
|