@zhushanwen/pi-session-reader 0.5.1 → 0.6.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.
@@ -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) 实装语义是「只扫一层平铺目录」——平铺根意味着
@@ -428,7 +327,7 @@ function isFlatRoot(root: SessionRoot): boolean {
428
327
  * 加载标题元数据索引(u11)。仅 keyword 路径调用(惰性触发:uuid 精确/归一化两级零命中
429
328
  * 且 query 非纯 hex 才到达此处,§6.6 调用策略 ①)。
430
329
  *
431
- * 窄化目标(策略 ②,按序):未剥层 liveSessionDir 本身(xyz-agent 下即平铺主根;纯 pi 下
330
+ * 窄化目标(策略 ②,按序):未剥层 liveSessionDir 本身(taiji 下即平铺主根;纯 pi 下
432
331
  * 即当前 cwd 目录,小)+ 实扫结果无子目录的候选根。两条 guard:
433
332
  * - 仅对存在的根/目录调用(root.exists / stat liveSessionDir),且永传非空串——pi 的
434
333
  * listAll 对空串 falsy 走默认全盘分支(数千项 / 秒级,hash-provider.ts 空参灾难先例);
@@ -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 标题)
@@ -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
  // ============================================================
@@ -24,7 +24,7 @@ import { performance } from 'node:perf_hooks'
24
24
 
25
25
  /** 发现层信号包(index.ts 采集,全部可降级;本模块只消费不采集) */
26
26
  export interface SessionRootSignals {
27
- /** pi agent 数据目录(两宿主均成立;B 后 xyz-agent = `<dataDir>/agent`) */
27
+ /** pi agent 数据目录(两宿主均成立;B 后 taiji = `<dataDir>/agent`) */
28
28
  agentDir: string
29
29
  /**
30
30
  * 宿主已解析的 live session 目录(`ctx.sessionManager.getSessionDir()`,§6.1 信号 1)。
@@ -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) */
@@ -74,7 +66,7 @@ const SUBAGENTS_DIRNAME = 'subagents'
74
66
 
75
67
  /**
76
68
  * `[live]` 规范化(§6.1/§7B 要点 3):`getSessionDir()` 在纯 pi 返回按 cwd 编码的子目录
77
- *(`<agentDir>/sessions/--Users-x--`),在 xyz-agent 覆盖态返回根本身。判据:basename
69
+ *(`<agentDir>/sessions/--Users-x--`),在 taiji 覆盖态返回根本身。判据:basename
78
70
  * 匹配 encodeCwd 形态(`--` 开头 `--` 结尾,实证见 `~/.pi/agent/sessions/` 全部子目录
79
71
  * 与 real-data.ts)则取 dirname,否则取自身。
80
72
  */
@@ -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
+ }