@zhushanwen/pi-session-reader 0.5.1 → 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.
@@ -13,9 +13,9 @@ import { performance } from 'node:perf_hooks'
13
13
  * U1(design 2026-09-10 §6.1/§7B):新增 `resolveSessionRoots(signals)` —— 信号包 →
14
14
  * 带来源标签的候选根列表,逐根扫描、按 realpath 去重(同路径只扫一次,保留最高优先级
15
15
  * kind 标签)。B 收缩(design §6.13):布局对齐后无 `[env]` 信号,`[default]` 为主根。
16
- * 旧签名 `listMainSessions`/`listSubagentSessions` 保留为薄包装(内部构造只含 agentDir
17
- * 的信号包 → main 源退化为 `[default]`+`[legacy]` 两根),存量调用(find.ts /
18
- * subagents.ts)不破。
16
+ * 旧签名 `listMainSessions`/`listSubagentSessions` 薄包装已删除(ext-simplify-04 U4/A3:
17
+ * 内部即「resolveSessionRoots + filter」恒等式,包内原唯一消费者 subagents.ts 改直调;
18
+ * npm 深 import 移除按 minor breaking 在 CHANGELOG 登记)。
19
19
  */
20
20
 
21
21
  // ============================================================
@@ -39,9 +39,8 @@ export type SessionRootKind = 'live' | 'default' | 'legacy' | 'subagent'
39
39
  /** main 源 = 主 session 根(live/default/legacy);subagent 源 = subagent 会话根 */
40
40
  export type SessionRootSource = 'main' | 'subagent'
41
41
 
42
- /** 候选根(含扫描结果)。id 恒等于 kind:去重后每 kind 至多一个根,作 doctor 表行键。 */
42
+ /** 候选根(含扫描结果)。去重后每 kind 至多一个根,kind 即 doctor 表行键。 */
43
43
  export interface SessionRoot {
44
- id: SessionRootKind
45
44
  kind: SessionRootKind
46
45
  /** 规范化后的绝对路径(`[live]` 已剥 encodeCwd 层) */
47
46
  path: string
@@ -55,17 +54,10 @@ export interface SessionRoot {
55
54
  /** 扫描所得文件列表(design §7B 要点 8「本就在返回值里」);未扫描时空数组 */
56
55
  files: SessionFileMeta[]
57
56
  /**
58
- * realpath 与更高优先级根同路径 → 指向保留根的 id,本根未扫描(doctor 渲染
57
+ * realpath 与更高优先级根同路径 → 指向保留根的 kind,本根未扫描(doctor 渲染
59
58
  * 「与 N 同路径,已去重」注记的依据,§7B 要点 4)。
60
59
  */
61
60
  dedupedInto?: SessionRootKind
62
- /**
63
- * 本根数据来自调用方注入缓存(resolveSessionRoots options.cache 命中):fileCount/
64
- * scanMs/exists 取缓存值,files 恒空数组——消费方不得把 cached 根的 files 当实扫
65
- * 结果(u8 追加:doctor「同一数据源两处渲染」统一走 resolveSessionRoots,缓存语义
66
- * 由调用方注入,§6.3)。
67
- */
68
- cached?: boolean
69
61
  }
70
62
 
71
63
  /** 各根固定子目录名(常量推导,不来自信号——§6.1 信号 5) */
@@ -103,8 +95,8 @@ function sessionRootSpecs(signals: SessionRootSignals): SessionRoot[] {
103
95
  return specs
104
96
  }
105
97
 
106
- function spec(id: SessionRootKind, source: SessionRootSource, path: string): SessionRoot {
107
- return { id, kind: id, source, path, exists: false, files: [] }
98
+ function spec(kind: SessionRootKind, source: SessionRootSource, path: string): SessionRoot {
99
+ return { kind, source, path, exists: false, files: [] }
108
100
  }
109
101
 
110
102
  /** realpath 解析失败(目录不存在等)→ 回退字面路径作去重键(同字面路径仍可去重) */
@@ -129,37 +121,18 @@ const SKIP_DIRS_NONE = new Set<string>()
129
121
 
130
122
  // ============================================================
131
123
  // 扫描 options(u8 追加:doctor「同一数据源两处渲染,不重复实现探测」§6.3——
132
- // doctor 经 options 驱动 subagent 根不扫与进程内缓存,本模块不内置 TTL/mtime
133
- // 失效逻辑,缓存语义全部由调用方注入的句柄定义)
124
+ // doctor 经 options 驱动 subagent 根不扫。原 u8 的进程内缓存注入面已删除
125
+ // (ext-simplify-04 U3):每次调用实扫,本模块不再持有任何缓存语义)
134
126
  // ============================================================
135
127
 
136
- /** 缓存条目载荷(扫描统计快照;失效判定所需元数据由缓存实现方自持,不进本契约) */
137
- export interface SessionRootCacheEntry {
138
- exists: boolean
139
- fileCount: number
140
- scanMs: number
141
- }
142
-
143
- /**
144
- * 根扫描的进程内缓存句柄(调用方注入)。get 返回 undefined = 未命中(含调用方判定
145
- * TTL 到期/目录 mtime 变化后自行淘汰)→ 本模块实扫并 set 回写;命中 → 直接以缓存值
146
- * 构造根(cached: true,files 恒空数组)。get/set 允许同步或异步实现。
147
- */
148
- export interface SessionRootCache {
149
- get(key: string): Promise<SessionRootCacheEntry | undefined> | SessionRootCacheEntry | undefined
150
- set(key: string, value: SessionRootCacheEntry): void | Promise<void>
151
- }
152
-
153
128
  /** resolveSessionRoots 扫描行为选项(全部可选,缺省 = 既有行为零变化) */
154
129
  export interface SessionRootScanOptions {
155
130
  /**
156
131
  * subagent 根扫描模式:`'scan'`(默认,现行为——递归扫出文件数)| `'stat'`
157
- *(doctor 默认形态 §6.3——只做存在性检查,fileCount/scanMs 恒 undefined,
158
- * 不产缓存条目)。subagent 根在纯 pi 下可达数千文件,doctor 可能被反复询问。
132
+ *(doctor 默认形态与 not-found 文案 §6.3——只做存在性检查,fileCount/scanMs
133
+ * 恒 undefined)。subagent 根在纯 pi 下可达数千文件,doctor 可能被反复询问。
159
134
  */
160
135
  subagents?: 'scan' | 'stat'
161
- /** 根扫描缓存句柄(见 SessionRootCache)。缺省 = 每次实扫(find 路径永不传)。 */
162
- cache?: SessionRootCache
163
136
  }
164
137
 
165
138
  /**
@@ -170,10 +143,9 @@ export interface SessionRootScanOptions {
170
143
  * 逐根复用 scanJsonlRecursive(main 源跳 workflow-state;subagent 源不跳)。单根失败
171
144
  * (不存在/无权限)→ 空结果继续,不抛错(沿用 listXxxSessions 契约)。
172
145
  *
173
- * options(u8 追加,全部缺省安全):`subagents:'stat'` 让 subagent 根只做存在性检查;
174
- * `cache` 提供进程内缓存句柄(命中即不实扫,cached 根 files 恒空)——cache 命中优先于
175
- * 一切实扫,`set` 仅在实扫后回写(命中不回写)。find/F1 路径恒不传 options(自检行
176
- * 计数必须取本次实扫,§7B 要点 8 PS-14)。
146
+ * options(u8 追加,全部缺省安全):`subagents:'stat'` 让 subagent 根只做存在性检查。
147
+ * 无缓存——每次调用实扫(原 u8 cache 注入面已删除,ext-simplify-04 U3;find/F1
148
+ * 自检行计数与 doctor 表计数同取本次实扫,§7B 要点 8)。
177
149
  */
178
150
  export async function resolveSessionRoots(
179
151
  signals: SessionRootSignals,
@@ -187,36 +159,19 @@ export async function resolveSessionRoots(
187
159
  const kept = byRealPath.get(key)
188
160
  if (kept) {
189
161
  // 同 realpath:不扫描,标注归属(exists 与保留根同目录,必然一致)
190
- out.push({ ...candidate, exists: kept.exists, dedupedInto: kept.id })
162
+ out.push({ ...candidate, exists: kept.exists, dedupedInto: kept.kind })
191
163
  continue
192
164
  }
193
165
  if (candidate.source === 'subagent' && subagentMode === 'stat') {
194
- // doctor 默认形态(§6.3):只列路径与可扫性,不产文件数、不入缓存
166
+ // doctor 默认形态 / not-found 文案(§6.3):只列路径与可扫性,不产文件数
195
167
  out.push({ ...candidate, exists: await pathExists(candidate.path) })
196
168
  continue
197
169
  }
198
- const cached = options?.cache ? await options.cache.get(candidate.path) : undefined
199
- if (cached) {
200
- // 命中:缓存值即数据源(不实扫、不回写);files 恒空——消费方不得当实扫结果
201
- const root: SessionRoot = {
202
- ...candidate,
203
- exists: cached.exists,
204
- fileCount: cached.fileCount,
205
- scanMs: cached.scanMs,
206
- cached: true,
207
- }
208
- byRealPath.set(key, root)
209
- out.push(root)
210
- continue
211
- }
212
170
  const skipDirs = candidate.source === 'main' ? SKIP_DIRS_MAIN : SKIP_DIRS_NONE
213
171
  const t0 = performance.now()
214
172
  const files = await scanJsonlRecursive(candidate.path, skipDirs)
215
173
  const scanMs = performance.now() - t0
216
174
  const exists = await pathExists(candidate.path)
217
- if (options?.cache) {
218
- await options.cache.set(candidate.path, { exists, fileCount: files.length, scanMs })
219
- }
220
175
  const root: SessionRoot = {
221
176
  ...candidate,
222
177
  exists,
@@ -295,29 +250,3 @@ async function scanJsonlRecursive(
295
250
  return results
296
251
  }
297
252
 
298
- /**
299
- * 旧签名薄包装(U1,design §7B 要点 7):内部构造只含 agentDir 的信号包,main 源退化为
300
- * 「`[default]`+`[legacy]`」两根并集。工具运行路径后续单元改走 resolveSessionRoots 新签名,
301
- * 本包装仅为存量调用(find.ts / subagents.ts)与外部深 import 保持不破。
302
- *
303
- * 递归扫描子目录(cwd 编码目录如 --Users-foo--),glob *.jsonl,排除 *.jsonl.finalized
304
- *(design §3.3 D-7 Q2);跳过 workflow-state 子目录(workflow 运行状态文件,非 session)。
305
- */
306
- export async function listMainSessions(agentDir: string): Promise<SessionFileMeta[]> {
307
- const roots = await resolveSessionRoots({ agentDir })
308
- return roots
309
- .filter((r) => r.source === 'main' && r.dedupedInto === undefined)
310
- .flatMap((r) => r.files)
311
- }
312
-
313
- /**
314
- * 旧签名薄包装:agentDir 信号包下 `[subagent]` 根 = `<agentDir>/subagents`(常量推导),
315
- * 行为与旧实现等价。结构:subagents/<cwd编码>/sessions/*.jsonl。records/ 子目录
316
- *(.json manifest)无 .jsonl,天然不被误收。
317
- */
318
- export async function listSubagentSessions(agentDir: string): Promise<SessionFileMeta[]> {
319
- const roots = await resolveSessionRoots({ agentDir })
320
- const sub = roots.find((r) => r.kind === 'subagent')
321
- return sub?.files ?? []
322
- }
323
-
@@ -0,0 +1,103 @@
1
+ import { open, type FileHandle } from 'node:fs/promises'
2
+ import { closeSync, openSync, readSync } from 'node:fs'
3
+
4
+ /**
5
+ * [ext-simplify-18 D5] session 文件首行 header 读取的包内单源(原 subagents.ts / find.ts
6
+ * 两份 async 副本 + tool-handler.ts 一份 sync 副本合一)。不进 shared——跨包第二消费方
7
+ * 不存在(runtime 侧首行读取是另一形态 + 分层禁反向 import,设计负面清单同口径)。
8
+ */
9
+
10
+ /** session header(首行 type==='session' entry 的消费子集)。 */
11
+ export interface SessionHeader {
12
+ id: string
13
+ cwd?: string
14
+ parentSession?: string
15
+ }
16
+
17
+ /**
18
+ * 首行读取 buffer 上限。header 超 8KB 的极端情况会截断致 parse 失败(parseSessionHeader
19
+ * 返回 null)——session header(id/cwd/parentSession)实测 < 300 字节,8KB 约 27 倍余量。
20
+ */
21
+ export const HEADER_READ_BYTES = 8192
22
+
23
+ /** 读文件首行(header)。定长 8KB 一次 read;空文件/读失败返回 undefined。 */
24
+ export async function readSessionHeaderFirstLine(path: string): Promise<string | undefined> {
25
+ let fh: FileHandle | undefined
26
+ try {
27
+ fh = await open(path, 'r')
28
+ const buf = Buffer.alloc(HEADER_READ_BYTES)
29
+ const { bytesRead } = await fh.read(buf, 0, HEADER_READ_BYTES, 0)
30
+ if (bytesRead === 0) return undefined
31
+ const text = buf.subarray(0, bytesRead).toString('utf8')
32
+ const nl = text.indexOf('\n')
33
+ return nl === -1 ? text : text.slice(0, nl)
34
+ } catch {
35
+ return undefined
36
+ } finally {
37
+ await fh?.close().catch(() => {})
38
+ }
39
+ }
40
+
41
+ /** 解析 header 首行为 SessionHeader。非 session 行/缺 id → null。 */
42
+ export function parseSessionHeader(line: string | undefined): SessionHeader | null {
43
+ if (!line) return null
44
+ let raw: unknown
45
+ try {
46
+ raw = JSON.parse(line)
47
+ } catch {
48
+ return null
49
+ }
50
+ if (typeof raw !== 'object' || raw === null) return null
51
+ const o = raw as Record<string, unknown>
52
+ if (o.type !== 'session' || typeof o.id !== 'string') return null
53
+ const h: SessionHeader = { id: o.id }
54
+ if (typeof o.cwd === 'string') h.cwd = o.cwd
55
+ if (typeof o.parentSession === 'string') h.parentSession = o.parentSession
56
+ return h
57
+ }
58
+
59
+ /** readSessionHeaderIdSync 读首行的 buffer 上限。session header 实测 < 300 字节,4KB 足够。 */
60
+ const SYNC_HEADER_READ_BYTES = 4096
61
+
62
+ /**
63
+ * 同步读 session 文件首行 header,返回 type==='session' 的 id。
64
+ *
65
+ * 任何异常(文件不存在/空文件/解析失败/type 不符)返回 undefined。与 async 版
66
+ * readSessionHeaderFirstLine/parseSessionHeader 同构(定长 buffer 读首行 + JSON.parse +
67
+ * type 校验),但用同步 fs API(resolveSessionId 内仅调用 1 次,同步开销可接受),
68
+ * 窗口取 4KB(仅取 id)——sync 形态与窗口差异是刻意的调用上下文适配,独立常量、
69
+ * 不与 async 版 8KB 强行统一。
70
+ */
71
+ export function readSessionHeaderIdSync(filePath: string): string | undefined {
72
+ let fd: number | undefined
73
+ try {
74
+ fd = openSync(filePath, 'r')
75
+ const buf = Buffer.alloc(SYNC_HEADER_READ_BYTES)
76
+ const bytesRead = readSync(fd, buf, 0, SYNC_HEADER_READ_BYTES, 0)
77
+ if (bytesRead === 0) return undefined
78
+ const text = buf.subarray(0, bytesRead).toString('utf8')
79
+ const nl = text.indexOf('\n')
80
+ const line = nl === -1 ? text : text.slice(0, nl)
81
+ let raw: unknown
82
+ try {
83
+ raw = JSON.parse(line)
84
+ } catch {
85
+ return undefined
86
+ }
87
+ if (typeof raw !== 'object' || raw === null) return undefined
88
+ const o = raw as Record<string, unknown>
89
+ if (o.type !== 'session' || typeof o.id !== 'string') return undefined
90
+ return o.id
91
+ } catch {
92
+ return undefined
93
+ } finally {
94
+ if (fd !== undefined) {
95
+ try {
96
+ closeSync(fd)
97
+ } catch {
98
+ // closeSync 失败:fd 可能已无效,header 数据已读取,关闭失败不影响结果(best-effort)
99
+ void fd
100
+ }
101
+ }
102
+ }
103
+ }
@@ -2,9 +2,10 @@ import { readFile, readdir, open, stat } from 'node:fs/promises'
2
2
  import type { FileHandle } from 'node:fs/promises'
3
3
  import { join, basename } from 'node:path'
4
4
  import type { Entry } from '../core/parser.js'
5
- import type { Family, SessionRef, SubagentRef } from '../core/family.js'
5
+ import type { Family, SessionRef } from '../core/family.js'
6
6
  import { buildFamilyIndex, resolveFamily } from '../core/family.js'
7
- import { listMainSessions, listSubagentSessions, resolveSessionRoots, type SessionFileMeta } from './roots.js'
7
+ import { resolveSessionRoots, type SessionFileMeta } from './roots.js'
8
+ import { parseSessionHeader, readSessionHeaderFirstLine } from './session-header.js'
8
9
  import { resolveWorkflows } from './workflows.js'
9
10
 
10
11
  /**
@@ -56,8 +57,8 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
56
57
  const index = buildFamilyIndex(scan.headers, scan.identities, scan.fileStats)
57
58
  const family = resolveFamily(sessionId, index)
58
59
 
59
- // ---- 6. 补 M1 占位字段(fileName / subagent cwd)+ workflows ----
60
- enrichRefs(family, scan.pathToRef)
60
+ // ---- 6. workflows(enrichRefs 回填已删除,ext-simplify-04 U4/E2:fileName/cwd 占位
61
+ // 零文本读者,family 路径维持 core 层占位空串;富字段的消费者 = formatFamilyText 展示)----
61
62
  family.workflows = await resolveWorkflows(sessionId, scan.sessionIdToPath, scan.pathToRef)
62
63
 
63
64
  return family
@@ -71,10 +72,12 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
71
72
  * not-found 错误文案:列出实际扫描过的 main 候选根(U1 并入部分,design §6.1 自证能力
72
73
  * ——「没有这条 session」与「根配错了」必须可区分,错误信息携带发现层内部状态)。
73
74
  * 仅失败路径调用,额外一次 resolveSessionRoots 的扫描成本可接受;根过滤条件与
74
- * collectMainSessions 实扫集合一致(listMainSessions = agentDir 信号包下未去重的 main 根)。
75
+ * collectMainSessions 实扫集合一致(agentDir 信号包下未去重的 main 根)。
75
76
  */
76
77
  async function formatSessionNotFound(sessionId: string, agentDir: string): Promise<string> {
77
- const mainRoots = (await resolveSessionRoots({ agentDir })).filter(
78
+ // subagents:'stat'——本路径只渲染 main 根行,subagent 根不做深扫(ext-simplify-04 A2:
79
+ // 原默认 'scan' 白扫整个 subagent 根后 filter 丢弃)
80
+ const mainRoots = (await resolveSessionRoots({ agentDir }, { subagents: 'stat' })).filter(
78
81
  (r) => r.source === 'main' && r.dedupedInto === undefined,
79
82
  )
80
83
  const lines = mainRoots.map((r) => {
@@ -97,9 +100,9 @@ interface FamilyFsScan {
97
100
  identities: Entry[]
98
101
  /** sessionId → { mtime, size }(buildFamilyIndex 存活判定素材) */
99
102
  fileStats: Map<string, { mtime: number; size: number }>
100
- /** sessionId → 真实文件路径,供 workflows 找目标文件 + enrich 补 fileName */
103
+ /** sessionId → 真实文件路径,供 workflows 找目标文件 */
101
104
  sessionIdToPath: Map<string, string>
102
- /** path → 完整 SessionRef(含真实 cwd/fileName/mtime/size),供 enrich + workflow calls 反查 */
105
+ /** path → 完整 SessionRef(含真实 cwd/fileName/mtime/size),供 workflow calls 反查 */
103
106
  pathToRef: Map<string, SessionRef>
104
107
  /** 已扫描到的 subagent 文件路径集合,供 manifest 孤儿判定(alive 则跳过 manifest) */
105
108
  aliveSubPaths: Set<string>
@@ -114,9 +117,13 @@ function indexManifestsBySessionFile(manifests: RecordManifest[]): Map<string, R
114
117
 
115
118
  /** 步骤 1:main sessions —— 首行 header → headers + fileStats + 路径反查 */
116
119
  async function collectMainSessions(agentDir: string, scan: FamilyFsScan): Promise<void> {
117
- const mainMetas = await listMainSessions(agentDir)
120
+ // A3(ext-simplify-04):直调 resolveSessionRoots + filter(原 listMainSessions 薄包装
121
+ // 已删除)——agentDir 信号包下 main 源未去重根([default]+[legacy],被 [live] 去重的除外)的文件并集
122
+ const mainMetas = (await resolveSessionRoots({ agentDir }))
123
+ .filter((r) => r.source === 'main' && r.dedupedInto === undefined)
124
+ .flatMap((r) => r.files)
118
125
  for (const meta of mainMetas) {
119
- const h = parseHeaderLine(await readFirstLine(meta.path))
126
+ const h = parseSessionHeader(await readSessionHeaderFirstLine(meta.path))
120
127
  if (!h) continue // 非 session/坏 header → 跳过(不入 byId)
121
128
  const entry: Entry = { type: 'session', id: h.id, parentId: null, cwd: h.cwd ?? '' }
122
129
  if (h.parentSession) entry.parentSession = h.parentSession
@@ -171,9 +178,12 @@ async function collectSubagentIdentities(
171
178
  ): Promise<void> {
172
179
  // U4 数据流:manifest 命中透全字段;未命中 P-fallback 读尾行 identity 取 task/slug/agent
173
180
  //(model/status 不可回退,留 undefined);无 manifest 无 identity(运行中/异常)跳过。
174
- const subMetas = await listSubagentSessions(agentDir)
181
+ // A3(ext-simplify-04):直调 resolveSessionRoots(原 listSubagentSessions 薄包装已删除)
182
+ //——agentDir 信号包下 [subagent] 根 = <agentDir>/subagents(常量推导,恒唯一)
183
+ const subMetas =
184
+ (await resolveSessionRoots({ agentDir })).find((r) => r.kind === 'subagent')?.files ?? []
175
185
  for (const meta of subMetas) {
176
- const h = parseHeaderLine(await readFirstLine(meta.path))
186
+ const h = parseSessionHeader(await readSessionHeaderFirstLine(meta.path))
177
187
  if (!h) continue // 非 session/坏 header → 无真实 session id,无法 id 修正,跳过
178
188
  const realId = h.id
179
189
  // header 可解析即视为 alive(MF-3):identity 在文件尾行、完成时才写入,运行中的 subagent
@@ -244,55 +254,12 @@ function appendOrphanIdentities(manifests: RecordManifest[], scan: FamilyFsScan)
244
254
 
245
255
  // ============================================================
246
256
  // 文件读取 helpers(定长 buffer,避免全文读:subagent 文件均 269KB、总量 ~923MB)
257
+ // 首行 header 读取/解析在 session-header.ts(D5 单源);本文件仅保留尾行 identity 读取。
247
258
  // ============================================================
248
259
 
249
- /** 首行读取 buffer 上限。session header(id/cwd/parentSession)实测 < 300 字节,8KB 足够。 */
250
- const HEADER_READ_BYTES = 8192
251
260
  /** 尾行 identity 读取 buffer 上限。identity 含完整 task 文本可达数 KB;实测 64KB 覆盖 3203/3430。 */
252
261
  const TAIL_READ_BYTES = 65536
253
262
 
254
- /** 读文件首行(header)。定长 8KB 一次 read;空文件/读失败返回 undefined。 */
255
- async function readFirstLine(path: string): Promise<string | undefined> {
256
- let fh: FileHandle | undefined
257
- try {
258
- fh = await open(path, 'r')
259
- const buf = Buffer.alloc(HEADER_READ_BYTES)
260
- const { bytesRead } = await fh.read(buf, 0, HEADER_READ_BYTES, 0)
261
- if (bytesRead === 0) return undefined
262
- const text = buf.subarray(0, bytesRead).toString('utf8')
263
- const nl = text.indexOf('\n')
264
- return nl === -1 ? text : text.slice(0, nl)
265
- } catch {
266
- return undefined
267
- } finally {
268
- await fh?.close().catch(() => {})
269
- }
270
- }
271
-
272
- interface SessionHeader {
273
- id: string
274
- cwd?: string
275
- parentSession?: string
276
- }
277
-
278
- /** 解析 header 首行为 SessionHeader。非 session 行/缺 id → null。 */
279
- function parseHeaderLine(line: string | undefined): SessionHeader | null {
280
- if (!line) return null
281
- let raw: unknown
282
- try {
283
- raw = JSON.parse(line)
284
- } catch {
285
- return null
286
- }
287
- if (typeof raw !== 'object' || raw === null) return null
288
- const o = raw as Record<string, unknown>
289
- if (o.type !== 'session' || typeof o.id !== 'string') return null
290
- const h: SessionHeader = { id: o.id }
291
- if (typeof o.cwd === 'string') h.cwd = o.cwd
292
- if (typeof o.parentSession === 'string') h.parentSession = o.parentSession
293
- return h
294
- }
295
-
296
263
  /** readTailIdentity 的成功返回形状(rootSessionId 必有,其余存在才带) */
297
264
  type TailIdentity = {
298
265
  rootSessionId: string
@@ -488,41 +455,3 @@ export function extractSessionIdFromFilename(name: string): string {
488
455
  const candidate = idx >= 0 ? noExt.slice(idx + 1) : noExt
489
456
  return /^[0-9a-f-]{8,}$/i.test(candidate) ? candidate : ''
490
457
  }
491
-
492
- // ============================================================
493
- // enrich:补 M1 占位字段(fileName / subagent cwd)
494
- // ============================================================
495
-
496
- /**
497
- * M1 buildFamilyIndex 设 SessionRef.fileName=''(header 推不出路径)、subagent cwd=''
498
- *(identity 无 cwd)。此处用已扫描的真实文件信息补全:alive 的 ref 补 fileName + cwd;
499
- * cleanedUp 孤儿(无文件)保持占位。
500
- */
501
- function enrichRefs(family: Family, pathToRef: Map<string, SessionRef>): void {
502
- // sessionId → 完整 ref(含真实 fileName/cwd),由 pathToRef 反建
503
- const bySid = new Map<string, SessionRef>()
504
- for (const ref of pathToRef.values()) bySid.set(ref.sessionId, ref)
505
-
506
- const enrichSessionRef = (ref: SessionRef): SessionRef => {
507
- const full = bySid.get(ref.sessionId)
508
- if (!full) return ref
509
- return {
510
- ...ref,
511
- fileName: full.fileName || ref.fileName,
512
- cwd: full.cwd || ref.cwd,
513
- }
514
- }
515
-
516
- family.root = enrichSessionRef(family.root)
517
- family.parents = family.parents.map(enrichSessionRef)
518
- family.forks = family.forks.map(enrichSessionRef)
519
- family.subagents = family.subagents.map((s) => {
520
- const full = bySid.get(s.sessionId)
521
- if (!full) return s
522
- return {
523
- ...s,
524
- fileName: full.fileName || s.fileName,
525
- cwd: full.cwd || s.cwd,
526
- } as SubagentRef
527
- })
528
- }
package/src/doctor.ts CHANGED
@@ -2,21 +2,15 @@
2
2
  * doctor action(u8:design 2026-09-10 §6.2/§6.3/§6.4/§7B 要点 2/4/5/8 + §6.11 U14b 段)。
3
3
  *
4
4
  * 从 tool-handler.ts 机械提取(max-lines 拆分轮,零行为变更):环境判定 + 根表渲染 +
5
- * doctor 扫描缓存 + 旧布局残留 glob 探测。SessionReadSignals(u3 信号包超集)随域迁移,
6
- * tool-handler re-export 保持导出面不变(index.ts / 单测白盒 import 路径不变)。
7
- * statDirMtimeOrNull / DOCTOR_CACHE_TTL_MS 供留守的 u11 metadata 缓存复用(一并导出)。
5
+ * 旧布局残留 glob 探测。SessionReadSignals(u3 信号包超集)在域内定义,tool-handler
6
+ * re-export 供 index.ts 生产消费。原 u8 的扫描缓存机已删除(ext-simplify-04 U3):
7
+ * 每次调用实扫,无缓存命中语义;TTL/mtime 判定随 metadata 缓存留在 tool-handler 侧。
8
8
  */
9
9
  import { existsSync } from 'node:fs'
10
- import { readdir, stat } from 'node:fs/promises'
10
+ import { readdir } from 'node:fs/promises'
11
11
  import { join, dirname } from 'node:path'
12
12
  import { detectEnvironment, type DetectedEnvironment } from './discovery/env.js'
13
- import {
14
- resolveSessionRoots,
15
- type SessionRoot,
16
- type SessionRootCache,
17
- type SessionRootCacheEntry,
18
- type SessionRootSignals,
19
- } from './discovery/roots.js'
13
+ import { resolveSessionRoots, type SessionRoot, type SessionRootSignals } from './discovery/roots.js'
20
14
  import type { SessionReadParams, ToolResult } from './tool-handler.js'
21
15
 
22
16
  /**
@@ -32,69 +26,9 @@ export interface SessionReadSignals extends SessionRootSignals {
32
26
  bundleUrl?: string
33
27
  }
34
28
 
35
- /**
36
- * doctor 扫描缓存(进程内,keyed by 根字面路径;Map 生命周期 = pi 进程生命周期,
37
- * 即「进程生命周期兜底上限」)。失效 = 秒级 TTL 到期 **或** 根目录 mtime 变化(任一)。
38
- *
39
- * 以 SessionRootCache 句柄注入 resolveSessionRoots(§6.3「同一数据源两处渲染」——根
40
- * 骨架/去重/扫描全在 roots.ts,doctor 只注入缓存与 subagent 扫描模式);TTL/mtime
41
- * 失效判定全在本侧实现,roots.ts 不内置。**仅 doctor 消费——find 一律不读缓存**
42
- *(§7B 要点 8):find 恒不传 options;若 find 读缓存,最坏形态是 PS-14(首条
43
- * assistant 前 main 根 0 文件被缓存,之后每次都把「根解析正常」误报成「主根为空」)
44
- * ——正是本设计要消灭的错误归因。
45
- */
46
- interface DoctorCacheEntry extends SessionRootCacheEntry {
47
- /** 写入时刻(Date.now()),TTL 判定用 */
48
- cachedAt: number
49
- /** 根目录 mtime(ms);目录不存在为 null(存在性翻转即失效) */
50
- dirMtimeMs: number | null
51
- }
52
-
53
- const doctorScanCache = new Map<string, DoctorCacheEntry>()
54
- // §7.5 豁免(development-guide.md「纯性能缓存豁免」):TTL 纯性能缓存,jiti 双路径加载
55
- // 分裂成两份仅多一次 miss 重扫,无正确性影响,不升级 globalThis 单例。
56
-
57
29
  /** doctor 根表来源标签列宽(最长 `[subagent]` 10 字符 + 1 对齐间距,§5.1 表格形态)。 */
58
30
  const DOCTOR_ROOT_LABEL_PAD = 11
59
31
 
60
- /** 缓存 TTL(秒级,§6.3)。mtime 是主失效通道;TTL 兜「子目录内增删不改变根 mtime」的陈旧面。 */
61
- export const DOCTOR_CACHE_TTL_MS = 5000
62
-
63
- /** stat 根目录 mtime;不存在返回 null(与缓存条目的 null 比对 = 存在性未翻转)。 */
64
- export async function statDirMtimeOrNull(path: string): Promise<number | null> {
65
- try {
66
- return (await stat(path)).mtimeMs
67
- } catch (err) {
68
- // 目录不存在是 doctor 的常态输入(三根降级形态),非异常——void 同 roots.ts 容错
69
- void err
70
- return null
71
- }
72
- }
73
-
74
- /** doctor 侧缓存句柄:get 做失效判定(未命中即删除条目),set 快照 mtime 与写入时刻。 */
75
- const doctorRootCache: SessionRootCache = {
76
- async get(key) {
77
- const hit = doctorScanCache.get(key)
78
- if (hit === undefined) return undefined
79
- if (Date.now() - hit.cachedAt >= DOCTOR_CACHE_TTL_MS) {
80
- doctorScanCache.delete(key)
81
- return undefined
82
- }
83
- if ((await statDirMtimeOrNull(key)) !== hit.dirMtimeMs) {
84
- doctorScanCache.delete(key)
85
- return undefined
86
- }
87
- return { exists: hit.exists, fileCount: hit.fileCount, scanMs: hit.scanMs }
88
- },
89
- async set(key, value) {
90
- doctorScanCache.set(key, {
91
- ...value,
92
- cachedAt: Date.now(),
93
- dirMtimeMs: await statDirMtimeOrNull(key),
94
- })
95
- },
96
- }
97
-
98
32
  /** 旧布局残留条目(§6.11 U14b doctor 侧独立 glob 探测)。 */
99
33
  /** 旧布局残留探测单条命中(doctor 与 u14b 启动探测同源判据,§6.11)。 */
100
34
  export interface PiLayoutLeftover {
@@ -110,6 +44,8 @@ export interface PiLayoutLeftover {
110
44
  * 形态判据(与 u14b 启动探测 / u14a 脚本 0a 同源):目录下含 `agent/` 或 `sessions/`
111
45
  * 子目录才算残留——防纯 pi 宿主下任意来源的 `~/.pi/pi/` 目录误报。读探测,每次现查
112
46
  * 不入缓存(两次 readdir 成本可忽略,缓存只服务扫盘贵的根)。
47
+ *
48
+ * 迁移期支持(A4 登记):本探测服务于 v2 布局迁移窗口,全量迁移确认后可整段删除。
113
49
  */
114
50
  async function detectPiLayoutLeftovers(
115
51
  agentDir: string,
@@ -155,9 +91,8 @@ export async function doDoctor(
155
91
  })
156
92
  const roots = await resolveSessionRoots(signals, {
157
93
  // subagent 根默认只 stat(§6.3 成本控制:只列路径与可扫性);includeSubagents:true
158
- // 才扫(走同一缓存句柄)。find/F1 路径恒不传 options——不读缓存(§7B 要点 8)。
94
+ // 才扫。无缓存——每次实扫(原缓存机已删,ext-simplify-04 U3,§7B 要点 8 新态)。
159
95
  subagents: params.includeSubagents === true ? 'scan' : 'stat',
160
- cache: doctorRootCache,
161
96
  })
162
97
  const leftovers = await detectPiLayoutLeftovers(signals.agentDir)
163
98
  return renderDoctor(roots, environment, leftovers)
@@ -206,19 +141,18 @@ function renderDoctor(
206
141
  }
207
142
  const facts = [r.exists ? '存在' : '不存在']
208
143
  if (r.fileCount === undefined) {
209
- // 仅 subagents:'stat' 的 subagent 根(doctor 默认形态)——其余根恒有计数
210
- //(实扫或缓存命中);被去重根已在上方提前返回
144
+ // 仅 subagents:'stat' 的 subagent 根(doctor 默认形态)——其余根恒有计数(实扫);
145
+ // 被去重根已在上方提前返回
211
146
  facts.push('未扫描(subagent 根默认不扫,includeSubagents:true 开启)')
212
147
  } else {
213
148
  facts.push(`${r.fileCount} 文件`, `扫 ${Math.round(r.scanMs ?? 0)}ms`)
214
149
  }
215
- if (r.cached === true) facts.push('缓存命中')
216
150
  facts.push(r.source === 'main' ? 'main' : 'subagent')
217
151
  lines.push(` ${facts.join(' · ')}`)
218
152
  })
219
153
 
220
154
  // 事实型诊断(§7B 要点 5):列表内首个 main 根即最高优先级 main 根(去重只移除后位根,
221
- // 首位根恒有计数——实扫或缓存命中)。0 文件也是事实——首条 assistant 前 jsonl 不落盘
155
+ // 首位根恒有计数——实扫)。0 文件也是事实——首条 assistant 前 jsonl 不落盘
222
156
  //(PS-14),不做归因。
223
157
  lines.push('')
224
158
  const firstMain = roots.find((r) => r.source === 'main')
package/src/extract.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  * 从 tool-handler.ts 机械提取(max-lines 拆分轮,零行为变更)。
5
5
  * design §3.3 D3 的 5 个预设 + F8/F9 规格;F7(what 校验)与 doExtract 编排
6
6
  *(resolveSessionId/safeParse/turns 范围限定依赖 tool-handler 私有定位层)留守
7
- * tool-handler,经本模块导出的 5 预设函数分发。renderExtractItems 导出面不变:
8
- * tool-handler re-export(单测 F9 白盒 import 路径不变)。纯提取,不调 LLM。
7
+ * tool-handler,经本模块导出的 5 预设函数分发。纯提取,不调 LLM。
9
8
  */
9
+ import { textBlockParts } from './core/render.js'
10
10
  import { extractToolCalls, formatToolCallSummary, basename } from './core/toolcall.js'
11
11
  import type { Turn } from './core/turns.js'
12
12
  import { pad } from './handler-utils.js'
@@ -29,25 +29,12 @@ const SHORT_HASH_RE = /\b[0-9a-f]{7,8}\b/g
29
29
  const COMMIT_CTX_RE = /feat:|fix:|refactor:|chore:|docs:|\b(commit|commits|merged|pushed|merge)\b/i
30
30
 
31
31
  /**
32
- * 从 message.content 提取纯 text(string 直取;数组拼接 text 块)。
33
- *
34
- * 与 render.ts 内部 extractText 同语义,但那未导出;extract 仅需纯 text
35
- *(user-messages / tool-results 的正文),不要 thinking/toolCall 占位,本地实现。
36
- * content 是 unknown 做类型守卫。
32
+ * 从 message.content 提取纯 text(E11 归一:共享核 textBlockParts 组合调用,'\n' join
33
+ * 语义留在本调用点)。extract 仅需纯 text(user-messages / tool-results 的正文),
34
+ * 不要 thinking/toolCall 占位。
37
35
  */
38
36
  function extractContentText(content: unknown): string {
39
- if (typeof content === 'string') return content
40
- if (Array.isArray(content)) {
41
- const parts: string[] = []
42
- for (const b of content) {
43
- if (b !== null && typeof b === 'object') {
44
- const o = b as Record<string, unknown>
45
- if (o.type === 'text' && typeof o.text === 'string') parts.push(o.text)
46
- }
47
- }
48
- return parts.join('\n')
49
- }
50
- return ''
37
+ return textBlockParts(content).join('\n')
51
38
  }
52
39
 
53
40
  /** 截断到 max 字符,超出加省略号(防爆;tool-results 正文用)。 */