@zhushanwen/pi-session-reader 0.3.0 → 0.4.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.
@@ -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
  /**
@@ -20,7 +20,8 @@ import { resolveWorkflows } from './workflows.js'
20
20
  * M2 用 subagent 文件首行 header.id(真实 session id)替换之,使 SubagentRef.sessionId
21
21
  * 是真实 id,Q1 隔代关联仍用 data.rootSessionId(不变)。
22
22
  * - cleanedUp:subagent 文件被 30 天 TTL GC 后,identity(在文件尾)一并消失。唯一残留痕迹
23
- * 是 records/<sa-id>.json manifest(subagent 创建时写入,持久存在)。故 manifest 是孤儿/
23
+ * 是 records/<sa-id>.json manifest(终态 finalize 或 sync 批落标出口写入——subagent
24
+ * 创建时不写,两写点均先于 30 天 GC 落盘,故 GC 后必残留)。manifest 是孤儿/
24
25
  * cleanedUp 的来源(design §3.3 D-7 "records/*.json manifest 作孤儿补充")。
25
26
  * - workflows:M1 resolveFamily 恒返回 []。M2 在此单独读目标 session 的 workflow-state-link
26
27
  * custom entry → link.data.path(wf-state 文件绝对路径)→ 从文件尾向头找首个非空且
@@ -33,26 +34,77 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
33
34
  // manifest 主路径:alive 文件的 meta.path 命中索引 → 透 task/slug/model/status/sessionFile 全字段。
34
35
  // 索引未命中(场景 A:嵌套 subagent 的 manifest 不在当前 agentDir,11.5%)走 P-fallback 回退 identity。
35
36
  const manifests = await listRecordManifests(agentDir)
36
- const manifestBySessionFile = new Map<string, RecordManifest>()
37
- for (const m of manifests) manifestBySessionFile.set(m.sessionFile, m)
37
+ const manifestBySessionFile = indexManifestsBySessionFile(manifests)
38
38
 
39
- // ---- 1. main sessions:首行 header → byId/childrenOf 素材 + fileStats + 路径反查 ----
40
- const mainMetas = await listMainSessions(agentDir)
41
- const headers: Entry[] = []
42
- const fileStats = new Map<string, { mtime: number; size: number }>()
39
+ // ---- 1-3. 逐阶段扫描(main → subagent → 孤儿 manifest),累积器显式传递 ----
40
+ const scan: FamilyFsScan = {
41
+ headers: [],
42
+ identities: [],
43
+ fileStats: new Map(),
44
+ sessionIdToPath: new Map(),
45
+ pathToRef: new Map(),
46
+ aliveSubPaths: new Set(),
47
+ }
48
+ await collectMainSessions(agentDir, scan)
49
+ await collectSubagentIdentities(agentDir, manifestBySessionFile, scan)
50
+ appendOrphanIdentities(manifests, scan)
51
+
52
+ // ---- 4-5. build index + resolve(sessionId 不在 byId → resolveFamily 抛 Error)----
53
+ if (!scan.sessionIdToPath.has(sessionId)) {
54
+ throw new Error(
55
+ `session "${sessionId}" not found under ${agentDir}/sessions — ` +
56
+ `no main session file whose first-line header id matches. ` +
57
+ `Verify the sessionId or agentDir; for partial uuid, use findSessions first.`,
58
+ )
59
+ }
60
+ const index = buildFamilyIndex(scan.headers, scan.identities, scan.fileStats)
61
+ const family = resolveFamily(sessionId, index)
62
+
63
+ // ---- 6. 补 M1 占位字段(fileName / subagent cwd)+ workflows ----
64
+ enrichRefs(family, scan.pathToRef)
65
+ family.workflows = await resolveWorkflows(sessionId, scan.sessionIdToPath, scan.pathToRef)
66
+
67
+ return family
68
+ }
69
+
70
+ // ============================================================
71
+ // buildFamilyFromFs 扫描阶段 helpers(各阶段显式传入累积器,插入顺序 = 原实现顺序)
72
+ // ============================================================
73
+
74
+ /** buildFamilyFromFs 各扫描阶段的共享累积器(主函数创建,各阶段 helper 显式传入并写入) */
75
+ interface FamilyFsScan {
76
+ /** main session headers(buildFamilyIndex byId/childrenOf 素材) */
77
+ headers: Entry[]
78
+ /** subagent identity entries(步骤 2 富化 + 步骤 3 孤儿) */
79
+ identities: Entry[]
80
+ /** sessionId → { mtime, size }(buildFamilyIndex 存活判定素材) */
81
+ fileStats: Map<string, { mtime: number; size: number }>
43
82
  /** sessionId → 真实文件路径,供 workflows 找目标文件 + enrich 补 fileName */
44
- const sessionIdToPath = new Map<string, string>()
83
+ sessionIdToPath: Map<string, string>
45
84
  /** path → 完整 SessionRef(含真实 cwd/fileName/mtime/size),供 enrich + workflow calls 反查 */
46
- const pathToRef = new Map<string, SessionRef>()
85
+ pathToRef: Map<string, SessionRef>
86
+ /** 已扫描到的 subagent 文件路径集合,供 manifest 孤儿判定(alive 则跳过 manifest) */
87
+ aliveSubPaths: Set<string>
88
+ }
89
+
90
+ /** manifest 按 sessionFile 建索引(alive 文件的 meta.path 命中 → 走 manifest 主路径) */
91
+ function indexManifestsBySessionFile(manifests: RecordManifest[]): Map<string, RecordManifest> {
92
+ const manifestBySessionFile = new Map<string, RecordManifest>()
93
+ for (const m of manifests) manifestBySessionFile.set(m.sessionFile, m)
94
+ return manifestBySessionFile
95
+ }
47
96
 
97
+ /** 步骤 1:main sessions —— 首行 header → headers + fileStats + 路径反查 */
98
+ async function collectMainSessions(agentDir: string, scan: FamilyFsScan): Promise<void> {
99
+ const mainMetas = await listMainSessions(agentDir)
48
100
  for (const meta of mainMetas) {
49
101
  const h = parseHeaderLine(await readFirstLine(meta.path))
50
102
  if (!h) continue // 非 session/坏 header → 跳过(不入 byId)
51
103
  const entry: Entry = { type: 'session', id: h.id, parentId: null, cwd: h.cwd ?? '' }
52
104
  if (h.parentSession) entry.parentSession = h.parentSession
53
- headers.push(entry)
54
- fileStats.set(h.id, { mtime: meta.mtime, size: meta.size })
55
- sessionIdToPath.set(h.id, meta.path)
105
+ scan.headers.push(entry)
106
+ scan.fileStats.set(h.id, { mtime: meta.mtime, size: meta.size })
107
+ scan.sessionIdToPath.set(h.id, meta.path)
56
108
  const ref: SessionRef = {
57
109
  sessionId: h.id,
58
110
  fileName: meta.path,
@@ -61,17 +113,47 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
61
113
  cwd: h.cwd ?? '',
62
114
  }
63
115
  if (h.parentSession) ref.parentSession = h.parentSession
64
- pathToRef.set(meta.path, ref)
116
+ scan.pathToRef.set(meta.path, ref)
117
+ }
118
+ }
119
+
120
+ /** identity data 组装(manifest 主,TC-u4-manifest-enrich):透 task/slug/model/status/sessionFile 全字段 */
121
+ function manifestIdentityData(manifest: RecordManifest): Record<string, unknown> {
122
+ return {
123
+ rootSessionId: manifest.rootSessionId,
124
+ slug: manifest.slug ?? '', // slug 兼容旧 manifest(缺→空串兜底,m0 契约)
125
+ task: manifest.task,
126
+ agent: manifest.agentName, // 同语义异名:manifest.agentName ↔ identity.data.agent
127
+ model: manifest.model,
128
+ status: manifest.status,
129
+ sessionFile: manifest.sessionFile,
130
+ }
131
+ }
132
+
133
+ /** identity data 组装(P-fallback,ES-p-fallback-no-manifest):identity 回退取 task/slug/agent */
134
+ function fallbackIdentityData(
135
+ meta: SessionFileMeta,
136
+ ident: TailIdentity,
137
+ ): Record<string, unknown> {
138
+ return {
139
+ rootSessionId: ident.rootSessionId,
140
+ slug: ident.slug,
141
+ task: ident.task,
142
+ agent: ident.agent,
143
+ // model/status 不可回退(identity 无,探针 15/15),留 undefined
144
+ sessionFile: meta.path,
65
145
  }
146
+ }
66
147
 
67
- // ---- 2. subagent sessions:首行 header(真实 id)+ manifest 主/identity 回退 → 富字段 identity ----
148
+ /** 步骤 2:subagent sessions —— 首行 header(真实 id)+ manifest 主/identity 回退 → 富字段 identity */
149
+ async function collectSubagentIdentities(
150
+ agentDir: string,
151
+ manifestBySessionFile: Map<string, RecordManifest>,
152
+ scan: FamilyFsScan,
153
+ ): Promise<void> {
68
154
  // U4 数据流:manifest 命中透全字段;未命中 P-fallback 读尾行 identity 取 task/slug/agent
69
155
  //(model/status 不可回退,留 undefined);无 manifest 无 identity(运行中/异常)跳过。
70
156
  const subMetas = await listSubagentSessions(agentDir)
71
- const identities: Entry[] = []
72
- /** 已扫描到的 subagent 文件路径集合,供 manifest 孤儿判定(alive 则跳过 manifest) */
73
- const aliveSubPaths = new Set<string>()
74
-
75
157
  for (const meta of subMetas) {
76
158
  const h = parseHeaderLine(await readFirstLine(meta.path))
77
159
  if (!h) continue // 非 session/坏 header → 无真实 session id,无法 id 修正,跳过
@@ -79,46 +161,30 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
79
161
  // header 可解析即视为 alive(MF-3):identity 在文件尾行、完成时才写入,运行中的 subagent
80
162
  // 无 identity。若此处跳过,步骤 3 会把活文件(含其 manifest)当孤儿收编 → cleanedUp=true,
81
163
  // family 把活着的 subagent 显示成 [已清理],真实 sessionId 永远无法关联。
82
- aliveSubPaths.add(meta.path)
164
+ scan.aliveSubPaths.add(meta.path)
83
165
 
84
166
  // identity data 组装:manifest 主路径或 P-fallback identity 回退,二选一
85
167
  const manifest = manifestBySessionFile.get(meta.path)
86
168
  let data: Record<string, unknown>
87
169
  if (manifest) {
88
- // manifest 主(TC-u4-manifest-enrich):透 task/slug/model/status/sessionFile 全字段
89
- data = {
90
- rootSessionId: manifest.rootSessionId,
91
- slug: manifest.slug ?? '', // slug 兼容旧 manifest(缺→空串兜底,m0 契约)
92
- task: manifest.task,
93
- agent: manifest.agentName, // 同语义异名:manifest.agentName ↔ identity.data.agent
94
- model: manifest.model,
95
- status: manifest.status,
96
- sessionFile: manifest.sessionFile,
97
- }
170
+ data = manifestIdentityData(manifest)
98
171
  } else {
99
172
  // P-fallback(ES-p-fallback-no-manifest):读尾行 identity 回退取 task/slug/agent
100
173
  const ident = await readTailIdentity(meta.path, meta.size)
101
174
  if (!ident) continue // 无 identity(ES-p-fallback-no-identity,运行中/异常)→ 跳过
102
- data = {
103
- rootSessionId: ident.rootSessionId,
104
- slug: ident.slug,
105
- task: ident.task,
106
- agent: ident.agent,
107
- // model/status 不可回退(identity 无,探针 15/15),留 undefined
108
- sessionFile: meta.path,
109
- }
175
+ data = fallbackIdentityData(meta, ident)
110
176
  }
111
177
  // id 修正:entry.id 用真实 header.id 替换 sa-xxx 占位
112
- identities.push({
178
+ scan.identities.push({
113
179
  type: 'custom',
114
180
  id: realId,
115
181
  parentId: null,
116
182
  customType: 'subagent-identity',
117
183
  data,
118
184
  })
119
- fileStats.set(realId, { mtime: meta.mtime, size: meta.size })
120
- sessionIdToPath.set(realId, meta.path)
121
- pathToRef.set(meta.path, {
185
+ scan.fileStats.set(realId, { mtime: meta.mtime, size: meta.size })
186
+ scan.sessionIdToPath.set(realId, meta.path)
187
+ scan.pathToRef.set(meta.path, {
122
188
  sessionId: realId,
123
189
  fileName: meta.path,
124
190
  mtime: meta.mtime,
@@ -126,15 +192,20 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
126
192
  cwd: h.cwd ?? '',
127
193
  })
128
194
  }
195
+ }
129
196
 
130
- // ---- 3. records manifest → 孤儿(cleanedUp,ES-orphan-manifest)----
131
- // manifest 在 subagent 创建时写入,.jsonl 被 GC 后仍残留。alive 的(sessionFile 已在步骤 2
132
- // 扫到)跳过;未扫到的 = 文件已 GC → 孤儿。用 manifest 完整富字段填 SubagentRef,
133
- // sessionFile 保留 manifest 的 GC 路径(不置空,供 LLM 知晓原位置),cleanedUp 由
134
- // buildFamilyIndex 的 !fileStats.has(ident.id) 判 true(ident.id=manifest.id 不在 fileStats)。
197
+ /**
198
+ * 步骤 3:records manifest → 孤儿(cleanedUp,ES-orphan-manifest)。
199
+ * manifest 由终态 finalize / sync 批落标出口写入(创建时不写),.jsonl 被 GC 后仍
200
+ * 残留。alive 的(sessionFile 已在步骤 2
201
+ * 扫到)跳过;未扫到的 = 文件已 GC → 孤儿。用 manifest 完整富字段填 SubagentRef,
202
+ * sessionFile 保留 manifest 的 GC 路径(不置空,供 LLM 知晓原位置),cleanedUp 由
203
+ * buildFamilyIndex 的 !fileStats.has(ident.id) 判 true(ident.id=manifest.id 不在 fileStats)。
204
+ */
205
+ function appendOrphanIdentities(manifests: RecordManifest[], scan: FamilyFsScan): void {
135
206
  for (const m of manifests) {
136
- if (aliveSubPaths.has(m.sessionFile)) continue
137
- identities.push({
207
+ if (scan.aliveSubPaths.has(m.sessionFile)) continue
208
+ scan.identities.push({
138
209
  type: 'custom',
139
210
  id: m.id,
140
211
  parentId: null,
@@ -151,23 +222,6 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
151
222
  },
152
223
  })
153
224
  }
154
-
155
- // ---- 4-5. build index + resolve(sessionId 不在 byId → resolveFamily 抛 Error)----
156
- if (!sessionIdToPath.has(sessionId)) {
157
- throw new Error(
158
- `session "${sessionId}" not found under ${agentDir}/sessions — ` +
159
- `no main session file whose first-line header id matches. ` +
160
- `Verify the sessionId or agentDir; for partial uuid, use findSessions first.`,
161
- )
162
- }
163
- const index = buildFamilyIndex(headers, identities, fileStats)
164
- const family = resolveFamily(sessionId, index)
165
-
166
- // ---- 6. 补 M1 占位字段(fileName / subagent cwd)+ workflows ----
167
- enrichRefs(family, pathToRef)
168
- family.workflows = await resolveWorkflows(sessionId, sessionIdToPath, pathToRef)
169
-
170
- return family
171
225
  }
172
226
 
173
227
  // ============================================================
@@ -221,6 +275,15 @@ function parseHeaderLine(line: string | undefined): SessionHeader | null {
221
275
  return h
222
276
  }
223
277
 
278
+ /** readTailIdentity 的成功返回形状(rootSessionId 必有,其余存在才带) */
279
+ type TailIdentity = {
280
+ rootSessionId: string
281
+ slug: string
282
+ task?: string
283
+ agent?: string
284
+ parentRecordId?: string
285
+ }
286
+
224
287
  /**
225
288
  * 读 subagent 文件尾部(最后 64KB)找 subagent-identity entry,返回 rootSessionId + slug + task + agent。
226
289
  *
@@ -238,54 +301,36 @@ function parseHeaderLine(line: string | undefined): SessionHeader | null {
238
301
  export async function readTailIdentity(
239
302
  path: string,
240
303
  size?: number,
241
- ): Promise<
242
- | { rootSessionId: string; slug: string; task?: string; agent?: string; parentRecordId?: string }
243
- | undefined
244
- > {
245
- // size 未传时内部 stat 获取(execution-tree.ts 复用时无 size)
246
- let resolvedSize = size
247
- if (resolvedSize === undefined) {
248
- try {
249
- resolvedSize = (await stat(path)).size
250
- } catch {
251
- return undefined // 文件不存在/读失败 → undefined
252
- }
253
- }
304
+ ): Promise<TailIdentity | undefined> {
305
+ const resolvedSize = await resolveTailSize(path, size)
306
+ if (resolvedSize === undefined) return undefined // 文件不存在/读失败 → undefined
254
307
  if (resolvedSize === 0) return undefined
308
+ const text = await readTailText(path, resolvedSize)
309
+ if (text === undefined) return undefined
310
+ const line = extractTailIdentityLine(text, resolvedSize)
311
+ if (line === undefined) return undefined
312
+ return parseTailIdentityLine(line)
313
+ }
314
+
315
+ /** 尾读 size 解析:size 未传时内部 stat 获取(execution-tree.ts 复用时无 size);stat 失败 → undefined */
316
+ async function resolveTailSize(path: string, size?: number): Promise<number | undefined> {
317
+ if (size !== undefined) return size
318
+ try {
319
+ return (await stat(path)).size
320
+ } catch {
321
+ return undefined // 文件不存在/读失败 → undefined
322
+ }
323
+ }
324
+
325
+ /** 读文件尾部 min(64KB, size) 字节为文本;打开/读取失败 → undefined */
326
+ async function readTailText(path: string, resolvedSize: number): Promise<string | undefined> {
255
327
  let fh: FileHandle | undefined
256
328
  try {
257
329
  fh = await open(path, 'r')
258
330
  const len = Math.min(TAIL_READ_BYTES, resolvedSize)
259
331
  const buf = Buffer.alloc(len)
260
332
  await fh.read(buf, 0, len, Math.max(0, resolvedSize - len))
261
- const text = buf.toString('utf8')
262
- const idx = text.lastIndexOf('subagent-identity')
263
- if (idx < 0) return undefined
264
- // 行首若在读窗口外(identity 行 > 64KB,整行塞不下)→ 无法可靠解析,跳过
265
- const lineStartSearch = text.lastIndexOf('\n', idx)
266
- if (lineStartSearch < 0 && resolvedSize > len) return undefined
267
- const start = lineStartSearch < 0 ? 0 : lineStartSearch + 1
268
- let end = text.indexOf('\n', idx)
269
- if (end < 0) end = text.length
270
- const line = text.slice(start, end)
271
- let raw: unknown
272
- try {
273
- raw = JSON.parse(line)
274
- } catch {
275
- return undefined
276
- }
277
- const data = (raw as Record<string, unknown> | undefined)?.data as
278
- | Record<string, unknown>
279
- | undefined
280
- if (!data || typeof data.rootSessionId !== 'string') return undefined
281
- return {
282
- rootSessionId: data.rootSessionId,
283
- slug: typeof data.slug === 'string' ? data.slug : '',
284
- task: typeof data.task === 'string' ? data.task : undefined,
285
- agent: typeof data.agent === 'string' ? data.agent : undefined,
286
- parentRecordId:
287
- typeof data.parentRecordId === 'string' ? data.parentRecordId : undefined,
288
- }
333
+ return buf.toString('utf8')
289
334
  } catch {
290
335
  return undefined
291
336
  } finally {
@@ -293,6 +338,45 @@ export async function readTailIdentity(
293
338
  }
294
339
  }
295
340
 
341
+ /**
342
+ * 从尾窗文本定位最后一个 subagent-identity 标记所在行(多次重写时取最新)。
343
+ * 无标记 / 行首在读窗口外(identity 行 > 64KB,整行塞不下)→ undefined。
344
+ */
345
+ function extractTailIdentityLine(text: string, resolvedSize: number): string | undefined {
346
+ const idx = text.lastIndexOf('subagent-identity')
347
+ if (idx < 0) return undefined
348
+ const len = Math.min(TAIL_READ_BYTES, resolvedSize)
349
+ // 行首若在读窗口外(identity 行 > 64KB,整行塞不下)→ 无法可靠解析,跳过
350
+ const lineStartSearch = text.lastIndexOf('\n', idx)
351
+ if (lineStartSearch < 0 && resolvedSize > len) return undefined
352
+ const start = lineStartSearch < 0 ? 0 : lineStartSearch + 1
353
+ let end = text.indexOf('\n', idx)
354
+ if (end < 0) end = text.length
355
+ return text.slice(start, end)
356
+ }
357
+
358
+ /** identity 行 → 富字段结果;JSON 损坏 / 缺 rootSessionId → undefined */
359
+ function parseTailIdentityLine(line: string): TailIdentity | undefined {
360
+ let raw: unknown
361
+ try {
362
+ raw = JSON.parse(line)
363
+ } catch {
364
+ return undefined
365
+ }
366
+ const data = (raw as Record<string, unknown> | undefined)?.data as
367
+ | Record<string, unknown>
368
+ | undefined
369
+ if (!data || typeof data.rootSessionId !== 'string') return undefined
370
+ return {
371
+ rootSessionId: data.rootSessionId,
372
+ slug: typeof data.slug === 'string' ? data.slug : '',
373
+ task: typeof data.task === 'string' ? data.task : undefined,
374
+ agent: typeof data.agent === 'string' ? data.agent : undefined,
375
+ parentRecordId:
376
+ typeof data.parentRecordId === 'string' ? data.parentRecordId : undefined,
377
+ }
378
+ }
379
+
296
380
  // ============================================================
297
381
  // records manifest(孤儿 / cleanedUp 来源)
298
382
  // ============================================================
@@ -301,7 +385,7 @@ export interface RecordManifest {
301
385
  id: string
302
386
  rootSessionId: string
303
387
  agentName?: string
304
- /** subagent session.jsonl 绝对路径(创建时写入;文件 GC 后路径仍残留) */
388
+ /** subagent session.jsonl 绝对路径(manifest 写入时快照;文件 GC 后路径仍残留) */
305
389
  sessionFile: string
306
390
  /** subagent 任务文本(探针 20/20 全有;旧 manifest 缺→undefined) */
307
391
  task?: string
@@ -343,8 +427,8 @@ async function tryReadManifest(path: string): Promise<RecordManifest | undefined
343
427
 
344
428
  /**
345
429
  * 扫描 subagents/<cwdSlug>/records/*.json —— subagent 注册清单。
346
- * 每个 manifest 在 subagent 创建时写入,持久存在即使 .jsonl 被 GC。坏 manifest(缺必填字段
347
- * /JSON 损坏)跳过,不中断扫描。
430
+ * 每个 manifest 由终态 finalize 或 sync 批落标出口写入(创建时不写),持久存在即使
431
+ * .jsonl 被 GC。坏 manifest(缺必填字段/JSON 损坏)跳过,不中断扫描。
348
432
  */
349
433
  export async function listRecordManifests(agentDir: string): Promise<RecordManifest[]> {
350
434
  const root = join(agentDir, 'subagents')
@@ -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
@@ -21,16 +21,27 @@ import { createSessionCommand } from './tui/session-command.js'
21
21
 
22
22
  const SessionReadSchema = Type.Object({
23
23
  action: StringEnum(
24
- ['find', 'family', 'outline', 'expand', 'detail', 'search', 'export', 'extract', 'workflow'],
24
+ [
25
+ 'find',
26
+ 'family',
27
+ 'outline',
28
+ 'expand',
29
+ 'detail',
30
+ 'search',
31
+ 'export',
32
+ 'extract',
33
+ 'workflow',
34
+ 'result',
35
+ ],
25
36
  {
26
37
  description:
27
- 'Action to perform: find (locate session), family (fork/subagent/workflow relations; recursive=true returns nested execution tree), outline (turn-level overview), expand (single-turn entries), detail (full text of turns), search (full-text grep), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type), workflow (workflow run overview: status/budget/steps; requires session, optional runId focuses one run; step call sessionId jumps to outline/detail).',
38
+ 'Action to perform: find (locate session), family (fork/subagent/workflow relations; recursive=true returns nested execution tree), outline (turn-level overview), expand (single-turn entries), detail (full text of turns), search (full-text grep), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type), workflow (workflow run overview: status/budget/steps; requires session, optional runId focuses one run; step call sessionId jumps to outline/detail), result (fetch a subagent session final result text — same content as its completion notice; session = single id or comma-separated batch of at most 10, optional limit caps chars per item).',
28
39
  },
29
40
  ),
30
41
  session: Type.Optional(
31
42
  Type.String({
32
43
  description:
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.',
44
+ '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/result. result also accepts a comma-separated list of up to 10 ids. # prefix auto-stripped.',
34
45
  }),
35
46
  ),
36
47
  query: Type.Optional(
@@ -91,7 +102,10 @@ const SessionReadSchema = Type.Object({
91
102
  }),
92
103
  ),
93
104
  limit: Type.Optional(
94
- Type.Number({ description: 'find/search: max results. Default 20.' }),
105
+ Type.Number({
106
+ description:
107
+ 'find/search: max results. Default 20. result: max chars per item, default 8000 (overlong text truncated with a pointer to the full file).',
108
+ }),
95
109
  ),
96
110
  what: Type.Optional(
97
111
  StringEnum(
@@ -129,12 +143,13 @@ const guidelines = [
129
143
  'family traces fork parents/children, subagent sessions, and workflow runs.',
130
144
  'extract what=<type> to pull user messages / commands / files / commits / tool results across turns (optional tool= filter for commands/tool-results).',
131
145
  "workflow action to see workflow run overviews (status/budget/steps). Each step's call sessionId can jump to outline/detail for deep reading.",
146
+ "result action to fetch a subagent's final result text (same content as its completion notice): session takes a single sa-id/uuid/path or a comma-separated batch of at most 10; optional limit caps chars per item (default 8000, truncated items carry a pointer to the full file).",
132
147
  'Errors carry a 👉 recovery hint—follow it to retry in one step.',
133
148
  ]
134
149
 
135
150
  // ---- 工具 description(design §3.4,照搬措辞)----
136
151
 
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).`
152
+ 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. Ten 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), result (fetch a subagent session final result text, same content as its completion notice; single id or comma-separated batch ≤10, optional limit chars per item default 8000). 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
153
 
139
154
  /**
140
155
  * 已注册过 TUI provider/command 的 pi 实例集合。