@zhushanwen/pi-session-reader 0.5.0 → 0.6.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.
@@ -30,7 +30,6 @@ const NEW_SNAPSHOT_FIXTURE = {
30
30
  opts: {
31
31
  prompt: 'Reply with exactly: PROBE-OK',
32
32
  model: 'deepseek-router/ds-pro',
33
- thinkingLevel: 'high',
34
33
  description: 'step-0',
35
34
  },
36
35
  status: 'done',
@@ -64,7 +63,7 @@ const V2_SNAPSHOT_FIXTURE = {
64
63
  calls: [
65
64
  {
66
65
  id: 0,
67
- opts: { prompt: 'task 0', model: 'default', thinkingLevel: 'high', description: 'v2-step-0' },
66
+ opts: { prompt: 'task 0', model: 'default', description: 'v2-step-0' },
68
67
  status: 'done',
69
68
  attempts: 1,
70
69
  sessionId: '019v2a',
@@ -126,12 +125,10 @@ describe('parseRunSnapshot', () => {
126
125
  expect(step.status).toBe('done')
127
126
  expect(step.description).toBe('step-0')
128
127
  expect(step.model).toBe('deepseek-router/ds-pro')
129
- expect(step.thinkingLevel).toBe('high')
130
128
  expect(step.attempts).toBe(1)
131
129
  expect(step.durationMs).toBe(1234)
132
130
  expect(step.sessionId).toBe('019xxx')
133
131
  expect(step.sessionFile).toBe('/abs/session.jsonl')
134
- expect(step.contentPreview).toBe('PROBE-OK')
135
132
  })
136
133
 
137
134
  it('TC-w5-parse-new-v2:v2 快照(pi-subagent-workflow 8.x 写入)解析非 null,calls sessionFile 读出', () => {
@@ -148,7 +145,6 @@ describe('parseRunSnapshot', () => {
148
145
  expect(overview!.steps[0].description).toBe('v2-step-0')
149
146
  expect(overview!.steps[1].sessionFile).toBe('/abs/v2-call1.jsonl')
150
147
  expect(overview!.steps[1].sessionId).toBe('019v2b')
151
- expect(overview!.steps[1].contentPreview).toBe('OK')
152
148
  })
153
149
 
154
150
  it('TC-w5-parse-new-status-unknown:NEW call.status 非 done/running/pending(如 failed)→ step.status=pending', () => {
@@ -174,7 +170,6 @@ describe('parseRunSnapshot', () => {
174
170
  expect(step.index).toBe(3)
175
171
  expect(step.status).toBe('pending')
176
172
  expect(step.description).toBe('step-3')
177
- expect(step.contentPreview).toBe('partial')
178
173
  expect(step.durationMs).toBe(9)
179
174
  })
180
175
 
@@ -193,7 +188,6 @@ describe('parseRunSnapshot', () => {
193
188
  expect(step.index).toBe(0) // callCache 顺序索引
194
189
  expect(step.status).toBe('pending') // content='' 空串不算完成标志 → pending
195
190
  expect(step.sessionFile).toBeUndefined() // OLD 未持久化
196
- expect(step.contentPreview).toBe('') // value.content='' 仍提取
197
191
  })
198
192
 
199
193
  it('TC-w5-parse-corrupt-nonobject:非对象输入(null/undefined/string/number/array)返回 null', () => {
@@ -59,6 +59,7 @@ export interface ExecutionTreeNode {
59
59
  agentName?: string
60
60
  slug?: string
61
61
  task?: string
62
+ /** 仅 details 可见,不进文本渲染(formatExecutionTreeText 渲染 status/slug/agentName/task,与 SubagentRef.model 同口径) */
62
63
  model?: string
63
64
  /** subagent 终态 completed/failed/running(manifest.status) */
64
65
  status?: string
@@ -6,11 +6,14 @@ import type { Entry } from './parser.js'
6
6
  * M1 范围:纯逻辑层。buildFamilyIndex 接收已读入的 entry 数组(不做文件 IO),
7
7
  * 文件扫描/首行读取归 M2 discovery 层(roots.ts/find.ts/subagents.ts)。
8
8
  *
9
- * M1 占位约定(M2 discovery 层补全真实值,逻辑不变):
10
- * - SessionRef.fileName:session header 推不出文件路径 → 占位空串
9
+ * 占位约定(原「M2 回填补全」双阶段约定已随 enrichRefs 删除退役,ext-simplify-04 U4/E2
10
+ * ——回填的 fileName/cwd 零文本读者;SessionRef 类型与 find 路径共享,find 侧两字段有
11
+ * 真实消费者,字段本身保留):
12
+ * - SessionRef.fileName / subagent cwd:family 路径恒为占位空串(session header 推不出
13
+ * 路径 / identity entry 无 cwd),不再回填
11
14
  * - SessionRef.mtime/sizeBytes:从 fileStats 取(M1 key=sessionId),取不到为 0
12
15
  * - SubagentRef.sessionId:identity entry 不含 subagent session 的 id → 占位用 entry.id
13
- * - SubagentRef.cwd:identity entry(custom 类型)无 cwd → 占位空串
16
+ * (M2 buildFamilyFromFs 以 header.id 替换为真实 id)
14
17
  * - fileStats 的 key:sessionId(M2 实现沿用此 key,cleanedUp 按 sessionId 命中),逻辑通用
15
18
  *
16
19
  * 隔代关联规则(design §3.3 D-7 Q1)见 resolveFamily 注释。
@@ -35,11 +38,11 @@ export interface SubagentRef extends SessionRef {
35
38
  task?: string
36
39
  /** agent 类型名(manifest.agentName,P-fallback 时 identity.data.agent;同语义异名) */
37
40
  agentName?: string
38
- /** 模型 id(仅 manifest 有;P-fallback 时 undefined) */
41
+ /** 模型 id(仅 manifest 有;P-fallback 时 undefined)。仅 details 可见,不进文本渲染(D2②:展示面只取 status/agentName/task) */
39
42
  model?: string
40
43
  /** 终态 completed/failed/running(仅 manifest 有;P-fallback 时 undefined) */
41
44
  status?: string
42
- /** subagent session.jsonl 绝对路径(manifest.sessionFile 或 alive 文件 meta.path) */
45
+ /** subagent session.jsonl 绝对路径(manifest.sessionFile 或 alive 文件 meta.path)。仅 details 可见,不进文本渲染 */
43
46
  sessionFile?: string
44
47
  }
45
48
 
@@ -118,16 +121,16 @@ function isSubagentIdentityData(v: unknown): v is SubagentIdentityData {
118
121
  * 文件名格式 `<timestamp>_<sessionId>.jsonl`,故路径字符串含父 sessionId。
119
122
  * 遍历已知 sessionId 做子串匹配反查。
120
123
  *
121
- * 兼容 parentSession 直接就是 sessionId 的简化场景(测试 fixture 常用)。
122
124
  * 假设 sessionId 互不为子串(pi 用 UUID,满足);M2 可优化为 fileName→sessionId
123
125
  * 索引反查(O(1)),当前遍历 O(N),家族索引文件数通常几十到几百,可接受。
126
+ *(原「parentSession 直接是 sessionId」快路径已删除,ext-simplify-04 A4——生产数据
127
+ * 恒为文件路径,该分支只服务测试 fixture 简化形态。)
124
128
  */
125
129
  function resolveParentSessionId(
126
130
  parentSession: string | undefined,
127
131
  byId: Map<string, SessionRef>,
128
132
  ): string | null {
129
133
  if (!parentSession) return null
130
- if (byId.has(parentSession)) return parentSession // 直接是 sessionId(简化场景)
131
134
  for (const sid of byId.keys()) {
132
135
  if (parentSession.includes(sid)) return sid
133
136
  }
@@ -144,7 +147,7 @@ function buildById(
144
147
  const stat = fileStats.get(h.id)
145
148
  const ref: SessionRef = {
146
149
  sessionId: h.id,
147
- fileName: '', // M1 占位:M2 discovery 补真实文件路径
150
+ fileName: '', // 占位空串:family 路径不回填(enrichRefs 已删除,ext-simplify-04 U4)
148
151
  mtime: stat?.mtime ?? 0,
149
152
  sizeBytes: stat?.size ?? 0,
150
153
  cwd: h.cwd ?? '',
@@ -198,7 +201,7 @@ function buildSubagentsByRoot(
198
201
  fileName: '', // M1 占位
199
202
  mtime: stat?.mtime ?? 0,
200
203
  sizeBytes: stat?.size ?? 0,
201
- cwd: '', // identity entry 无 cwd;M2 从 subagent 文件 header 补
204
+ cwd: '', // identity entry 无 cwd;family 路径不回填(enrichRefs 已删除,ext-simplify-04 U4)
202
205
  // fileStats key=sessionId(buildFamilyFromFs 以 header.id 写入),cleanedUp 按 sessionId 命中
203
206
  cleanedUp: !fileStats.has(ident.id),
204
207
  // U4 富字段:从 identity data 读(manifest 主/P-fallback 由 buildFamilyFromFs 组装时决定)
@@ -17,7 +17,8 @@ const ASSISTANT_BRIEF_MAX_CHARS = 80
17
17
  const ENTRY_BRIEF_MAX_CHARS = 100
18
18
  /** turn 索引显示宽度(T013 三位补零)。 */
19
19
  const TURN_INDEX_WIDTH = 3
20
- /** outline 默认 token 预算(OutlineOptions.budget 缺省值)。 */
20
+ /** outline 默认 token 预算(OutlineOptions.budget 缺省值)。生产路径(doOutline/doExport)
21
+ * 唯一取值:outline 是设计定档的 ~1500 token 全貌 TOC,无按调用方调节的需求。 */
21
22
  const OUTLINE_DEFAULT_BUDGET_TOKENS = 2000
22
23
  /** token 估算换算基数(chars/4,与 tokenEstimate 口径一致)。 */
23
24
  const CHARS_PER_TOKEN = 4
@@ -55,6 +56,11 @@ export interface TurnBrief {
55
56
 
56
57
  export interface OutlineResult {
57
58
  turns: TurnBrief[]
59
+ /**
60
+ * 渲染行(E7/D3 行渲染统一):formatLine 产出、降级与总预算截断后的最终展示行——
61
+ * 预算度量与工具层展示共用同一份(度量 = 展示 by construction),展示侧不得重建行格式。
62
+ */
63
+ lines: string[]
58
64
  /** skippedLines:JSON 解析失败/缺结构字段的行数(parser 检测,工具层覆盖精确值;render 层无 ParseResult 恒 0) */
59
65
  stats: { totalTurns: number; totalEntries: number; totalBytes: number; parsedBytes: number; skippedLines: number }
60
66
  /** chars / 4 近似(与 design P-outline 口径一致) */
@@ -64,7 +70,9 @@ export interface OutlineResult {
64
70
  }
65
71
 
66
72
  export interface OutlineOptions {
67
- /** 默认 2000(token) */
73
+ /** 测试注入缝(E10 定性):非模型可见参数——session_read schema 无 budget 入参,模型
74
+ * 无法也无需调节;存在动机 = 测试注入小预算触发降级档用例(砍 assistantBrief / 骨架档)。
75
+ * 生产路径不传,走 OUTLINE_DEFAULT_BUDGET_TOKENS 默认。 */
68
76
  budget?: number
69
77
  allBranches?: boolean
70
78
  /** 默认 turn;entry = 每 entry 一行不聚合(D-1 兜底,坏 session 调试) */
@@ -101,6 +109,26 @@ function blockThinking(block: ContentBlock): string {
101
109
  return ''
102
110
  }
103
111
 
112
+ /**
113
+ * E11 共享核:白名单提取 message.content 中 `type==='text'` 块的 text 字段(块 → string[])。
114
+ * string content 直取为单元素数组;非 string 非数组返回空数组。join 语义(''/'\n'/' ')
115
+ * 与空态语义(空串 vs undefined)留在各调用点组合——S7 警告适用:join 差异是行为敏感点,
116
+ * 不可在共享核合并。消费方:result-action('' join)/ extract('\n' join)/ find(' ' join
117
+ * + 空返 undefined)。
118
+ */
119
+ export function textBlockParts(content: unknown): string[] {
120
+ if (typeof content === 'string') return [content]
121
+ if (!Array.isArray(content)) return []
122
+ const parts: string[] = []
123
+ for (const b of content) {
124
+ if (b !== null && typeof b === 'object') {
125
+ const o = b as Record<string, unknown>
126
+ if (o.type === 'text' && typeof o.text === 'string') parts.push(o.text)
127
+ }
128
+ }
129
+ return parts
130
+ }
131
+
104
132
  /** 拼接 text 块文本(排除 thinking / toolCall / tool_use / tool_result 块)。 */
105
133
  function extractText(content: unknown): string {
106
134
  if (isStringContent(content)) return content
@@ -276,7 +304,7 @@ function findBranchForkPoint(turn: Turn, tree: TreeView): string | undefined {
276
304
  }
277
305
 
278
306
  // ---------------------------------------------------------------------------
279
- // 行渲染(预算度量依据)
307
+ // 行渲染(预算度量与展示同一份渲染行,E7/D3 行渲染统一)
280
308
  // ---------------------------------------------------------------------------
281
309
 
282
310
  function formatHHMM(timestamp?: string): string {
@@ -292,10 +320,11 @@ export function formatBytesMarker(bytes: number): string {
292
320
  }
293
321
 
294
322
  /**
295
- * 渲染单行为字符串(用于预算度量与降级判断)。
323
+ * 渲染单行为字符串(E7 行渲染统一:预算度量与展示同一份渲染行,OutlineResult.lines 的行工厂)。
296
324
  * v2 O1:L1 行含 assistantBrief(补 assistant 结论行让 outline 单独可决策,不再逼反复 expand)。
297
325
  * level: 0=全有(toolSummary + assistantBrief)/ 1=砍 assistantBrief / 2=再砍 toolSummary(骨架)。
298
326
  * userBrief + omittedBytes 骨架永保。assistantBrief 格式 `→ <结论>`,在 toolSummary 后、omitted 前。
327
+ * 旁支标记 `[旁支 N entries]`(branchSize = 该 forkPoint 下旁支子树 entry 数)。
299
328
  */
300
329
  function formatLine(b: TurnBrief, level: LineLevel, branchSize?: number): string {
301
330
  const parts: string[] = []
@@ -341,7 +370,8 @@ function applyBranchLabels(briefs: TurnBrief[], turns: Turn[], tree: TreeView):
341
370
  /**
342
371
  * 降级序逐行渲染(design §3.5 算法 1 step3):level 0 全有;超预算降到 level 1
343
372
  * 砍 assistantBrief;仍超降到 level 2 砍 toolSummary(骨架)。
344
- * 直接改写 brief 的 assistantBrief/toolSummary 字段,返回行缓存。
373
+ * 直接改写 brief 的 assistantBrief/toolSummary 字段,返回行缓存(E7 后随结果
374
+ * 返回为 OutlineResult.lines,预算度量与展示同一份)。
345
375
  */
346
376
  function renderDegradingLines(
347
377
  briefs: TurnBrief[],
@@ -408,7 +438,7 @@ export function renderOutline(
408
438
  }
409
439
 
410
440
  if (turns.length === 0) {
411
- return { turns: [], stats, tokenEstimate: 0 }
441
+ return { turns: [], lines: [], stats, tokenEstimate: 0 }
412
442
  }
413
443
 
414
444
  // granularity:entry —— 每 entry 一行,不聚合 turn(D-1 兜底)
@@ -428,11 +458,11 @@ export function renderOutline(
428
458
  // 3. 降级序渲染:level 0 全有 → level 1 砍 assistantBrief → level 2 骨架
429
459
  const lineCache = renderDegradingLines(briefs, tree, perTurnCharBudget)
430
460
 
431
- // 4. 总预算截断(从尾部丢弃)
461
+ // 4. 总预算截断(从尾部丢弃,lines 与 briefs 同步截短)
432
462
  const { totalChars, truncated } = truncateToTotalBudget(briefs, lineCache, budget)
433
463
 
434
464
  const tokenEstimate = Math.ceil(totalChars / CHARS_PER_TOKEN)
435
- return { turns: briefs, stats, tokenEstimate, truncated }
465
+ return { turns: briefs, lines: lineCache, stats, tokenEstimate, truncated }
436
466
  }
437
467
 
438
468
  /** granularity:entry 模式:每 entry 一行 TurnBrief,不聚合,仅总预算截断。 */
@@ -486,6 +516,7 @@ function renderEntryGranularity(
486
516
  }
487
517
  return {
488
518
  turns: briefs,
519
+ lines,
489
520
  stats,
490
521
  tokenEstimate: Math.ceil(totalChars / CHARS_PER_TOKEN),
491
522
  truncated,
@@ -568,7 +599,7 @@ function stripThinking(content: unknown): unknown {
568
599
 
569
600
  /**
570
601
  * L3 detail 默认摘要态的 toolResult entry(v2 O3:toolResult 不再整条消失,给中间态)。
571
- * includeToolResult:true 时 renderDetail 返回原 Entry(全文),否则返回此摘要。
602
+ * includeToolResult:true 时 renderDetail 直接 push 原 Entry(全文态真实路径),否则返回此摘要。
572
603
  */
573
604
  export interface ToolResultSummaryEntry {
574
605
  type: 'toolResultSummary'
@@ -580,8 +611,6 @@ export interface ToolResultSummaryEntry {
580
611
  headLines: string
581
612
  /** 结果文本总行数 */
582
613
  totalLines: number
583
- /** 原 toolResult entry(includeToolResult:true 时 renderDetail 改用此返回全文) */
584
- fullEntry: Entry
585
614
  }
586
615
 
587
616
  export function renderDetail(
@@ -620,7 +649,6 @@ export function renderDetail(
620
649
  summary: formatToolResultSummary(msg.toolName, tc, text),
621
650
  headLines,
622
651
  totalLines: lines.length,
623
- fullEntry: e,
624
652
  })
625
653
  }
626
654
  continue
@@ -11,25 +11,20 @@
11
11
  // 升版连带编译期影响本扩展;且上游类型只描述 NEW,OLD 仍需自处理(TC-wf-snapshot-version-union)。
12
12
  // session-reader 作为纯读取者,按字段存在性 + v 标记分支做「结构化快照」式解析,与上游解耦。
13
13
 
14
- // ---- 类型守卫 helpers ----
14
+ import { isRecord } from '@zhushanwen/pi-ext-guards'
15
15
 
16
- /** unknown → Record<string, unknown> 守卫(非对象或 null → false)。 */
17
- function isRecord(v: unknown): v is Record<string, unknown> {
18
- return typeof v === 'object' && v !== null
19
- }
16
+ // ---- 类型守卫 helpers ----
20
17
 
21
18
  /** unknown → string 收窄(非 string → undefined)。快照可选字符串字段的统一入口。 */
22
19
  function strOr(v: unknown): string | undefined {
23
20
  return typeof v === 'string' ? v : undefined
24
21
  }
25
22
 
26
- /** 字符串截断(超 max 加省略号)。概览预览用,全文走 detail。 */
23
+ /** 字符串截断(超 max 加省略号)。step 行渲染用。 */
27
24
  function truncate(s: string, max: number): string {
28
25
  return s.length <= max ? s : s.slice(0, max) + '…'
29
26
  }
30
27
 
31
- /** contentPreview 截断长度(概览预览,全文走 detail)。 */
32
- const CONTENT_PREVIEW_MAX = 120
33
28
  /** step 行 call sessionId 显示截断长度(uuid 前缀段,LLM 可读)。 */
34
29
  const SESSION_ID_PREVIEW_MAX = 12
35
30
 
@@ -61,8 +56,6 @@ export interface WorkflowStep {
61
56
  description?: string
62
57
  /** NEW call.opts.model */
63
58
  model?: string
64
- /** NEW call.opts.thinkingLevel */
65
- thinkingLevel?: string
66
59
  /** NEW call.attempts / OLD 无 → undefined */
67
60
  attempts?: number
68
61
  /** NEW call.result.durationMs / OLD value.result.durationMs */
@@ -71,8 +64,6 @@ export interface WorkflowStep {
71
64
  sessionId?: string
72
65
  /** call.sessionFile 或 result.sessionFile(LLM 跳 outline/detail 的绝对路径入口,OLD 多数为 undefined) */
73
66
  sessionFile?: string
74
- /** result.content 截断前 120 字(概览预览,全文走 detail) */
75
- contentPreview?: string
76
67
  }
77
68
 
78
69
  /** workflow run 概览(parseRunSnapshot 输出 / renderWorkflowOverview 输入)。 */
@@ -83,7 +74,7 @@ export interface WorkflowOverview {
83
74
  stateFile: string
84
75
  /** NEW state.status / OLD 顶层 status */
85
76
  status: string
86
- /** 格式标记(渲染/调试用)。v2 读取面形状与 v1 一致(pi-subagent-workflow 8.x 一次性生命周期) */
77
+ /** details 格式标记(随 details.runs 透传给程序化消费方,文本渲染不读)。v2 读取面形状与 v1 一致(pi-subagent-workflow 8.x 一次性生命周期) */
87
78
  version: 'wf-run-v1' | 'wf-run-v2' | 'legacy'
88
79
  /** NEW spec.scriptName / spec.name / OLD name */
89
80
  script?: string
@@ -138,19 +129,16 @@ function mapCallToStep(call: unknown, fallbackIndex: number): WorkflowStep {
138
129
  const index = typeof call.id === 'number' ? call.id : fallbackIndex
139
130
  const status = normalizeCallStatus(strOr(call.status) ?? '')
140
131
  const refs = pickSessionRefs(call, result)
141
- const content = strOr(result.content)
142
132
 
143
133
  return {
144
134
  index,
145
135
  status,
146
136
  description: strOr(opts.description),
147
137
  model: strOr(opts.model),
148
- thinkingLevel: strOr(opts.thinkingLevel),
149
138
  attempts: typeof call.attempts === 'number' ? call.attempts : undefined,
150
139
  durationMs: typeof result.durationMs === 'number' ? result.durationMs : undefined,
151
140
  sessionId: refs.sessionId,
152
141
  sessionFile: refs.sessionFile,
153
- contentPreview: content !== undefined ? truncate(content, CONTENT_PREVIEW_MAX) : undefined,
154
142
  }
155
143
  }
156
144
 
@@ -162,11 +150,11 @@ function mapCacheEntryToStep(entry: unknown, index: number): WorkflowStep {
162
150
 
163
151
  // sessionFile/sessionId: value.sessionFile 或 value.result.sessionFile(OLD 多数缺失,探针 112 文件 0)
164
152
  const refs = pickSessionRefs(value, result)
165
- // content:真实 OLD 数据 value.content(wf-skip-ok)与测试 fixture value.result.content 并存
153
+ // content:真实 OLD 数据 value.content(wf-skip-ok)与测试 fixture value.result.content 并存,
154
+ // 仅用于 status 推测(A4 死字段删除:contentPreview 已删,不再随 step 输出)
166
155
  const content = strOr(result.content) ?? strOr(value.content)
167
156
  // OLD 无 status 字段:有 sessionFile 或非空 content → done,否则 pending
168
- //(空 content 如 wf-skip-ok 的 '' 不算完成标志,对齐 TC-w5-parse-old expected status='pending';
169
- // content 仍提取为 contentPreview='')
157
+ //(空 content 如 wf-skip-ok 的 '' 不算完成标志,对齐 TC-w5-parse-old expected status='pending')
170
158
  const hasContent = content !== undefined && content.length > 0
171
159
  const status: WorkflowStep['status'] =
172
160
  refs.sessionFile !== undefined || hasContent ? 'done' : 'pending'
@@ -177,7 +165,6 @@ function mapCacheEntryToStep(entry: unknown, index: number): WorkflowStep {
177
165
  durationMs: typeof result.durationMs === 'number' ? result.durationMs : undefined,
178
166
  sessionId: refs.sessionId,
179
167
  sessionFile: refs.sessionFile,
180
- contentPreview: content !== undefined ? truncate(content, CONTENT_PREVIEW_MAX) : undefined,
181
168
  }
182
169
  }
183
170
 
@@ -1,15 +1,27 @@
1
1
  import { createReadStream, type ReadStream } from 'node:fs'
2
- import { open, stat, type FileHandle } from 'node:fs/promises'
2
+ import { stat } from 'node:fs/promises'
3
3
  import { createInterface } from 'node:readline'
4
4
  import { basename, dirname } from 'node:path'
5
5
  import type { SessionRef } from '../core/family.js'
6
+ import { textBlockParts } from '../core/render.js'
6
7
  import {
7
8
  resolveSessionRoots,
8
9
  type SessionFileMeta,
9
10
  type SessionRoot,
10
11
  type SessionRootSignals,
11
12
  } from './roots.js'
12
- import { listRecordManifests, extractSessionIdFromFilename, type RecordManifest } from './subagents.js'
13
+ // 首行 header 读取/解析在 discovery/session-header.ts(D5 单源)。
14
+ import {
15
+ parseSessionHeader,
16
+ readSessionHeaderFirstLine,
17
+ type SessionHeader,
18
+ } from './session-header.js'
19
+ import {
20
+ listRecordManifests,
21
+ extractSessionIdFromFilename,
22
+ readTailIdentity,
23
+ type RecordManifest,
24
+ } from './subagents.js'
13
25
 
14
26
  /**
15
27
  * M2 discovery 发现层:按 query 定位 session(design §3.3 D-3 + §3.4 find action)。
@@ -59,8 +71,8 @@ export interface SessionMetadataEntry {
59
71
  cwd: string
60
72
  /** 用户标题(session_info entry 的 name);旧 session 缺失 */
61
73
  name?: string
62
- /** pi 返回 Date,测试替身可传 number */
63
- modified: Date | number
74
+ /** pi 返回 Date(A4 收窄:原 `Date | number` 宽联合只服务测试替身,替身改传 Date) */
75
+ modified: Date
64
76
  /** 首消息全文(title 命中时填充 firstMessagePreview,免二次读文件) */
65
77
  firstMessage?: string
66
78
  }
@@ -74,30 +86,6 @@ export type SessionMetadataProvider = (dir: string) => Promise<SessionMetadataEn
74
86
 
75
87
  const DEFAULT_LIMIT = 20
76
88
  const PREVIEW_MAX = 80
77
- /** readFirstLine 单次读取 buffer 上限。session header(id/cwd/parentSession)远小于此。 */
78
- const HEADER_READ_BYTES = 8192
79
-
80
- /**
81
- * 读文件首行(header)。用定长 buffer 一次 read(避免 stream 开销),
82
- * 空文件/读失败返回 undefined。header 超 8KB 的极端情况会截断致 parse 失败——
83
- * session header(id+cwd)实测 < 300 字节,8KB 足够 27 倍余量。
84
- */
85
- async function readFirstLine(path: string): Promise<string | undefined> {
86
- let fh: FileHandle | undefined
87
- try {
88
- fh = await open(path, 'r')
89
- const buf = Buffer.alloc(HEADER_READ_BYTES)
90
- const { bytesRead } = await fh.read(buf, 0, HEADER_READ_BYTES, 0)
91
- if (bytesRead === 0) return undefined
92
- const content = buf.subarray(0, bytesRead).toString('utf8')
93
- const nl = content.indexOf('\n')
94
- return nl === -1 ? content : content.slice(0, nl)
95
- } catch {
96
- return undefined
97
- } finally {
98
- await fh?.close().catch(() => {})
99
- }
100
- }
101
89
 
102
90
  /** 从单行 JSON 提取 message entry 的 user role 文本,非 user message 行返回 undefined。 */
103
91
  function extractUserText(line: string): string | undefined {
@@ -120,22 +108,12 @@ function extractUserText(line: string): string | undefined {
120
108
  /**
121
109
  * 从 message content 提取可读文本。
122
110
  * 兼容 pi 两种形态:string content(直接用)与 array content(拼 type:text 项的 text)。
111
+ * E11 归一:共享核 textBlockParts 组合调用,' ' join 与零 text 块返 undefined 的语义
112
+ * 留在本调用点(find 匹配把 undefined 当「无文本」信号,空串会被 includes('') 误吸)。
123
113
  */
124
114
  function extractTextFromContent(content: unknown): string | undefined {
125
- if (typeof content === 'string') return content
126
- if (Array.isArray(content)) {
127
- const parts: string[] = []
128
- for (const item of content) {
129
- if (typeof item === 'object' && item !== null) {
130
- const it = item as Record<string, unknown>
131
- if (it.type === 'text' && typeof it.text === 'string') {
132
- parts.push(it.text)
133
- }
134
- }
135
- }
136
- return parts.length > 0 ? parts.join(' ') : undefined
137
- }
138
- return undefined
115
+ const parts = textBlockParts(content)
116
+ return parts.length > 0 ? parts.join(' ') : undefined
139
117
  }
140
118
 
141
119
  /**
@@ -163,30 +141,6 @@ async function readFirstUserMessageText(path: string): Promise<string | undefine
163
141
  }
164
142
  }
165
143
 
166
- interface SessionHeader {
167
- id: string
168
- cwd?: string
169
- parentSession?: string
170
- }
171
-
172
- /** 解析 header 首行为 SessionHeader。非 session 行/缺 id → null。 */
173
- function parseHeader(line: string | undefined): SessionHeader | null {
174
- if (!line) return null
175
- let raw: unknown
176
- try {
177
- raw = JSON.parse(line)
178
- } catch {
179
- return null
180
- }
181
- if (typeof raw !== 'object' || raw === null) return null
182
- const obj = raw as Record<string, unknown>
183
- if (obj.type !== 'session' || typeof obj.id !== 'string') return null
184
- const header: SessionHeader = { id: obj.id }
185
- if (typeof obj.cwd === 'string') header.cwd = obj.cwd
186
- if (typeof obj.parentSession === 'string') header.parentSession = obj.parentSession
187
- return header
188
- }
189
-
190
144
  /**
191
145
  * uuid 归一化键(design 2026-09-10 §6.7 子决策 1):小写 + 去连字符。
192
146
  * 修复 §3.3 的两个盲区——大写 uuid(String.includes 大小写敏感)与去连字符 uuid
@@ -230,59 +184,6 @@ interface Matched extends Candidate {
230
184
  // U5:subagent task/slug/agentName 匹配(manifest 索引 + P-fallback identity 回退)
231
185
  // ============================================================
232
186
 
233
- /** P-fallback 尾行 identity 读取窗口(同 subagents.ts,task 文本可达数 KB)。 */
234
- const TAIL_READ_BYTES = 65536
235
-
236
- /**
237
- * 读 subagent 文件尾部(最后 64KB)找 subagent-identity entry,返回 task/slug/agent。
238
- *
239
- * find 的 P-fallback 路径(场景 A:subagent 无 manifest,本机 11.5%):manifest 索引未命中时
240
- * 读尾行 identity 取 task/slug/agent 做 query 子串匹配。与 subagents.ts 的 readTailIdentity
241
- * 同源(64KB 窗口 + lastIndexOf 定位),但是 find 专用最小版(只取 task/slug/agent,不要
242
- * rootSessionId——find 候选已有 header.id)。不导出,不碰 subagents.ts(w3 冻结)。
243
- */
244
- async function readTailIdentityForMatch(
245
- path: string,
246
- size: number,
247
- ): Promise<{ task?: string; slug?: string; agent?: string } | undefined> {
248
- if (size === 0) return undefined
249
- let fh: FileHandle | undefined
250
- try {
251
- fh = await open(path, 'r')
252
- const len = Math.min(TAIL_READ_BYTES, size)
253
- const buf = Buffer.alloc(len)
254
- await fh.read(buf, 0, len, Math.max(0, size - len))
255
- const text = buf.toString('utf8')
256
- const idx = text.lastIndexOf('subagent-identity')
257
- if (idx < 0) return undefined
258
- const lineStartSearch = text.lastIndexOf('\n', idx)
259
- if (lineStartSearch < 0 && size > len) return undefined
260
- const start = lineStartSearch < 0 ? 0 : lineStartSearch + 1
261
- let end = text.indexOf('\n', idx)
262
- if (end < 0) end = text.length
263
- const line = text.slice(start, end)
264
- let raw: unknown
265
- try {
266
- raw = JSON.parse(line)
267
- } catch {
268
- return undefined
269
- }
270
- const data = (raw as Record<string, unknown> | undefined)?.data as
271
- | Record<string, unknown>
272
- | undefined
273
- if (!data) return undefined
274
- return {
275
- task: typeof data.task === 'string' ? data.task : undefined,
276
- slug: typeof data.slug === 'string' ? data.slug : undefined,
277
- agent: typeof data.agent === 'string' ? data.agent : undefined,
278
- }
279
- } catch {
280
- return undefined
281
- } finally {
282
- await fh?.close().catch(() => {})
283
- }
284
- }
285
-
286
187
  /**
287
188
  * 建 sessionId→RecordManifest 索引(U5:subagent task/slug/agentName 匹配用)。
288
189
  *
@@ -325,8 +226,11 @@ async function matchSubagentMetadata(
325
226
  (manifest.agentName?.includes(query) ?? false)
326
227
  )
327
228
  }
328
- // P-fallback:manifest 索引未命中 → 读尾行 identity 回退
329
- const ident = await readTailIdentityForMatch(candidate.meta.path, candidate.meta.size)
229
+ // P-fallback:manifest 索引未命中 → 读尾行 identity 回退。E8(ext-simplify-04):
230
+ // 复用 subagents.ts 的 readTailIdentity 单实现,取 task/slug/agent 子集。行为差异 =
231
+ // 缺 rootSessionId 的畸形 identity 行从「可匹配」变「不匹配」(m0 契约外数据,
232
+ // design §3.3 E8 登记为可接受)。
233
+ const ident = await readTailIdentity(candidate.meta.path, candidate.meta.size)
330
234
  if (!ident) return false
331
235
  return (
332
236
  (ident.task?.includes(query) ?? false) ||
@@ -362,9 +266,9 @@ function buildCandidate(
362
266
  *
363
267
  * u11 起直接消费 resolveSessionRoots 的根列表(单次实扫,files 与根归属信息同批产出——
364
268
  * 标题窄化策略需要「扫描结果无子目录的候选根」这一根级事实,薄包装的扁平列表给不出)。
365
- * 对只含 agentDir 的信号包,根集合 = [default]+[legacy]+[subagent],与旧薄包装
366
- * listMainSessions/listSubagentSessions 的并集逐文件一致(含 workflow-state 跳过与
367
- * realpath 去重语义);传 liveSessionDir 时按 §6.1 追加 [live] 根(realpath 去重保优先级)。
269
+ * 对只含 agentDir 的信号包,根集合 = [default]+[legacy]+[subagent],与已删除薄包装
270
+ * listMainSessions/listSubagentSessions(ext-simplify-04 U4/A3)原并集逐文件一致(含
271
+ * workflow-state 跳过与 realpath 去重语义);传 liveSessionDir 时按 §6.1 追加 [live] 根(realpath 去重保优先级)。
368
272
  */
369
273
  async function collectCandidates(
370
274
  roots: SessionRoot[],
@@ -376,7 +280,7 @@ async function collectCandidates(
376
280
  if (root.dedupedInto !== undefined) continue // 被去重根未实扫(files 恒空),不产候选
377
281
  if (sourceFilter !== undefined && root.source !== sourceFilter) continue
378
282
  for (const meta of root.files) {
379
- const header = parseHeader(await readFirstLine(meta.path))
283
+ const header = parseSessionHeader(await readSessionHeaderFirstLine(meta.path))
380
284
  if (!header) continue // 非 session 文件/坏 header → 跳过
381
285
  if (cwdFilter !== undefined && (header.cwd ?? '') !== cwdFilter) continue
382
286
  candidates.push(buildCandidate(meta, header, root.source))
@@ -409,11 +313,6 @@ async function pathExistsDir(path: string): Promise<boolean> {
409
313
  }
410
314
  }
411
315
 
412
- /** 条目 modified 的可比数值(pi 返回 Date,测试替身可传 number)。 */
413
- function modifiedOf(e: SessionMetadataEntry): number {
414
- return typeof e.modified === 'number' ? e.modified : e.modified.getTime()
415
- }
416
-
417
316
  /**
418
317
  * 平铺目录判据(§6.6 调用策略 ②):本根实扫出的全部 .jsonl 都直接位于根本身
419
318
  *(即扫描结果无子目录)。listAll(dir) 实装语义是「只扫一层平铺目录」——平铺根意味着
@@ -467,7 +366,7 @@ async function loadTitleIndex(ctx: MetadataContext): Promise<Map<string, Session
467
366
  }
468
367
  for (const e of entries) {
469
368
  const prev = index.get(e.id)
470
- if (prev === undefined || modifiedOf(e) > modifiedOf(prev)) index.set(e.id, e)
369
+ if (prev === undefined || e.modified.getTime() > prev.modified.getTime()) index.set(e.id, e)
471
370
  }
472
371
  }
473
372
  return index
@@ -590,7 +489,7 @@ async function loadRecentTitles(
590
489
  }
591
490
  for (const e of entries) {
592
491
  const prev = titles.get(e.id)
593
- if (prev === undefined || modifiedOf(e) > modifiedOf(prev)) titles.set(e.id, e)
492
+ if (prev === undefined || e.modified.getTime() > prev.modified.getTime()) titles.set(e.id, e)
594
493
  }
595
494
  }
596
495
  return titles
@@ -658,6 +557,12 @@ async function fillFirstMessagePreviews(
658
557
  * 去重)并作标题窄化目标;缺省 = 现状三根降级行为。
659
558
  * - opts.metadataProvider:标题元数据注入(仅 keyword 路径 + recent 截断后补充消费);
660
559
  * 缺省 = undefined = 现状行为(标题检索不可用,首条 user 匹配不受影响)。
560
+ *
561
+ * E1 预解析根复用(ext-simplify-04 §3 D1):opts.roots = 调用方预解析的根列表——同一
562
+ * 次 doFind 内多段查询(分组 main/subagent 两路)复用同一次根解析,目录扫描不随查询
563
+ * 段数翻倍。缺省 = 本函数自解析(既有测试与单段调用方零改动)。调用方须保证传入的
564
+ * roots 来自无 options 的 resolveSessionRoots 实扫(预解析是复用同一次实扫——根扫描
565
+ * 无缓存,doctor 缓存机已删除,ext-simplify-04 U3)。
661
566
  */
662
567
  export async function findSessions(
663
568
  query: string,
@@ -668,18 +573,21 @@ export async function findSessions(
668
573
  source?: SessionSource
669
574
  liveSessionDir?: string
670
575
  metadataProvider?: SessionMetadataProvider
576
+ /** 预解析根列表(E1/D1):提供则跳过内部 resolveSessionRoots,直接消费 */
577
+ roots?: SessionRoot[]
671
578
  },
672
579
  ): Promise<{ matches: MatchedSession[]; truncated: boolean }> {
673
580
  const limit = opts?.limit ?? DEFAULT_LIMIT
674
581
  const cwdFilter = opts?.cwd
675
582
  const sourceFilter = opts?.source
676
583
 
677
- // 0. 根解析(单次实扫,无 options——find 不读 doctor 缓存,§7B 要点 8 PS-14)
584
+ // 0. 根解析(单次实扫,无 options——根扫描无缓存);
585
+ // E1:调用方已预解析(opts.roots)时复用,不再自扫
678
586
  const signals: SessionRootSignals =
679
587
  opts?.liveSessionDir !== undefined && opts.liveSessionDir.length > 0
680
588
  ? { agentDir, liveSessionDir: opts.liveSessionDir }
681
589
  : { agentDir }
682
- const roots = await resolveSessionRoots(signals)
590
+ const roots = opts?.roots ?? (await resolveSessionRoots(signals))
683
591
  // 0+1. 逐根首行扫描建候选
684
592
  const candidates = await collectCandidates(roots, sourceFilter, cwdFilter)
685
593
  // 2. 三路匹配(recent / uuid 片段 / 名称关键词+U5 元数据+u11 标题)