@zhushanwen/pi-session-reader 0.2.4 → 0.3.1

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.2.4",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "xyz-agent": {
@@ -145,7 +145,7 @@ describe('findSessions', () => {
145
145
  recordsDir = join(agentDir, 'subagents', '--Users-demo--', 'records')
146
146
  })
147
147
  afterEach(async () => {
148
- await rm(agentDir, { recursive: true, force: true })
148
+ await rm(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
149
149
  })
150
150
 
151
151
  it('uuid 片段匹配正确的 session(sessionId 含 query)', async () => {
@@ -256,6 +256,20 @@ describe('findSessions', () => {
256
256
  expect(result).toEqual({ matches: [], truncated: false })
257
257
  })
258
258
 
259
+ it('uuid 片段命中路径的 firstMessagePreview 补读(命中时未读首消息,第 5 步补)', async () => {
260
+ // uuid 片段直接命中 → 不走关键词层(preview 未读),最终结果仍应补读出首消息预览
261
+ await makeSession(slugDir, {
262
+ name: 'a.jsonl',
263
+ id: '019cafe0-aaaa-bbbb',
264
+ cwd: '/demo',
265
+ firstUserText: 'uuid 命中后补读的预览文本',
266
+ })
267
+ const { matches } = await findSessions('019cafe0', agentDir)
268
+ expect(matches).toHaveLength(1)
269
+ expect(matches[0].sessionId).toBe('019cafe0-aaaa-bbbb')
270
+ expect(matches[0].firstMessagePreview).toBe('uuid 命中后补读的预览文本')
271
+ })
272
+
259
273
  it('firstMessagePreview 截断到 80 字符', async () => {
260
274
  const longText = 'X'.repeat(200)
261
275
  await makeSession(slugDir, {
@@ -93,7 +93,7 @@ describe('renderOutline', () => {
93
93
  })
94
94
 
95
95
  it('2a. 降级:单行 toolSummary 过长超 perTurnBudget → 砍 toolSummary,保 userBrief 骨架', () => {
96
- // L1 行不含 assistantBrief(design §3.5 算法1 step2);budget=40, 3 turns → perTurnCharBudget≈53
96
+ // 预算紧张降级后 L1 行不含 assistantBrief(预算充足 level 0 行含);budget=40, 3 turns → perTurnCharBudget≈53
97
97
  // 行 = head + userBrief(10) + toolSummary(20 个工具名≈63 chars) → ~80 > 53 → 砍 toolSummary
98
98
  const toolCalls = Array.from({ length: 20 }, (_, k) => ({ name: `t${String(k).padStart(2, '0')}` }))
99
99
  const turns = [0, 1, 2].map((i) =>
@@ -183,7 +183,7 @@ describe('renderOutline', () => {
183
183
  // stats 完整性
184
184
  expect(result.stats.totalTurns).toBe(32)
185
185
  // totalEntries 近似(leaf+branch+orphan)不含 session header(segmentTurns 规则1 跳过);
186
- // 准确值由 M2 工具层用 ParseResult.totalEntries 覆盖。M1 验量级。
186
+ // 工具层仅覆盖 stats.totalBytes 与 skippedLines(ParseResult 无 totalEntries 字段)。M1 验量级。
187
187
  expect(result.stats.totalEntries).toBeGreaterThan(1000)
188
188
  // 5.6MB 全量解析在并发/高负载下可能超 vitest 默认 5s,显式放宽
189
189
  }, 60000)
@@ -22,7 +22,7 @@ import {
22
22
  * 测试框架 vitest(禁止 node:test/tsx)。直接调 handleSessionRead(纯逻辑,agentDir 注入),
23
23
  * 传真实 `/Users/zhushanwen/.pi/agent` 作 agentDir——用本机真实历史 session 数据,无需 mock。
24
24
  *
25
- * 覆盖 7 action 主路径 + F1(find 零匹配)/F4(turn 越界)/F5(缺参)/resolveSessionId 片段等价。
25
+ * 覆盖 9 action 主路径 + F1(find 零匹配)/F4(turn 越界)/F5(缺参)/resolveSessionId 片段等价。
26
26
  *
27
27
  * 真实数据用例全部带 skipIf 守卫(CI 无本机 ~/.pi/agent → skip,不硬失败);
28
28
  * renderExtractItems F9 截断是纯 fixture,无条件跑。
@@ -330,7 +330,7 @@ describe('F2 多匹配消歧(fixture,MF-9)', () => {
330
330
  dir = await mkdtemp(join(tmpdir(), 'tool-handler-f2-'))
331
331
  })
332
332
  afterEach(async () => {
333
- await rm(dir, { recursive: true, force: true })
333
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
334
334
  })
335
335
 
336
336
  it('共享 uuid 片段 → 不抛错,content 含两候选 + 👉,details.ambiguous=true', async () => {
@@ -380,7 +380,7 @@ describe('outline skippedLines 报告(fixture,D8d 有检测必有报告)',
380
380
  dir = await mkdtemp(join(tmpdir(), 'tool-handler-skipped-'))
381
381
  })
382
382
  afterEach(async () => {
383
- await rm(dir, { recursive: true, force: true })
383
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
384
384
  })
385
385
 
386
386
  it('坏行计入 stats.skippedLines 且文本尾部可见(不静默跳过)', async () => {
@@ -423,7 +423,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
423
423
  await makeFixtureSession(dir, SID, 'aaa plugin 内容')
424
424
  })
425
425
  afterEach(async () => {
426
- await rm(dir, { recursive: true, force: true })
426
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
427
427
  })
428
428
 
429
429
  it('嵌套量词 pattern (a+)+ → 降级字面子串(不挂死,零命中)', async () => {
@@ -523,6 +523,143 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
523
523
  })
524
524
  })
525
525
 
526
+ describe('extract commits 双路径(fixture:git-cmd 主路径 + commit-context 次路径 + 去重)', () => {
527
+ let dir: string
528
+ const SID = '019e6c96-bbbb-cccc-dddd-00000000000c'
529
+
530
+ beforeEach(async () => {
531
+ dir = await mkdtemp(join(tmpdir(), 'tool-handler-commits-'))
532
+ })
533
+ afterEach(async () => {
534
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
535
+ })
536
+
537
+ /** 写单个 session JSONL(commits 用例行内联传入)。 */
538
+ async function writeCommitsSession(id: string, lines: string[]): Promise<void> {
539
+ await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
540
+ await writeFile(
541
+ join(dir, 'sessions', '--demo-cwd--', `${id}.jsonl`),
542
+ lines.join('\n') + '\n',
543
+ )
544
+ }
545
+
546
+ it('commit-context 次路径:非 git bash 的 toolResult,hash 邻近含关键词才收(D6)', async () => {
547
+ await writeCommitsSession(SID, [
548
+ JSON.stringify({ type: 'session', id: SID, cwd: '/demo' }),
549
+ JSON.stringify({
550
+ type: 'message',
551
+ id: `${SID}-m1`,
552
+ parentId: SID,
553
+ message: {
554
+ role: 'assistant',
555
+ content: [
556
+ { type: 'toolCall', id: 'tc-read-1', name: 'read', arguments: { path: '/tmp/log.txt' } },
557
+ ],
558
+ },
559
+ }),
560
+ // read 结果:hash 邻近 30 字符窗口含 'feat:' → commit-context 次路径纳入
561
+ JSON.stringify({
562
+ type: 'message',
563
+ id: `${SID}-m2`,
564
+ parentId: `${SID}-m1`,
565
+ message: {
566
+ role: 'toolResult',
567
+ toolName: 'read',
568
+ toolCallId: 'tc-read-1',
569
+ content: [{ type: 'text', text: 'deploy abc1234f done\nfeat: add login' }],
570
+ },
571
+ }),
572
+ // read 结果:hash 无关键词邻近 → 两路径都不收(过滤分支)
573
+ JSON.stringify({
574
+ type: 'message',
575
+ id: `${SID}-m3`,
576
+ parentId: `${SID}-m2`,
577
+ message: {
578
+ role: 'assistant',
579
+ content: [
580
+ { type: 'toolCall', id: 'tc-read-2', name: 'read', arguments: { path: '/tmp/out.txt' } },
581
+ ],
582
+ },
583
+ }),
584
+ JSON.stringify({
585
+ type: 'message',
586
+ id: `${SID}-m4`,
587
+ parentId: `${SID}-m3`,
588
+ message: {
589
+ role: 'toolResult',
590
+ toolName: 'read',
591
+ toolCallId: 'tc-read-2',
592
+ content: [{ type: 'text', text: 'output 9998887 plain text' }],
593
+ },
594
+ }),
595
+ ])
596
+ const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, dir)
597
+ const d = r.details as { count: number; items: Array<{ hash: string; source: string }> }
598
+ const hashes = d.items.map((it) => it.hash)
599
+ expect(hashes).toContain('abc1234f')
600
+ expect(hashes).not.toContain('9998887')
601
+ expect(d.items.find((it) => it.hash === 'abc1234f')?.source).toBe('commit-context')
602
+ })
603
+
604
+ it('去重高置信优先:同 hash 同时命中 git-cmd 与 commit-context → 保留 git-cmd 一条', async () => {
605
+ await writeCommitsSession(SID, [
606
+ JSON.stringify({ type: 'session', id: SID, cwd: '/demo' }),
607
+ JSON.stringify({
608
+ type: 'message',
609
+ id: `${SID}-m1`,
610
+ parentId: SID,
611
+ message: {
612
+ role: 'assistant',
613
+ content: [
614
+ { type: 'toolCall', id: 'tc-bash-1', name: 'bash', arguments: { command: 'git log --oneline -3' } },
615
+ ],
616
+ },
617
+ }),
618
+ // bash + git log → git-cmd 高置信(含 def5678a)
619
+ JSON.stringify({
620
+ type: 'message',
621
+ id: `${SID}-m2`,
622
+ parentId: `${SID}-m1`,
623
+ message: {
624
+ role: 'toolResult',
625
+ toolName: 'bash',
626
+ toolCallId: 'tc-bash-1',
627
+ content: [{ type: 'text', text: 'def5678a feat: fix crash' }],
628
+ },
629
+ }),
630
+ JSON.stringify({
631
+ type: 'message',
632
+ id: `${SID}-m3`,
633
+ parentId: `${SID}-m2`,
634
+ message: {
635
+ role: 'assistant',
636
+ content: [
637
+ { type: 'toolCall', id: 'tc-read-1', name: 'read', arguments: { path: '/tmp/deploy.txt' } },
638
+ ],
639
+ },
640
+ }),
641
+ // read 结果同 hash def5678a + 关键词 merged → low 候选,去重后丢弃
642
+ JSON.stringify({
643
+ type: 'message',
644
+ id: `${SID}-m4`,
645
+ parentId: `${SID}-m3`,
646
+ message: {
647
+ role: 'toolResult',
648
+ toolName: 'read',
649
+ toolCallId: 'tc-read-1',
650
+ content: [{ type: 'text', text: 'deploy def5678a merged' }],
651
+ },
652
+ }),
653
+ ])
654
+ const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, dir)
655
+ const d = r.details as { count: number; items: Array<{ hash: string; source: string }> }
656
+ const dupes = d.items.filter((it) => it.hash === 'def5678a')
657
+ expect(dupes).toHaveLength(1)
658
+ expect(dupes[0].source).toBe('git-cmd')
659
+ expect(d.count).toBe(1)
660
+ })
661
+ })
662
+
526
663
  describe('renderExtractItems F9 截断(S3 首项超大 + S4 文案)', () => {
527
664
  it('S3: 首项超大内部截断 → body 不超预算,shown<count + truncated=true', () => {
528
665
  // 首项 10000 字符 >> EXTRACT_BUDGET_BYTES(8000);旧逻辑放行首项致 body≈10KB
@@ -635,7 +772,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
635
772
  dir = await mkdtemp(join(tmpdir(), 'tool-handler-path-'))
636
773
  })
637
774
  afterEach(async () => {
638
- await rm(dir, { recursive: true, force: true })
775
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
639
776
  })
640
777
 
641
778
  it('TC2: 绝对路径 outline → sessionId=header 真实 id(非文件名)', async () => {
@@ -723,7 +860,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
723
860
  )
724
861
  expect((r.details as { path: string }).path).toContain(fileId)
725
862
  } finally {
726
- await rm(tmpUnderHome, { recursive: true, force: true })
863
+ await rm(tmpUnderHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
727
864
  }
728
865
  })
729
866
  })
@@ -735,7 +872,7 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
735
872
  dir = await mkdtemp(join(tmpdir(), 'tool-handler-said-'))
736
873
  })
737
874
  afterEach(async () => {
738
- await rm(dir, { recursive: true, force: true })
875
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
739
876
  })
740
877
 
741
878
  it('TC7: sa-id 恰 1 命中 + sessionFile 存在 → 成功,sessionId=header 真实 id(非 sa-)', async () => {
@@ -782,7 +919,7 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
782
919
  await handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, dir)
783
920
  } catch (e) {
784
921
  const msg = (e as Error).message
785
- expect(msg).toContain('可能仍在运行')
922
+ expect(msg).toContain('可能尚未落盘')
786
923
  expect(msg).toContain('action:"family"')
787
924
  expect(msg).toContain('👉')
788
925
  }
@@ -858,7 +995,7 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
858
995
  dir = await mkdtemp(join(tmpdir(), 'tool-handler-src-'))
859
996
  })
860
997
  afterEach(async () => {
861
- await rm(dir, { recursive: true, force: true })
998
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
862
999
  })
863
1000
 
864
1001
  it('TC12: find source 过滤——subagent 只返回 subagent 候选,main 只返回 main', async () => {
@@ -1089,7 +1226,7 @@ describe('doWorkflow(w6,fixture)', () => {
1089
1226
  dir = await mkdtemp(join(tmpdir(), 'tool-handler-wf-'))
1090
1227
  })
1091
1228
  afterEach(async () => {
1092
- await rm(dir, { recursive: true, force: true })
1229
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
1093
1230
  })
1094
1231
 
1095
1232
  it('TC-w6-single-run:单 run 概览,content 含 run 头行/budget/step,details.runs/runIds 非空', async () => {
@@ -1370,7 +1507,7 @@ describe('doFamily recursive(m3b U8 接入)', () => {
1370
1507
  })
1371
1508
  })
1372
1509
  afterEach(async () => {
1373
- await rm(dir, { recursive: true, force: true })
1510
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
1374
1511
  })
1375
1512
 
1376
1513
  it('TC-m3b-dofamily-recursive-false:不传 recursive → flat family(m0-m2 零回归)', async () => {
@@ -210,6 +210,19 @@ describe('formatToolCallSummary — coding-workflow-* / unknown fallback', () =>
210
210
  circ.self = circ
211
211
  expect(formatToolCallSummary({ id: 'c1', name: 'my-tool', arguments: circ })).toBe('my-tool')
212
212
  })
213
+
214
+ it('treats prototype-chain key names as unknown (Map lookup, no Object.prototype hit)', () => {
215
+ // 表驱动用 Map 查找的语义锚:普通对象下标会把 "constructor"/"toString"/"__proto__"
216
+ // 解析到 Object.prototype(formatter 命中非函数成员返回对象/抛 TypeError),
217
+ // 这里必须与其它未知 toolName 同走 formatUnknownTool fallback 分支。
218
+ expect(formatToolCallSummary({ id: 'c1', name: 'constructor', arguments: {} })).toBe(
219
+ 'constructor',
220
+ )
221
+ expect(formatToolCallSummary({ id: 'c1', name: 'toString', arguments: {} })).toBe('toString')
222
+ expect(formatToolCallSummary({ id: 'c1', name: '__proto__', arguments: { a: 1 } })).toBe(
223
+ '__proto__: {"a":1}',
224
+ )
225
+ })
213
226
  })
214
227
 
215
228
  describe('extractToolCalls — 提取 + coerceArgs 兜底', () => {
@@ -151,6 +151,33 @@ describe('parseRunSnapshot', () => {
151
151
  expect(overview!.steps[1].contentPreview).toBe('OK')
152
152
  })
153
153
 
154
+ it('TC-w5-parse-new-status-unknown:NEW call.status 非 done/running/pending(如 failed)→ step.status=pending', () => {
155
+ // call.status 出现三态外的值(如上游异常态)时收窄为 pending,其余字段照常提取
156
+ const fixture = {
157
+ ...NEW_SNAPSHOT_FIXTURE,
158
+ state: {
159
+ ...NEW_SNAPSHOT_FIXTURE.state,
160
+ calls: [
161
+ {
162
+ id: 3,
163
+ opts: { description: 'step-3' },
164
+ status: 'failed',
165
+ result: { content: 'partial', durationMs: 9 },
166
+ },
167
+ ],
168
+ },
169
+ }
170
+ const overview = parseRunSnapshot(fixture, 'wf-status-runid', '/abs/wf.jsonl')
171
+ expect(overview).not.toBeNull()
172
+ expect(overview!.steps).toHaveLength(1)
173
+ const step = overview!.steps[0]
174
+ expect(step.index).toBe(3)
175
+ expect(step.status).toBe('pending')
176
+ expect(step.description).toBe('step-3')
177
+ expect(step.contentPreview).toBe('partial')
178
+ expect(step.durationMs).toBe(9)
179
+ })
180
+
154
181
  it('TC-w5-parse-old:OLD 格式 (无 v) 尽力解析为 legacy overview,step status 推测', () => {
155
182
  const overview = parseRunSnapshot(OLD_SNAPSHOT_FIXTURE, 'wf-old-link', '/abs/wf-old.jsonl')
156
183
  expect(overview).not.toBeNull()
@@ -199,6 +226,37 @@ describe('extractCallSessionFiles', () => {
199
226
  const files = extractCallSessionFiles(NEW_SNAPSHOT_FIXTURE)
200
227
  expect(files).toEqual(['/abs/session.jsonl'])
201
228
  })
229
+
230
+ it('OLD 快照(无 v,callCache)从 value 顶层与 value.result 提取 sessionFile', () => {
231
+ const files = extractCallSessionFiles({
232
+ callCache: [
233
+ { key: 0, value: { sessionFile: '/abs/old-call0.jsonl' } },
234
+ { key: 1, value: { result: { sessionFile: '/abs/old-call1.jsonl' } } },
235
+ ],
236
+ })
237
+ expect(files).toEqual(['/abs/old-call0.jsonl', '/abs/old-call1.jsonl'])
238
+ })
239
+
240
+ it('OLD 快照 value 无 sessionFile / value 非对象 → 跳过该 call,不产出路径', () => {
241
+ // 对齐 OLD_SNAPSHOT_FIXTURE 形状(旧 pi 不持久化 sessionFile)+ value 非对象脏数据回退 call 本身
242
+ const files = extractCallSessionFiles({
243
+ callCache: [
244
+ { key: 7, value: { content: '', usage: { input: 0 } } },
245
+ { key: 8, value: 42 },
246
+ { key: 9, sessionFile: '/abs/bare-call.jsonl' },
247
+ ],
248
+ })
249
+ expect(files).toEqual(['/abs/bare-call.jsonl'])
250
+ })
251
+
252
+ it('快照非对象 / calls 容器缺失 / call 元素非对象 → 空数组', () => {
253
+ expect(extractCallSessionFiles(null)).toEqual([])
254
+ expect(extractCallSessionFiles('wf-run-v1')).toEqual([])
255
+ expect(extractCallSessionFiles({ v: 'wf-run-v1', state: null })).toEqual([])
256
+ expect(extractCallSessionFiles({ v: 'wf-run-v1', state: {} })).toEqual([])
257
+ expect(extractCallSessionFiles({ callCache: 'not-an-array' })).toEqual([])
258
+ expect(extractCallSessionFiles({ callCache: [null, 7, 'x'] })).toEqual([])
259
+ })
202
260
  })
203
261
 
204
262
  // ============================================================
@@ -282,7 +340,7 @@ describe('renderWorkflowOverview', () => {
282
340
  describe('readRunSnapshot', () => {
283
341
  let dir: string
284
342
  afterEach(async () => {
285
- if (dir) await rm(dir, { recursive: true, force: true }).catch(() => {})
343
+ if (dir) await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }).catch(() => {})
286
344
  })
287
345
 
288
346
  it('TC-w5-read-tail-fallback:末行半截 JSON 回退倒数第二行完整快照', async () => {
@@ -173,7 +173,6 @@ function collectRelatedRecords(
173
173
  // buildExecutionTree(IF3 核心)
174
174
  // ============================================================
175
175
 
176
- /** 建树过程的共享可变状态(避免递归函数参数爆炸,封装为 context)。 */
177
176
  /** 建树过程的共享可变状态(避免递归函数参数爆炸,封装为 context)。 */
178
177
  interface BuildContext {
179
178
  rootSessionId: string
@@ -11,7 +11,7 @@ import type { Entry } from './parser.js'
11
11
  * - SessionRef.mtime/sizeBytes:从 fileStats 取(M1 key=sessionId),取不到为 0
12
12
  * - SubagentRef.sessionId:identity entry 不含 subagent session 的 id → 占位用 entry.id
13
13
  * - SubagentRef.cwd:identity entry(custom 类型)无 cwd → 占位空串
14
- * - fileStats 的 key:M1 用 sessionId(M2 改用真实文件路径),cleanedUp 逻辑 M1/M2 通用
14
+ * - fileStats 的 key:sessionId(M2 实现沿用此 key,cleanedUp 按 sessionId 命中),逻辑通用
15
15
  *
16
16
  * 隔代关联规则(design §3.3 D-7 Q1)见 resolveFamily 注释。
17
17
  */
@@ -24,7 +24,6 @@ export interface SessionRef {
24
24
  cwd: string
25
25
  /** fork 文件指向来源的路径(来自 header 的 parentSession,原始文件路径字符串) */
26
26
  parentSession?: string
27
- name?: string
28
27
  }
29
28
 
30
29
  export interface SubagentRef extends SessionRef {
@@ -135,24 +134,12 @@ function resolveParentSessionId(
135
134
  return null
136
135
  }
137
136
 
138
- /**
139
- * 从已读入的 session headers + subagent identity entries 建家族索引(纯逻辑,无 IO)。
140
- *
141
- * - headers(type=session)→ byId + childrenOf(parentSession 文件路径反查父 sessionId)
142
- * - subagentIdentities(type=custom, customType=subagent-identity)→ subagentsByRoot
143
- * - fileStats 原样存入 index,供 cleanedUp 判断
144
- *
145
- * 坏数据容错:identity 缺 rootSessionId/slug 跳过;parentSession 反查不到父(父文件
146
- * 未被扫描到)该 entry 不进 childrenOf——均不报错,符合 pi 坏 session 容错(design §2)。
147
- */
148
- export function buildFamilyIndex(
137
+ /** 阶段 1:headers → byId(SessionRef 组装,mtime/size 从 fileStats 取,取不到为 0)。 */
138
+ function buildById(
149
139
  headers: Entry[],
150
- subagentIdentities: Entry[],
151
140
  fileStats: Map<string, { mtime: number; size: number }>,
152
- ): FamilyIndex {
141
+ ): Map<string, SessionRef> {
153
142
  const byId = new Map<string, SessionRef>()
154
-
155
- // 1. headers → byId
156
143
  for (const h of headers) {
157
144
  const stat = fileStats.get(h.id)
158
145
  const ref: SessionRef = {
@@ -165,8 +152,17 @@ export function buildFamilyIndex(
165
152
  if (h.parentSession) ref.parentSession = h.parentSession
166
153
  byId.set(h.id, ref)
167
154
  }
155
+ return byId
156
+ }
168
157
 
169
- // 2. childrenOf:parentSession(文件路径)→ 反查父 sessionId → key 用父 sessionId
158
+ /**
159
+ * 阶段 2:childrenOf——parentSession(文件路径)→ 反查父 sessionId → key 用父 sessionId。
160
+ * 反查不到父 → 无法建反查关系,跳过(不报错,design §2 容错)。
161
+ */
162
+ function buildChildrenOf(
163
+ headers: Entry[],
164
+ byId: Map<string, SessionRef>,
165
+ ): Map<string, SessionRef[]> {
170
166
  const childrenOf = new Map<string, SessionRef[]>()
171
167
  for (const h of headers) {
172
168
  if (!h.parentSession) continue
@@ -178,8 +174,17 @@ export function buildFamilyIndex(
178
174
  list.push(childRef)
179
175
  childrenOf.set(parentSid, list)
180
176
  }
177
+ return childrenOf
178
+ }
181
179
 
182
- // 3. subagentIdentities → subagentsByRoot
180
+ /**
181
+ * 阶段 3:subagentIdentities → subagentsByRoot。
182
+ * 坏数据(缺 rootSessionId/slug)跳过,不报错(design §2 容错)。
183
+ */
184
+ function buildSubagentsByRoot(
185
+ subagentIdentities: Entry[],
186
+ fileStats: Map<string, { mtime: number; size: number }>,
187
+ ): Map<string, SubagentRef[]> {
183
188
  const subagentsByRoot = new Map<string, SubagentRef[]>()
184
189
  for (const ident of subagentIdentities) {
185
190
  if (!isSubagentIdentityData(ident.data)) continue // 坏数据(缺 rootSessionId/slug)跳过
@@ -194,7 +199,7 @@ export function buildFamilyIndex(
194
199
  mtime: stat?.mtime ?? 0,
195
200
  sizeBytes: stat?.size ?? 0,
196
201
  cwd: '', // identity entry 无 cwd;M2 从 subagent 文件 header 补
197
- // M1: fileStats key=sessionId;M2 改用 subagent 真实文件路径(SubagentRef.fileName)查 fileStats
202
+ // fileStats key=sessionId(buildFamilyFromFs 以 header.id 写入),cleanedUp 按 sessionId 命中
198
203
  cleanedUp: !fileStats.has(ident.id),
199
204
  // U4 富字段:从 identity data 读(manifest 主/P-fallback 由 buildFamilyFromFs 组装时决定)
200
205
  task: ident.data.task,
@@ -208,7 +213,27 @@ export function buildFamilyIndex(
208
213
  list.push(ref)
209
214
  subagentsByRoot.set(ident.data.rootSessionId, list)
210
215
  }
216
+ return subagentsByRoot
217
+ }
211
218
 
219
+ /**
220
+ * 从已读入的 session headers + subagent identity entries 建家族索引(纯逻辑,无 IO)。
221
+ *
222
+ * - headers(type=session)→ byId + childrenOf(parentSession 文件路径反查父 sessionId)
223
+ * - subagentIdentities(type=custom, customType=subagent-identity)→ subagentsByRoot
224
+ * - fileStats 原样存入 index,供 cleanedUp 判断
225
+ *
226
+ * 坏数据容错:identity 缺 rootSessionId/slug 跳过;parentSession 反查不到父(父文件
227
+ * 未被扫描到)该 entry 不进 childrenOf——均不报错,符合 pi 坏 session 容错(design §2)。
228
+ */
229
+ export function buildFamilyIndex(
230
+ headers: Entry[],
231
+ subagentIdentities: Entry[],
232
+ fileStats: Map<string, { mtime: number; size: number }>,
233
+ ): FamilyIndex {
234
+ const byId = buildById(headers, fileStats)
235
+ const childrenOf = buildChildrenOf(headers, byId)
236
+ const subagentsByRoot = buildSubagentsByRoot(subagentIdentities, fileStats)
212
237
  return { byId, childrenOf, subagentsByRoot, fileStats }
213
238
  }
214
239
 
@@ -51,6 +51,31 @@ function isMessageRole(v: unknown): v is 'user' | 'assistant' | 'toolResult' {
51
51
  return v === 'user' || v === 'assistant' || v === 'toolResult'
52
52
  }
53
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
+
54
79
  /**
55
80
  * 把单个已 JSON.parse 成功的原始对象归一化为 Entry。
56
81
  * 缺必填结构字段(type/id)返回 undefined,调用方计为坏行(skippedLines++)。
@@ -74,24 +99,10 @@ function toEntry(raw: unknown): Entry | undefined {
74
99
  }
75
100
  if (typeof obj.timestamp === 'string') entry.timestamp = obj.timestamp
76
101
 
77
- // message:role 经值守卫收窄,缺/非法 role 时丢弃 message 字段(接口 role 必填)
78
- if (obj.message !== null && typeof obj.message === 'object') {
79
- const m = obj.message as Record<string, unknown>
80
- if (isMessageRole(m.role)) {
81
- const message: NonNullable<Entry['message']> = { role: m.role, content: m.content }
82
- if (Array.isArray(m.toolCalls)) message.toolCalls = m.toolCalls
83
- // toolResult 自带的工具关联字段(O2/O3 用,additive,实测 515/515 存在)
84
- if (typeof m.toolName === 'string') message.toolName = m.toolName
85
- if (typeof m.toolCallId === 'string') message.toolCallId = m.toolCallId
86
- entry.message = message
87
- }
88
- }
102
+ const message = parseEntryMessage(obj.message)
103
+ if (message !== undefined) entry.message = message
89
104
 
90
- if (typeof obj.customType === 'string') entry.customType = obj.customType
91
- if (obj.data !== undefined) entry.data = obj.data
92
- if (typeof obj.parentSession === 'string') entry.parentSession = obj.parentSession
93
- if (typeof obj.cwd === 'string') entry.cwd = obj.cwd
94
- if (obj.summary !== undefined) entry.summary = obj.summary
105
+ assignOptionalFields(obj, entry)
95
106
 
96
107
  return entry
97
108
  }