@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.
@@ -4,7 +4,7 @@ import { join, basename } from 'node:path'
4
4
  import type { Entry } from '../core/parser.js'
5
5
  import type { Family, SessionRef, SubagentRef } from '../core/family.js'
6
6
  import { buildFamilyIndex, resolveFamily } from '../core/family.js'
7
- import { listMainSessions, listSubagentSessions } from './roots.js'
7
+ import { listMainSessions, listSubagentSessions, type SessionFileMeta } from './roots.js'
8
8
  import { resolveWorkflows } from './workflows.js'
9
9
 
10
10
  /**
@@ -23,7 +23,8 @@ import { resolveWorkflows } from './workflows.js'
23
23
  * 是 records/<sa-id>.json manifest(subagent 创建时写入,持久存在)。故 manifest 是孤儿/
24
24
  * cleanedUp 的来源(design §3.3 D-7 "records/*.json manifest 作孤儿补充")。
25
25
  * - workflows:M1 resolveFamily 恒返回 []。M2 在此单独读目标 session 的 workflow-state-link
26
- * custom entry → link.data.path(wf-state 文件绝对路径)→ 读该文件最后一行(最新快照)取 calls。
26
+ * custom entry → link.data.path(wf-state 文件绝对路径)→ 从文件尾向头找首个非空且
27
+ * JSON.parse 成功的行(最新快照,容错尾半截 JSON)取 calls。
27
28
  *
28
29
  * @throws sessionId 不在任意 main session header → Error(M3 tool-adapter 层转 F1 恢复指引)
29
30
  */
@@ -32,26 +33,77 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
32
33
  // manifest 主路径:alive 文件的 meta.path 命中索引 → 透 task/slug/model/status/sessionFile 全字段。
33
34
  // 索引未命中(场景 A:嵌套 subagent 的 manifest 不在当前 agentDir,11.5%)走 P-fallback 回退 identity。
34
35
  const manifests = await listRecordManifests(agentDir)
35
- const manifestBySessionFile = new Map<string, RecordManifest>()
36
- for (const m of manifests) manifestBySessionFile.set(m.sessionFile, m)
36
+ const manifestBySessionFile = indexManifestsBySessionFile(manifests)
37
37
 
38
- // ---- 1. main sessions:首行 header → byId/childrenOf 素材 + fileStats + 路径反查 ----
39
- const mainMetas = await listMainSessions(agentDir)
40
- const headers: Entry[] = []
41
- const fileStats = new Map<string, { mtime: number; size: number }>()
38
+ // ---- 1-3. 逐阶段扫描(main → subagent → 孤儿 manifest),累积器显式传递 ----
39
+ const scan: FamilyFsScan = {
40
+ headers: [],
41
+ identities: [],
42
+ fileStats: new Map(),
43
+ sessionIdToPath: new Map(),
44
+ pathToRef: new Map(),
45
+ aliveSubPaths: new Set(),
46
+ }
47
+ await collectMainSessions(agentDir, scan)
48
+ await collectSubagentIdentities(agentDir, manifestBySessionFile, scan)
49
+ appendOrphanIdentities(manifests, scan)
50
+
51
+ // ---- 4-5. build index + resolve(sessionId 不在 byId → resolveFamily 抛 Error)----
52
+ if (!scan.sessionIdToPath.has(sessionId)) {
53
+ throw new Error(
54
+ `session "${sessionId}" not found under ${agentDir}/sessions — ` +
55
+ `no main session file whose first-line header id matches. ` +
56
+ `Verify the sessionId or agentDir; for partial uuid, use findSessions first.`,
57
+ )
58
+ }
59
+ const index = buildFamilyIndex(scan.headers, scan.identities, scan.fileStats)
60
+ const family = resolveFamily(sessionId, index)
61
+
62
+ // ---- 6. 补 M1 占位字段(fileName / subagent cwd)+ workflows ----
63
+ enrichRefs(family, scan.pathToRef)
64
+ family.workflows = await resolveWorkflows(sessionId, scan.sessionIdToPath, scan.pathToRef)
65
+
66
+ return family
67
+ }
68
+
69
+ // ============================================================
70
+ // buildFamilyFromFs 扫描阶段 helpers(各阶段显式传入累积器,插入顺序 = 原实现顺序)
71
+ // ============================================================
72
+
73
+ /** buildFamilyFromFs 各扫描阶段的共享累积器(主函数创建,各阶段 helper 显式传入并写入) */
74
+ interface FamilyFsScan {
75
+ /** main session headers(buildFamilyIndex byId/childrenOf 素材) */
76
+ headers: Entry[]
77
+ /** subagent identity entries(步骤 2 富化 + 步骤 3 孤儿) */
78
+ identities: Entry[]
79
+ /** sessionId → { mtime, size }(buildFamilyIndex 存活判定素材) */
80
+ fileStats: Map<string, { mtime: number; size: number }>
42
81
  /** sessionId → 真实文件路径,供 workflows 找目标文件 + enrich 补 fileName */
43
- const sessionIdToPath = new Map<string, string>()
82
+ sessionIdToPath: Map<string, string>
44
83
  /** path → 完整 SessionRef(含真实 cwd/fileName/mtime/size),供 enrich + workflow calls 反查 */
45
- const pathToRef = new Map<string, SessionRef>()
84
+ pathToRef: Map<string, SessionRef>
85
+ /** 已扫描到的 subagent 文件路径集合,供 manifest 孤儿判定(alive 则跳过 manifest) */
86
+ aliveSubPaths: Set<string>
87
+ }
88
+
89
+ /** manifest 按 sessionFile 建索引(alive 文件的 meta.path 命中 → 走 manifest 主路径) */
90
+ function indexManifestsBySessionFile(manifests: RecordManifest[]): Map<string, RecordManifest> {
91
+ const manifestBySessionFile = new Map<string, RecordManifest>()
92
+ for (const m of manifests) manifestBySessionFile.set(m.sessionFile, m)
93
+ return manifestBySessionFile
94
+ }
46
95
 
96
+ /** 步骤 1:main sessions —— 首行 header → headers + fileStats + 路径反查 */
97
+ async function collectMainSessions(agentDir: string, scan: FamilyFsScan): Promise<void> {
98
+ const mainMetas = await listMainSessions(agentDir)
47
99
  for (const meta of mainMetas) {
48
100
  const h = parseHeaderLine(await readFirstLine(meta.path))
49
101
  if (!h) continue // 非 session/坏 header → 跳过(不入 byId)
50
102
  const entry: Entry = { type: 'session', id: h.id, parentId: null, cwd: h.cwd ?? '' }
51
103
  if (h.parentSession) entry.parentSession = h.parentSession
52
- headers.push(entry)
53
- fileStats.set(h.id, { mtime: meta.mtime, size: meta.size })
54
- sessionIdToPath.set(h.id, meta.path)
104
+ scan.headers.push(entry)
105
+ scan.fileStats.set(h.id, { mtime: meta.mtime, size: meta.size })
106
+ scan.sessionIdToPath.set(h.id, meta.path)
55
107
  const ref: SessionRef = {
56
108
  sessionId: h.id,
57
109
  fileName: meta.path,
@@ -60,17 +112,47 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
60
112
  cwd: h.cwd ?? '',
61
113
  }
62
114
  if (h.parentSession) ref.parentSession = h.parentSession
63
- pathToRef.set(meta.path, ref)
115
+ scan.pathToRef.set(meta.path, ref)
64
116
  }
117
+ }
65
118
 
66
- // ---- 2. subagent sessions:首行 header(真实 id)+ manifest 主/identity 回退 → 富字段 identity ----
119
+ /** identity data 组装(manifest 主,TC-u4-manifest-enrich):透 task/slug/model/status/sessionFile 全字段 */
120
+ function manifestIdentityData(manifest: RecordManifest): Record<string, unknown> {
121
+ return {
122
+ rootSessionId: manifest.rootSessionId,
123
+ slug: manifest.slug ?? '', // slug 兼容旧 manifest(缺→空串兜底,m0 契约)
124
+ task: manifest.task,
125
+ agent: manifest.agentName, // 同语义异名:manifest.agentName ↔ identity.data.agent
126
+ model: manifest.model,
127
+ status: manifest.status,
128
+ sessionFile: manifest.sessionFile,
129
+ }
130
+ }
131
+
132
+ /** identity data 组装(P-fallback,ES-p-fallback-no-manifest):identity 回退取 task/slug/agent */
133
+ function fallbackIdentityData(
134
+ meta: SessionFileMeta,
135
+ ident: TailIdentity,
136
+ ): Record<string, unknown> {
137
+ return {
138
+ rootSessionId: ident.rootSessionId,
139
+ slug: ident.slug,
140
+ task: ident.task,
141
+ agent: ident.agent,
142
+ // model/status 不可回退(identity 无,探针 15/15),留 undefined
143
+ sessionFile: meta.path,
144
+ }
145
+ }
146
+
147
+ /** 步骤 2:subagent sessions —— 首行 header(真实 id)+ manifest 主/identity 回退 → 富字段 identity */
148
+ async function collectSubagentIdentities(
149
+ agentDir: string,
150
+ manifestBySessionFile: Map<string, RecordManifest>,
151
+ scan: FamilyFsScan,
152
+ ): Promise<void> {
67
153
  // U4 数据流:manifest 命中透全字段;未命中 P-fallback 读尾行 identity 取 task/slug/agent
68
154
  //(model/status 不可回退,留 undefined);无 manifest 无 identity(运行中/异常)跳过。
69
155
  const subMetas = await listSubagentSessions(agentDir)
70
- const identities: Entry[] = []
71
- /** 已扫描到的 subagent 文件路径集合,供 manifest 孤儿判定(alive 则跳过 manifest) */
72
- const aliveSubPaths = new Set<string>()
73
-
74
156
  for (const meta of subMetas) {
75
157
  const h = parseHeaderLine(await readFirstLine(meta.path))
76
158
  if (!h) continue // 非 session/坏 header → 无真实 session id,无法 id 修正,跳过
@@ -78,46 +160,30 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
78
160
  // header 可解析即视为 alive(MF-3):identity 在文件尾行、完成时才写入,运行中的 subagent
79
161
  // 无 identity。若此处跳过,步骤 3 会把活文件(含其 manifest)当孤儿收编 → cleanedUp=true,
80
162
  // family 把活着的 subagent 显示成 [已清理],真实 sessionId 永远无法关联。
81
- aliveSubPaths.add(meta.path)
163
+ scan.aliveSubPaths.add(meta.path)
82
164
 
83
165
  // identity data 组装:manifest 主路径或 P-fallback identity 回退,二选一
84
166
  const manifest = manifestBySessionFile.get(meta.path)
85
167
  let data: Record<string, unknown>
86
168
  if (manifest) {
87
- // manifest 主(TC-u4-manifest-enrich):透 task/slug/model/status/sessionFile 全字段
88
- data = {
89
- rootSessionId: manifest.rootSessionId,
90
- slug: manifest.slug ?? '', // slug 兼容旧 manifest(缺→空串兑底,m0 契约)
91
- task: manifest.task,
92
- agent: manifest.agentName, // 同语义异名:manifest.agentName ↔ identity.data.agent
93
- model: manifest.model,
94
- status: manifest.status,
95
- sessionFile: manifest.sessionFile,
96
- }
169
+ data = manifestIdentityData(manifest)
97
170
  } else {
98
171
  // P-fallback(ES-p-fallback-no-manifest):读尾行 identity 回退取 task/slug/agent
99
172
  const ident = await readTailIdentity(meta.path, meta.size)
100
173
  if (!ident) continue // 无 identity(ES-p-fallback-no-identity,运行中/异常)→ 跳过
101
- data = {
102
- rootSessionId: ident.rootSessionId,
103
- slug: ident.slug,
104
- task: ident.task,
105
- agent: ident.agent,
106
- // model/status 不可回退(identity 无,探针 15/15),留 undefined
107
- sessionFile: meta.path,
108
- }
174
+ data = fallbackIdentityData(meta, ident)
109
175
  }
110
176
  // id 修正:entry.id 用真实 header.id 替换 sa-xxx 占位
111
- identities.push({
177
+ scan.identities.push({
112
178
  type: 'custom',
113
179
  id: realId,
114
180
  parentId: null,
115
181
  customType: 'subagent-identity',
116
182
  data,
117
183
  })
118
- fileStats.set(realId, { mtime: meta.mtime, size: meta.size })
119
- sessionIdToPath.set(realId, meta.path)
120
- pathToRef.set(meta.path, {
184
+ scan.fileStats.set(realId, { mtime: meta.mtime, size: meta.size })
185
+ scan.sessionIdToPath.set(realId, meta.path)
186
+ scan.pathToRef.set(meta.path, {
121
187
  sessionId: realId,
122
188
  fileName: meta.path,
123
189
  mtime: meta.mtime,
@@ -125,15 +191,19 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
125
191
  cwd: h.cwd ?? '',
126
192
  })
127
193
  }
194
+ }
128
195
 
129
- // ---- 3. records manifest → 孤儿(cleanedUp,ES-orphan-manifest)----
130
- // manifest 在 subagent 创建时写入,.jsonl 被 GC 后仍残留。alive 的(sessionFile 已在步骤 2
131
- // 扫到)跳过;未扫到的 = 文件已 GC → 孤儿。用 manifest 完整富字段填 SubagentRef,
132
- // sessionFile 保留 manifest 的 GC 路径(不置空,供 LLM 知晓原位置),cleanedUp 由
133
- // buildFamilyIndex 的 !fileStats.has(ident.id) 判 true(ident.id=manifest.id 不在 fileStats)。
196
+ /**
197
+ * 步骤 3:records manifest → 孤儿(cleanedUp,ES-orphan-manifest)。
198
+ * manifest 在 subagent 创建时写入,.jsonl 被 GC 后仍残留。alive 的(sessionFile 已在步骤 2
199
+ * 扫到)跳过;未扫到的 = 文件已 GC → 孤儿。用 manifest 完整富字段填 SubagentRef,
200
+ * sessionFile 保留 manifest 的 GC 路径(不置空,供 LLM 知晓原位置),cleanedUp 由
201
+ * buildFamilyIndex 的 !fileStats.has(ident.id) 判 true(ident.id=manifest.id 不在 fileStats)。
202
+ */
203
+ function appendOrphanIdentities(manifests: RecordManifest[], scan: FamilyFsScan): void {
134
204
  for (const m of manifests) {
135
- if (aliveSubPaths.has(m.sessionFile)) continue
136
- identities.push({
205
+ if (scan.aliveSubPaths.has(m.sessionFile)) continue
206
+ scan.identities.push({
137
207
  type: 'custom',
138
208
  id: m.id,
139
209
  parentId: null,
@@ -150,23 +220,6 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
150
220
  },
151
221
  })
152
222
  }
153
-
154
- // ---- 4-5. build index + resolve(sessionId 不在 byId → resolveFamily 抛 Error)----
155
- if (!sessionIdToPath.has(sessionId)) {
156
- throw new Error(
157
- `session "${sessionId}" not found under ${agentDir}/sessions — ` +
158
- `no main session file whose first-line header id matches. ` +
159
- `Verify the sessionId or agentDir; for partial uuid, use findSessions first.`,
160
- )
161
- }
162
- const index = buildFamilyIndex(headers, identities, fileStats)
163
- const family = resolveFamily(sessionId, index)
164
-
165
- // ---- 6. 补 M1 占位字段(fileName / subagent cwd)+ workflows ----
166
- enrichRefs(family, sessionIdToPath, pathToRef)
167
- family.workflows = await resolveWorkflows(sessionId, sessionIdToPath, pathToRef)
168
-
169
- return family
170
223
  }
171
224
 
172
225
  // ============================================================
@@ -220,6 +273,15 @@ function parseHeaderLine(line: string | undefined): SessionHeader | null {
220
273
  return h
221
274
  }
222
275
 
276
+ /** readTailIdentity 的成功返回形状(rootSessionId 必有,其余存在才带) */
277
+ type TailIdentity = {
278
+ rootSessionId: string
279
+ slug: string
280
+ task?: string
281
+ agent?: string
282
+ parentRecordId?: string
283
+ }
284
+
223
285
  /**
224
286
  * 读 subagent 文件尾部(最后 64KB)找 subagent-identity entry,返回 rootSessionId + slug + task + agent。
225
287
  *
@@ -237,54 +299,36 @@ function parseHeaderLine(line: string | undefined): SessionHeader | null {
237
299
  export async function readTailIdentity(
238
300
  path: string,
239
301
  size?: number,
240
- ): Promise<
241
- | { rootSessionId: string; slug: string; task?: string; agent?: string; parentRecordId?: string }
242
- | undefined
243
- > {
244
- // size 未传时内部 stat 获取(execution-tree.ts 复用时无 size)
245
- let resolvedSize = size
246
- if (resolvedSize === undefined) {
247
- try {
248
- resolvedSize = (await stat(path)).size
249
- } catch {
250
- return undefined // 文件不存在/读失败 → undefined
251
- }
252
- }
302
+ ): Promise<TailIdentity | undefined> {
303
+ const resolvedSize = await resolveTailSize(path, size)
304
+ if (resolvedSize === undefined) return undefined // 文件不存在/读失败 → undefined
253
305
  if (resolvedSize === 0) return undefined
306
+ const text = await readTailText(path, resolvedSize)
307
+ if (text === undefined) return undefined
308
+ const line = extractTailIdentityLine(text, resolvedSize)
309
+ if (line === undefined) return undefined
310
+ return parseTailIdentityLine(line)
311
+ }
312
+
313
+ /** 尾读 size 解析:size 未传时内部 stat 获取(execution-tree.ts 复用时无 size);stat 失败 → undefined */
314
+ async function resolveTailSize(path: string, size?: number): Promise<number | undefined> {
315
+ if (size !== undefined) return size
316
+ try {
317
+ return (await stat(path)).size
318
+ } catch {
319
+ return undefined // 文件不存在/读失败 → undefined
320
+ }
321
+ }
322
+
323
+ /** 读文件尾部 min(64KB, size) 字节为文本;打开/读取失败 → undefined */
324
+ async function readTailText(path: string, resolvedSize: number): Promise<string | undefined> {
254
325
  let fh: FileHandle | undefined
255
326
  try {
256
327
  fh = await open(path, 'r')
257
328
  const len = Math.min(TAIL_READ_BYTES, resolvedSize)
258
329
  const buf = Buffer.alloc(len)
259
330
  await fh.read(buf, 0, len, Math.max(0, resolvedSize - len))
260
- const text = buf.toString('utf8')
261
- const idx = text.lastIndexOf('subagent-identity')
262
- if (idx < 0) return undefined
263
- // 行首若在读窗口外(identity 行 > 64KB,整行塞不下)→ 无法可靠解析,跳过
264
- const lineStartSearch = text.lastIndexOf('\n', idx)
265
- if (lineStartSearch < 0 && resolvedSize > len) return undefined
266
- const start = lineStartSearch < 0 ? 0 : lineStartSearch + 1
267
- let end = text.indexOf('\n', idx)
268
- if (end < 0) end = text.length
269
- const line = text.slice(start, end)
270
- let raw: unknown
271
- try {
272
- raw = JSON.parse(line)
273
- } catch {
274
- return undefined
275
- }
276
- const data = (raw as Record<string, unknown> | undefined)?.data as
277
- | Record<string, unknown>
278
- | undefined
279
- if (!data || typeof data.rootSessionId !== 'string') return undefined
280
- return {
281
- rootSessionId: data.rootSessionId,
282
- slug: typeof data.slug === 'string' ? data.slug : '',
283
- task: typeof data.task === 'string' ? data.task : undefined,
284
- agent: typeof data.agent === 'string' ? data.agent : undefined,
285
- parentRecordId:
286
- typeof data.parentRecordId === 'string' ? data.parentRecordId : undefined,
287
- }
331
+ return buf.toString('utf8')
288
332
  } catch {
289
333
  return undefined
290
334
  } finally {
@@ -292,6 +336,45 @@ export async function readTailIdentity(
292
336
  }
293
337
  }
294
338
 
339
+ /**
340
+ * 从尾窗文本定位最后一个 subagent-identity 标记所在行(多次重写时取最新)。
341
+ * 无标记 / 行首在读窗口外(identity 行 > 64KB,整行塞不下)→ undefined。
342
+ */
343
+ function extractTailIdentityLine(text: string, resolvedSize: number): string | undefined {
344
+ const idx = text.lastIndexOf('subagent-identity')
345
+ if (idx < 0) return undefined
346
+ const len = Math.min(TAIL_READ_BYTES, resolvedSize)
347
+ // 行首若在读窗口外(identity 行 > 64KB,整行塞不下)→ 无法可靠解析,跳过
348
+ const lineStartSearch = text.lastIndexOf('\n', idx)
349
+ if (lineStartSearch < 0 && resolvedSize > len) return undefined
350
+ const start = lineStartSearch < 0 ? 0 : lineStartSearch + 1
351
+ let end = text.indexOf('\n', idx)
352
+ if (end < 0) end = text.length
353
+ return text.slice(start, end)
354
+ }
355
+
356
+ /** identity 行 → 富字段结果;JSON 损坏 / 缺 rootSessionId → undefined */
357
+ function parseTailIdentityLine(line: string): TailIdentity | undefined {
358
+ let raw: unknown
359
+ try {
360
+ raw = JSON.parse(line)
361
+ } catch {
362
+ return undefined
363
+ }
364
+ const data = (raw as Record<string, unknown> | undefined)?.data as
365
+ | Record<string, unknown>
366
+ | undefined
367
+ if (!data || typeof data.rootSessionId !== 'string') return undefined
368
+ return {
369
+ rootSessionId: data.rootSessionId,
370
+ slug: typeof data.slug === 'string' ? data.slug : '',
371
+ task: typeof data.task === 'string' ? data.task : undefined,
372
+ agent: typeof data.agent === 'string' ? data.agent : undefined,
373
+ parentRecordId:
374
+ typeof data.parentRecordId === 'string' ? data.parentRecordId : undefined,
375
+ }
376
+ }
377
+
295
378
  // ============================================================
296
379
  // records manifest(孤儿 / cleanedUp 来源)
297
380
  // ============================================================
@@ -330,11 +413,6 @@ function isRecordManifest(v: unknown): v is RecordManifest {
330
413
  )
331
414
  }
332
415
 
333
- /**
334
- * 扫描 subagents/<cwdSlug>/records/*.json —— subagent 注册清单。
335
- * 每个 manifest 在 subagent 创建时写入,持久存在即使 .jsonl 被 GC。坏 manifest(缺必填字段
336
- * /JSON 损坏)跳过,不中断扫描。
337
- */
338
416
  /** 读单个 manifest 文件并校验;坏 manifest(JSON 损坏/缺必填字段)返回 undefined。 */
339
417
  async function tryReadManifest(path: string): Promise<RecordManifest | undefined> {
340
418
  try {
@@ -345,6 +423,11 @@ async function tryReadManifest(path: string): Promise<RecordManifest | undefined
345
423
  }
346
424
  }
347
425
 
426
+ /**
427
+ * 扫描 subagents/<cwdSlug>/records/*.json —— subagent 注册清单。
428
+ * 每个 manifest 在 subagent 创建时写入,持久存在即使 .jsonl 被 GC。坏 manifest(缺必填字段
429
+ * /JSON 损坏)跳过,不中断扫描。
430
+ */
348
431
  export async function listRecordManifests(agentDir: string): Promise<RecordManifest[]> {
349
432
  const root = join(agentDir, 'subagents')
350
433
  const out: RecordManifest[] = []
@@ -395,11 +478,7 @@ export function extractSessionIdFromFilename(name: string): string {
395
478
  *(identity 无 cwd)。此处用已扫描的真实文件信息补全:alive 的 ref 补 fileName + cwd;
396
479
  * cleanedUp 孤儿(无文件)保持占位。
397
480
  */
398
- function enrichRefs(
399
- family: Family,
400
- sessionIdToPath: Map<string, string>,
401
- pathToRef: Map<string, SessionRef>,
402
- ): void {
481
+ function enrichRefs(family: Family, pathToRef: Map<string, SessionRef>): void {
403
482
  // sessionId → 完整 ref(含真实 fileName/cwd),由 pathToRef 反建
404
483
  const bySid = new Map<string, SessionRef>()
405
484
  for (const ref of pathToRef.values()) bySid.set(ref.sessionId, ref)
@@ -426,6 +505,4 @@ function enrichRefs(
426
505
  cwd: full.cwd || s.cwd,
427
506
  } as SubagentRef
428
507
  })
429
- // sessionIdToPath 仅用于类型完整性占位引用,避免未用警告(实际路径信息已在 pathToRef)
430
- void sessionIdToPath
431
508
  }
@@ -34,42 +34,57 @@ export function extractCallSessionFiles(snap: unknown): string[] {
34
34
  const out: string[] = []
35
35
  if (typeof snap !== 'object' || snap === null) return out
36
36
  const s = snap as Record<string, unknown>
37
- const isNew = s.v === 'wf-run-v1' || s.v === 'wf-run-v2'
38
- let callsRaw: unknown
39
- if (isNew) {
40
- const state = s.state
41
- callsRaw =
42
- typeof state === 'object' && state !== null
43
- ? (state as Record<string, unknown>).calls
44
- : undefined
45
- } else {
46
- callsRaw = s.callCache
47
- }
37
+ const isNew = isNewSnapshotFormat(s)
38
+ const callsRaw = extractCallsRaw(s, isNew)
48
39
  if (!Array.isArray(callsRaw)) return out
49
40
  for (const c of callsRaw) {
50
41
  if (typeof c !== 'object' || c === null) continue
51
- const co = c as Record<string, unknown>
52
- // NEW: call 本身;OLD: {key, value},取 value
53
- const item: Record<string, unknown> = isNew
54
- ? co
55
- : typeof co.value === 'object' && co.value !== null
56
- ? (co.value as Record<string, unknown>)
57
- : co
58
- const sf = item.sessionFile
59
- if (typeof sf === 'string') {
60
- out.push(sf)
61
- continue
62
- }
63
- const result = item.result
64
- const sf2 =
65
- typeof result === 'object' && result !== null
66
- ? (result as Record<string, unknown>).sessionFile
67
- : undefined
68
- if (typeof sf2 === 'string') out.push(sf2)
42
+ const item = resolveCallItem(c as Record<string, unknown>, isNew)
43
+ const sf = extractSessionFileFromItem(item)
44
+ if (sf !== undefined) out.push(sf)
69
45
  }
70
46
  return out
71
47
  }
72
48
 
49
+ /** 快照是否为 NEW 格式(v="wf-run-v1"/"wf-run-v2",读取面形状一致) */
50
+ function isNewSnapshotFormat(s: Record<string, unknown>): boolean {
51
+ return s.v === 'wf-run-v1' || s.v === 'wf-run-v2'
52
+ }
53
+
54
+ /** 按格式取 calls 原始集合:NEW 读 state.calls(state 守卫后取),OLD 读顶层 callCache */
55
+ function extractCallsRaw(s: Record<string, unknown>, isNew: boolean): unknown {
56
+ if (isNew) {
57
+ const state = s.state
58
+ return typeof state === 'object' && state !== null
59
+ ? (state as Record<string, unknown>).calls
60
+ : undefined
61
+ }
62
+ return s.callCache
63
+ }
64
+
65
+ /**
66
+ * 单条 call 归一为可取 sessionFile 的对象:
67
+ * NEW: call 本身;OLD: {key, value},取 value(value 非对象时回退 call 本身,容错脏数据)
68
+ */
69
+ function resolveCallItem(co: Record<string, unknown>, isNew: boolean): Record<string, unknown> {
70
+ if (isNew) return co
71
+ const value = co.value
72
+ return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : co
73
+ }
74
+
75
+ /** 从归一后的 call 对象提 sessionFile:顶层优先,回退 result.sessionFile;均缺 → undefined */
76
+ function extractSessionFileFromItem(item: Record<string, unknown>): string | undefined {
77
+ const sf = item.sessionFile
78
+ if (typeof sf === 'string') return sf
79
+ const result = item.result
80
+ const sf2 =
81
+ typeof result === 'object' && result !== null
82
+ ? (result as Record<string, unknown>).sessionFile
83
+ : undefined
84
+ if (typeof sf2 === 'string') return sf2
85
+ return undefined
86
+ }
87
+
73
88
  /**
74
89
  * 读 wf-state 文件,从尾向头找首个 trim 非空且 JSON.parse 成功的行,返回解析后的原始对象。
75
90
  *
package/src/index.ts CHANGED
@@ -15,8 +15,6 @@ import { createSessionCommand } from './tui/session-command.js'
15
15
  * (错误直接 throw 给 pi——pi-agent-core agent-loop 只对 execute throw 置
16
16
  * isError:true,返回值里的 isError 字段被丢弃;W4 修复,锚点
17
17
  * agent-loop.js:453-483/525-547,pi 自带 bash 工具同范式)
18
- *
19
- * M4 将在此 addAutocompleteProvider(TUI # 补全,ctx.mode === 'tui' 时)。
20
18
  */
21
19
 
22
20
  // ---- TypeBox 参数 schema(design §3.4 14 字段)----
@@ -32,7 +30,7 @@ const SessionReadSchema = Type.Object({
32
30
  session: Type.Optional(
33
31
  Type.String({
34
32
  description:
35
- 'Session id, uuid fragment (e.g. e6c96), subagent record id (sa-xxx, precise lookup), or absolute .jsonl path (~ or ~/ allowed). Required for family/outline/expand/detail/search/export/workflow. # prefix auto-stripped.',
33
+ 'Session id, uuid fragment (e.g. e6c96), subagent record id (sa-xxx, precise lookup), or absolute .jsonl path (~ or ~/ allowed). Required for family/outline/expand/detail/search/export/extract/workflow. # prefix auto-stripped.',
36
34
  }),
37
35
  ),
38
36
  query: Type.Optional(
@@ -43,7 +41,7 @@ const SessionReadSchema = Type.Object({
43
41
  ),
44
42
  turns: Type.Optional(
45
43
  Type.String({
46
- description: 'detail action: turn range, "T013-T015" or "T013".',
44
+ description: 'detail/extract action: turn range, "T013-T015" or "T013".',
47
45
  }),
48
46
  ),
49
47
  turn: Type.Optional(
@@ -74,7 +72,8 @@ const SessionReadSchema = Type.Object({
74
72
  ),
75
73
  allBranches: Type.Optional(
76
74
  Type.Boolean({
77
- description: 'outline/family: include abandoned side-branches. Default false.',
75
+ description:
76
+ "outline (and export's outline section): include abandoned side-branches. Not supported by family. Default false.",
78
77
  }),
79
78
  ),
80
79
  granularity: Type.Optional(
@@ -87,7 +86,8 @@ const SessionReadSchema = Type.Object({
87
86
  ),
88
87
  source: Type.Optional(
89
88
  StringEnum(['main', 'subagent'], {
90
- description: 'find action: filter by source. "main" = sessions/, "subagent" = subagents/. Default both (merged).',
89
+ description:
90
+ 'find and session-resolving actions: filter by source. "main" = sessions/, "subagent" = subagents/. Default both (merged).',
91
91
  }),
92
92
  ),
93
93
  limit: Type.Optional(
@@ -123,8 +123,8 @@ const SessionReadSchema = Type.Object({
123
123
  // ---- guidelines(注入 LLM,design §3.4)----
124
124
 
125
125
  const guidelines = [
126
- 'Progressive reading: outline (~500 token overview) → expand (one turn) → detail (full text). Default omits toolResult/thinking noise.',
127
- 'find first to locate a session by uuid fragment or name. TUI #references are uuid fragments.',
126
+ 'Progressive reading: outline (~1500 token overview) → expand (one turn) → detail (full text). Default omits toolResult/thinking noise.',
127
+ 'find first to locate a session by uuid fragment or name. TUI #references are full uuids.',
128
128
  'outline before detail. Never read raw .jsonl files—use this tool.',
129
129
  'family traces fork parents/children, subagent sessions, and workflow runs.',
130
130
  'extract what=<type> to pull user messages / commands / files / commits / tool results across turns (optional tool= filter for commands/tool-results).',
@@ -134,7 +134,7 @@ const guidelines = [
134
134
 
135
135
  // ---- 工具 description(design §3.4,照搬措辞)----
136
136
 
137
- const description = `Read pi session files (conversation history) by semantic structure instead of raw bytes. Use when you need to review another session, trace a fork/subagent/workflow family, or locate a past decision. Nine actions: find (locate by name/uuid fragment), family (fork/subagent/workflow relations), outline (turn-level overview, ~500 token), expand (single-turn entry list), detail (full text of turns), search (full-text grep across a session), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type), workflow (workflow run overview: status/budget/steps, step call sessionId jumps to outline/detail). Progressive reading: outline → expand → detail. Do NOT use for the current session (use get_messages) or to edit sessions (pi has /resume /fork).`
137
+ const description = `Read pi session files (conversation history) by semantic structure instead of raw bytes. Use when you need to review another session, trace a fork/subagent/workflow family, or locate a past decision. Nine actions: find (locate by name/uuid fragment), family (fork/subagent/workflow relations), outline (turn-level overview, ~1500 token), expand (single-turn entry list), detail (full text of turns), search (full-text grep across a session), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type), workflow (workflow run overview: status/budget/steps, step call sessionId jumps to outline/detail). Progressive reading: outline → expand → detail. Do NOT use for the current session (the host provides current-session access) or to edit sessions (pi has /resume /fork).`
138
138
 
139
139
  /**
140
140
  * 已注册过 TUI provider/command 的 pi 实例集合。