@zhushanwen/pi-session-reader 0.6.0 → 0.7.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.
Files changed (48) hide show
  1. package/README.md +100 -0
  2. package/package.json +7 -4
  3. package/src/__tests__/cross-package-subagent-core.test.ts +36 -44
  4. package/src/__tests__/entry-anchor.test.ts +226 -0
  5. package/src/__tests__/env.test.ts +54 -54
  6. package/src/__tests__/execution-tree.test.ts +6 -0
  7. package/src/__tests__/family-zcode.test.ts +255 -0
  8. package/src/__tests__/family.test.ts +1 -1
  9. package/src/__tests__/find.test.ts +24 -1
  10. package/src/__tests__/index.test.ts +1 -1
  11. package/src/__tests__/parser.test.ts +1 -2
  12. package/src/__tests__/real-data.ts +22 -9
  13. package/src/__tests__/render.test.ts +1 -2
  14. package/src/__tests__/result.test.ts +2 -2
  15. package/src/__tests__/roots.test.ts +1 -1
  16. package/src/__tests__/subagents.test.ts +1 -4
  17. package/src/__tests__/tool-handler.test.ts +76 -85
  18. package/src/__tests__/toolcall.test.ts +1 -1
  19. package/src/__tests__/tree.test.ts +1 -1
  20. package/src/__tests__/turns.test.ts +1 -2
  21. package/src/__tests__/zcode-manifest.test.ts +361 -0
  22. package/src/__tests__/zcode-routing.test.ts +751 -0
  23. package/src/__tests__/zcode-whitelist.test.ts +119 -0
  24. package/src/core/execution-tree.ts +3 -1
  25. package/src/core/family.ts +80 -1
  26. package/src/core/render.ts +2 -3
  27. package/src/core/toolcall.ts +3 -4
  28. package/src/core/tree.ts +1 -1
  29. package/src/core/turns.ts +1 -1
  30. package/src/core/units.ts +14 -0
  31. package/src/discovery/entry-anchor.ts +108 -0
  32. package/src/discovery/env.ts +41 -41
  33. package/src/discovery/find.ts +1 -1
  34. package/src/discovery/roots.ts +2 -2
  35. package/src/discovery/session-header.ts +30 -69
  36. package/src/discovery/subagents.ts +9 -2
  37. package/src/discovery/whitelist.ts +87 -0
  38. package/src/discovery/workflows.ts +1 -1
  39. package/src/discovery/zcode-manifest.ts +277 -0
  40. package/src/doctor.ts +5 -70
  41. package/src/index.ts +9 -2
  42. package/src/result-action.ts +15 -6
  43. package/src/search-across.ts +2 -5
  44. package/src/tool-format.ts +455 -0
  45. package/src/tool-handler.ts +321 -325
  46. package/src/zcode-anchor-classify.ts +60 -0
  47. package/vitest.config.ts +2 -4
  48. package/src/core/parser.ts +0 -167
@@ -0,0 +1,60 @@
1
+ /**
2
+ * [§3.5 entry 兜底归因] zcode subagent-record「在场但锚不完整」的缺失归因判定
3
+ *(classifyIncompleteEntryAnchor 的 entry 级纯逻辑提取,metrics-gate 复杂度偿还,
4
+ * 判定语义零变化;文件扫描与 readFile I/O 留在 tool-handler.ts)。零 I/O 纯函数,
5
+ * 可完全单测。
6
+ *
7
+ * 判定与 entry-anchor.ts 的 zcodeAnchorOfEntry 双键判据同构,协议字面量同值
8
+ *(写侧 subagent-core record-entry.ts SUBAGENT_RECORD_CUSTOM_TYPE,漂移由
9
+ * entry-anchor.test.ts 守卫)。
10
+ */
11
+ import type { Entry } from '@zhushanwen/session-core'
12
+ import type { ZcodeAnchorMissingReason } from './discovery/zcode-manifest.js'
13
+
14
+ /**
15
+ * 目标 sa-id 的 zcode 形态 subagent-record data:type/customType/v=1/id/engine 五关
16
+ * 全过才返回。engine 判别(D5)在此——`d.engine !== 'zcode'` 的记录(pi 形态:
17
+ * engine 缺省、sessionRef 无 dbPath)不是「zcode 锚不完整」,返回 undefined 跳过
18
+ * 不归因:误归因 missing-dbPath 会把「pi record 先于 manifest settle 落盘」的正常
19
+ * 窗口错报成 zcode_anchor_missing(旧版本产物指引不适用);正确归因 = undefined
20
+ * → 上层落 zcode_record_not_found。
21
+ */
22
+ function zcodeRecordDataOf(entry: Entry, saId: string): Record<string, unknown> | undefined {
23
+ if (entry.type !== 'custom' || entry.customType !== 'subagent-record') return undefined
24
+ const data: unknown = entry.data
25
+ if (typeof data !== 'object' || data === null) return undefined
26
+ const d = data as Record<string, unknown>
27
+ if (d.v !== 1 || d.id !== saId) return undefined
28
+ if (d.engine !== 'zcode') return undefined
29
+ return d
30
+ }
31
+
32
+ /** 已匹配 zcode record 的锚完整性链:返回第一个缺失键的归因;锚完整返回 undefined。 */
33
+ function anchorMissingReasonOf(record: Record<string, unknown>): ZcodeAnchorMissingReason | undefined {
34
+ const handle = record.engineHandle
35
+ if (typeof handle !== 'object' || handle === null) return 'missing-engineHandle'
36
+ const ref = (handle as Record<string, unknown>).sessionRef
37
+ if (typeof ref !== 'object' || ref === null) return 'missing-sessionRef'
38
+ const r = ref as Record<string, unknown>
39
+ if (typeof r.sessionId !== 'string' || r.sessionId === '') return 'missing-sessionId'
40
+ if (typeof r.dbPath !== 'string' || r.dbPath === '') return 'missing-dbPath'
41
+ return undefined
42
+ }
43
+
44
+ /**
45
+ * entries 中第一条「在场但锚不完整」的 zcode subagent-record 的缺失归因;记录完全
46
+ * 不在场(或命中的记录锚全完整)返回 undefined。锚完整的命中不终止扫描——同 sa-id
47
+ * 多条记录时后续残缺记录仍可归因(与提取前的整循环行为一致)。
48
+ */
49
+ export function firstIncompleteAnchorReason(
50
+ entries: readonly Entry[],
51
+ saId: string,
52
+ ): ZcodeAnchorMissingReason | undefined {
53
+ for (const entry of entries) {
54
+ const record = zcodeRecordDataOf(entry, saId)
55
+ if (record === undefined) continue
56
+ const reason = anchorMissingReasonOf(record)
57
+ if (reason !== undefined) return reason
58
+ }
59
+ return undefined
60
+ }
package/vitest.config.ts CHANGED
@@ -1,11 +1,9 @@
1
1
  import path from 'node:path'
2
2
 
3
- import { defineConfig } from 'vitest/config'
3
+ import { taijiTestConfig } from '../../../test-guard/factory.ts'
4
4
 
5
- export default defineConfig({
5
+ export default taijiTestConfig({
6
6
  test: {
7
- reporters: ['default', 'junit'],
8
- outputFile: { junit: './test-results/vitest-junit.xml' },
9
7
  include: ['src/__tests__/**/*.test.ts'],
10
8
  },
11
9
  resolve: {
@@ -1,167 +0,0 @@
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
- * 归一化 message 字段(阶段 helper:role 经值守卫收窄,缺/非法 role 时
56
- * 返回 undefined,调用方丢弃 message 字段——接口 role 必填)。
57
- */
58
- function parseEntryMessage(raw: unknown): Entry['message'] | undefined {
59
- if (raw === null || typeof raw !== 'object') return undefined
60
- const m = raw as Record<string, unknown>
61
- if (!isMessageRole(m.role)) return undefined
62
- const message: NonNullable<Entry['message']> = { role: m.role, content: m.content }
63
- if (Array.isArray(m.toolCalls)) message.toolCalls = m.toolCalls
64
- // toolResult 自带的工具关联字段(O2/O3 用,additive,实测 515/515 存在)
65
- if (typeof m.toolName === 'string') message.toolName = m.toolName
66
- if (typeof m.toolCallId === 'string') message.toolCallId = m.toolCallId
67
- return message
68
- }
69
-
70
- /** 归一化剩余可选字段(阶段 helper:customType/data/parentSession/cwd/summary)。 */
71
- function assignOptionalFields(obj: Record<string, unknown>, entry: Entry): void {
72
- if (typeof obj.customType === 'string') entry.customType = obj.customType
73
- if (obj.data !== undefined) entry.data = obj.data
74
- if (typeof obj.parentSession === 'string') entry.parentSession = obj.parentSession
75
- if (typeof obj.cwd === 'string') entry.cwd = obj.cwd
76
- if (obj.summary !== undefined) entry.summary = obj.summary
77
- }
78
-
79
- /**
80
- * 把单个已 JSON.parse 成功的原始对象归一化为 Entry。
81
- * 缺必填结构字段(type/id)返回 undefined,调用方计为坏行(skippedLines++)。
82
- */
83
- function toEntry(raw: unknown): Entry | undefined {
84
- if (typeof raw !== 'object' || raw === null) return undefined
85
- const obj = raw as Record<string, unknown>
86
- if (typeof obj.type !== 'string') return undefined
87
-
88
- // id 解析:pi appendCustomEntry 恒写顶层 id(session-manager.js:820-828)——
89
- // 无需 fallback。data.id 是扩展业务字段(如 subagent-identity payload),不是 entry id。
90
- // [W4 删除] 原 data.id fallback 分支对 pi 写出的 entry 是死分支(对历史/异构文件
91
- // 也无必要——entry id 语义以顶层为准)。
92
- const id: unknown = obj.id
93
- if (typeof id !== 'string') return undefined
94
-
95
- const entry: Entry = {
96
- type: obj.type,
97
- id: id as string,
98
- parentId: typeof obj.parentId === 'string' ? obj.parentId : null,
99
- }
100
- if (typeof obj.timestamp === 'string') entry.timestamp = obj.timestamp
101
-
102
- const message = parseEntryMessage(obj.message)
103
- if (message !== undefined) entry.message = message
104
-
105
- assignOptionalFields(obj, entry)
106
-
107
- return entry
108
- }
109
-
110
- /**
111
- * 解析 session JSONL 文本为 entries。
112
- *
113
- * 逐行 JSON.parse,坏行(语法错误或缺必填结构字段)计入 skippedLines 并跳过,
114
- * 不中断整体解析——pi 坏 session 容错(design §2 失败模式)。
115
- *
116
- * 末尾换行产生的空行忽略(不计 skipped、不计 partial)。最后一行 parse 失败时
117
- * lastLinePartial=true(活跃 session 写到一半的半行),区别于中间坏行。
118
- */
119
- export function parseSessionContent(content: string): ParseResult {
120
- const entries: Entry[] = []
121
- let skippedLines = 0
122
- let lastLinePartial = false
123
-
124
- const lines = content.split('\n')
125
- // 移除末尾因 trailing newline 产生的空行(非真实行)
126
- while (lines.length > 0 && lines[lines.length - 1] === '') {
127
- lines.pop()
128
- }
129
-
130
- for (let i = 0; i < lines.length; i++) {
131
- const line = lines[i]
132
- const isLast = i === lines.length - 1
133
-
134
- // 中间空行容错:pi 正常 jsonl 无空行,防御文件损坏;不计 skipped
135
- if (line.trim() === '') continue
136
-
137
- let raw: unknown
138
- try {
139
- raw = JSON.parse(line)
140
- } catch {
141
- skippedLines++
142
- if (isLast) lastLinePartial = true
143
- continue
144
- }
145
-
146
- const entry = toEntry(raw)
147
- if (entry === undefined) {
148
- skippedLines++
149
- if (isLast) lastLinePartial = true
150
- continue
151
- }
152
- entries.push(entry)
153
- }
154
-
155
- return {
156
- entries,
157
- skippedLines,
158
- totalBytes: Buffer.byteLength(content, 'utf8'),
159
- lastLinePartial,
160
- }
161
- }
162
-
163
- /** 读取 session 文件并解析。文件不存在按 Node fs 原生错误抛出(ENOENT)。 */
164
- export async function parseSessionFile(filePath: string): Promise<ParseResult> {
165
- const content = await readFile(filePath, 'utf8')
166
- return parseSessionContent(content)
167
- }