@zhushanwen/pi-session-reader 0.13.3 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-session-reader",
3
- "version": "0.13.3",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "taiji": {
@@ -16,16 +16,16 @@
16
16
  "pi-package"
17
17
  ],
18
18
  "dependencies": {
19
- "@zhushanwen/extension-protocol": "0.26.0",
20
- "@zhushanwen/pi-ext-guards": "0.5.0",
19
+ "@zhushanwen/pi-ext-guards": "0.5.1",
21
20
  "@zhushanwen/pi-extension-logger": "0.6.2",
22
- "@zhushanwen/session-core": "0.12.0",
23
- "@zhushanwen/zcode-session-source": "0.2.9"
21
+ "@zhushanwen/extension-protocol": "0.27.0",
22
+ "@zhushanwen/session-core": "0.13.0",
23
+ "@zhushanwen/zcode-session-source": "0.2.10"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@vitest/coverage-v8": "^4.1.9",
27
27
  "vitest": "^4.1.8",
28
- "@zhushanwen/subagent-core": "2.1.2"
28
+ "@zhushanwen/subagent-core": "2.2.0"
29
29
  },
30
30
  "files": [
31
31
  "index.ts",
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
2
2
  import { mkdtemp, writeFile, rm } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
- import { parseSessionContent, parseSessionFile, type Entry } from '@zhushanwen/session-core'
5
+ import { parseSessionContent, parseSessionFile, UnknownMessageRoleError, type Entry } from '@zhushanwen/session-core'
6
6
 
7
7
  /** 构造单行 JSONL entry 字符串 */
8
8
  function line(obj: Record<string, unknown>): string {
@@ -22,7 +22,6 @@ describe('parseSessionContent', () => {
22
22
  const result = parseSessionContent(content)
23
23
 
24
24
  expect(result.skippedLines).toBe(0)
25
- expect(result.lastLinePartial).toBe(false)
26
25
  expect(result.entries).toHaveLength(5)
27
26
 
28
27
  const [s, u, a, c, custom] = result.entries
@@ -69,29 +68,25 @@ describe('parseSessionContent', () => {
69
68
  expect(result.entries[0].id).toBe('a')
70
69
  expect(result.entries[1].id).toBe('b')
71
70
  expect(result.skippedLines).toBe(1)
72
- // 最后一行解析成功 → 非 partial
73
- expect(result.lastLinePartial).toBe(false)
74
71
  })
75
72
 
76
- it('空字符串:entries 为空,skippedLines=0,非 partial', () => {
73
+ it('空字符串:entries 为空,skippedLines=0', () => {
77
74
  const result = parseSessionContent('')
78
75
 
79
76
  expect(result.entries).toEqual([])
80
77
  expect(result.skippedLines).toBe(0)
81
- expect(result.lastLinePartial).toBe(false)
82
78
  expect(result.totalBytes).toBe(0)
83
79
  })
84
80
 
85
- it('末尾换行不产生幽灵行(不计 skipped/partial)', () => {
81
+ it('末尾换行不产生幽灵行(不计 skipped)', () => {
86
82
  const content = line({ type: 'message', id: 'a', parentId: null }) + '\n'
87
83
  const result = parseSessionContent(content)
88
84
 
89
85
  expect(result.entries).toHaveLength(1)
90
86
  expect(result.skippedLines).toBe(0)
91
- expect(result.lastLinePartial).toBe(false)
92
87
  })
93
88
 
94
- it('最后一行是半 JSON:lastLinePartial=true,仍计入 skippedLines', () => {
89
+ it('最后一行是半 JSON:仍计入 skippedLines', () => {
95
90
  const content =
96
91
  line({ type: 'message', id: 'a', parentId: null }) + '\n' + '{"type":"message","id":"x"'
97
92
  const result = parseSessionContent(content)
@@ -99,7 +94,6 @@ describe('parseSessionContent', () => {
99
94
  expect(result.entries).toHaveLength(1)
100
95
  expect(result.entries[0].id).toBe('a')
101
96
  expect(result.skippedLines).toBe(1)
102
- expect(result.lastLinePartial).toBe(true)
103
97
  })
104
98
 
105
99
  it('JSON 合法但缺必填结构字段(type/id)视为坏行', () => {
@@ -114,8 +108,6 @@ describe('parseSessionContent', () => {
114
108
  expect(result.entries).toHaveLength(1)
115
109
  expect(result.entries[0].id).toBe('ok')
116
110
  expect(result.skippedLines).toBe(2)
117
- // 最后那条坏行也是最后一行 → partial
118
- expect(result.lastLinePartial).toBe(true)
119
111
  })
120
112
 
121
113
  it('custom entry 无顶层 id → 坏行跳过(W4:data.id fallback 死分支已删,pi appendCustomEntry 恒写顶层 id)', () => {
@@ -144,23 +136,29 @@ describe('parseSessionContent', () => {
144
136
  expect(result.entries[0].parentId).toBeNull()
145
137
  })
146
138
 
147
- it('message 缺/非法 role → 丢弃 message 字段但保留 entry', () => {
139
+ it('message 非法/缺失 role → 整文件解析抛 UnknownMessageRoleError(fail-fast 不静默丢内容)', () => {
148
140
  // role 样本用词表外的值('bot')——'system' 已随 pi 1.0 四值词表放行(session-core D9①)
149
141
  const content = [
150
142
  line({ type: 'message', id: 'm1', parentId: null, message: { role: 'bot', content: 'x' } }),
151
- line({ type: 'message', id: 'm2', parentId: null, message: { content: 'x' } }),
152
143
  ].join('\n')
153
144
 
154
- const result = parseSessionContent(content)
145
+ expect(() => parseSessionContent(content)).toThrow(UnknownMessageRoleError)
146
+ expect(() => parseSessionContent(content)).toThrow(/第 1 行.*"bot"/)
155
147
 
156
- expect(result.entries).toHaveLength(2)
148
+ // message 存在但缺失 role 同样 fail-fast(role 不在词表)
149
+ const missingRole = line({ type: 'message', id: 'm2', parentId: null, message: { content: 'x' } })
150
+ expect(() => parseSessionContent(missingRole)).toThrow(UnknownMessageRoleError)
151
+
152
+ // message 键整体缺省照常产出 entry(无 message 结构可校验)
153
+ const noMessage = line({ type: 'message', id: 'm3', parentId: null })
154
+ const result = parseSessionContent(noMessage)
155
+ expect(result.entries).toHaveLength(1)
157
156
  expect(result.entries[0].message).toBeUndefined()
158
- expect(result.entries[1].message).toBeUndefined()
159
157
  })
160
158
  })
161
159
 
162
160
  describe('parseSessionFile', () => {
163
- it('磁盘文件全量解析:entries 数 = 行数 / 0 skipped / 非 partial / totalBytes 与内容一致', async () => {
161
+ it('磁盘文件全量解析:entries 数 = 行数 / 0 skipped / totalBytes 与内容一致', async () => {
164
162
  // mkdtemp 自建自删的 fixture 文件(不读本机真实数据目录):session header +
165
163
  // 10 条链式 message 的最小行集,验证「文件读取 → 逐行解析」回路的完整性与计数字段
166
164
  const dir = await mkdtemp(join(tmpdir(), 'parser-file-'))
@@ -187,7 +185,6 @@ describe('parseSessionFile', () => {
187
185
 
188
186
  expect(result.entries).toHaveLength(11)
189
187
  expect(result.skippedLines).toBe(0)
190
- expect(result.lastLinePartial).toBe(false)
191
188
  expect(result.totalBytes).toBe(Buffer.byteLength(content, 'utf8'))
192
189
  } finally {
193
190
  await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
@@ -100,31 +100,28 @@ function extractZcodeSessionRefAnchor(engineHandle: unknown): ZcodeAnchor | unde
100
100
  * 多候选文件:按候选列表顺序逐文件扫,文件内有命中锚(含末条裁决后)即止——
101
101
  * 候选顺序 = 调用方声明的优先级(liveSessionDir 优先),不跨文件合并。
102
102
  *
103
- * 行级容错:逐行解析由基座 parseSessionContent 承担(坏行跳过继续、末行半行记
104
- * lastLinePartial 不中断)——best-effort,不因一行坏 JSON 失败整文件。
103
+ * 行级容错:坏行(非法 JSON/缺 type/id)由基座 parseSessionContent 跳过继续;message
104
+ * 存在但 role 不在词表时基座整文件解析抛 UnknownMessageRoleError(fail-fast,不静默
105
+ * 丢内容)——readParsedEntriesBestEffort 把两类读失败(IO / 解析 fail-fast)统一收敛为
106
+ * undefined,候选跳过。
105
107
  *
106
- * 候选文件读失败(不存在/不可读)= 该候选无命中,跳过继续下一候选;本函数契约
107
- * 是「锚或 not-found」,IO 错误同样收敛到 not-found 信号(调用方映射错误码),
108
+ * 候选文件读失败(不存在/不可读/解析 fail-fast)= 该候选无命中,跳过继续下一候选;
109
+ * 本函数契约是「锚或 not-found」,IO 错误同样收敛到 not-found 信号(调用方映射错误码),
108
110
  * 不向上抛。
109
111
  *
110
112
  * 读取用 node:fs/promises 的 async readFile——本函数跑在 pi 扩展进程内,同步
111
113
  * readFileSync 全文读入会阻塞事件循环(同函数族 classifyIncompleteEntryAnchor
112
114
  * 同形态;对照 readSessionHeaderIdSync 只在首行 4KB 有界读时才允许同步)。
113
115
  *
114
- * @returns 命中的锚;所有候选均未命中(或全部候选不可读)→ undefined(not-found 信号)
116
+ * @returns 命中的锚;所有候选均未命中(或全部候选不可读/解析失败)→ undefined(not-found 信号)
115
117
  */
116
118
  export async function findZcodeEntryAnchor(
117
119
  candidateFiles: readonly string[],
118
120
  saId: string,
119
121
  ): Promise<ZcodeAnchor | undefined> {
120
122
  for (const file of candidateFiles) {
121
- let content: string
122
- try {
123
- content = await readFile(file, 'utf8')
124
- } catch {
125
- continue
126
- }
127
- const { entries } = parseSessionContent(content)
123
+ const entries = await readParsedEntriesBestEffort(file)
124
+ if (entries === undefined) continue
128
125
  let anchor: ZcodeAnchor | undefined
129
126
  for (const entry of entries) {
130
127
  const hit = zcodeAnchorOfEntry(entry, saId)
@@ -134,3 +131,23 @@ export async function findZcodeEntryAnchor(
134
131
  }
135
132
  return undefined
136
133
  }
134
+
135
+ /**
136
+ * 读 session 文件并解析为 entries 的 best-effort 形态:读取失败(不存在/不可读)或
137
+ * 解析 fail-fast(message 存在但 role 不在词表等)都返回 undefined——候选扫描语境的
138
+ * 容错读(失败候选不贡献结果);需要显式报错的主读链不走本函数(tool-handler
139
+ * safeParse 把解析错误转为带 👉 的工具错误)。
140
+ */
141
+ export async function readParsedEntriesBestEffort(file: string): Promise<Entry[] | undefined> {
142
+ let content: string
143
+ try {
144
+ content = await readFile(file, 'utf8')
145
+ } catch {
146
+ return undefined
147
+ }
148
+ try {
149
+ return parseSessionContent(content).entries
150
+ } catch {
151
+ return undefined
152
+ }
153
+ }
@@ -46,7 +46,11 @@ import {
46
46
  } from './discovery/archive-match.js'
47
47
  import { resolveSessionRoots } from './discovery/roots.js'
48
48
  import { readSessionHeaderIdSync } from './discovery/session-header.js'
49
- import { findZcodeEntryAnchor, type ZcodeAnchor } from './discovery/entry-anchor.js'
49
+ import {
50
+ findZcodeEntryAnchor,
51
+ readParsedEntriesBestEffort,
52
+ type ZcodeAnchor,
53
+ } from './discovery/entry-anchor.js'
50
54
  import {
51
55
  listZcodeManifests,
52
56
  readZcodeManifest,
@@ -59,11 +63,7 @@ import {
59
63
  type RecordManifest,
60
64
  } from './discovery/subagents.js'
61
65
  import { readRunSnapshot, resolveWorkflows } from './discovery/workflows.js'
62
- import {
63
- parseSessionContent,
64
- parseSessionFile,
65
- type ParseResult,
66
- } from '@zhushanwen/session-core'
66
+ import { parseSessionFile, type ParseResult } from '@zhushanwen/session-core'
67
67
  import { parseRunRecordStream, parseRunSnapshot, renderWorkflowOverview, type WorkflowOverview } from './core/workflow.js'
68
68
  import { buildTreeView } from './core/tree.js'
69
69
  import { segmentTurns } from './core/turns.js'
@@ -358,13 +358,11 @@ async function classifyIncompleteEntryAnchor(
358
358
  saId: string,
359
359
  ): Promise<ZcodeAnchorMissingReason | undefined> {
360
360
  for (const file of candidateFiles) {
361
- let content: string
362
- try {
363
- content = await readFile(file, 'utf8')
364
- } catch {
365
- continue
366
- }
367
- const reason = firstIncompleteAnchorReason(parseSessionContent(content).entries, saId)
361
+ // 读取/解析失败(含非法 message role 的 fail-fast 抛错)按候选跳过:归因分类是
362
+ // best-effort 附加信息,最终 not-found 错误仍由调用方显式抛出
363
+ const entries = await readParsedEntriesBestEffort(file)
364
+ if (entries === undefined) continue
365
+ const reason = firstIncompleteAnchorReason(entries, saId)
368
366
  if (reason !== undefined) return reason
369
367
  }
370
368
  return undefined
@@ -566,7 +564,7 @@ async function loadZcodeParsed(anchor: ZcodeAnchor, agentDir: string): Promise<P
566
564
  }
567
565
  // ParseResult 形状对齐:zcode 库读无「文件字节/坏行」概念(strict Entry 树已由
568
566
  // converter 保证),totalBytes/skippedLines 恒 0
569
- return { entries: normalized.entries, totalBytes: 0, skippedLines: 0, lastLinePartial: false }
567
+ return { entries: normalized.entries, totalBytes: 0, skippedLines: 0 }
570
568
  } catch (e) {
571
569
  if (e instanceof Error && e.message.startsWith('[zcode_')) throw e // 本函数产出的错误范围原样传播
572
570
  throw err(zcodeReadErrorMessage(e, agentDir))