@zhushanwen/pi-session-reader 0.1.0 → 0.2.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.
- package/package.json +1 -1
- package/src/__tests__/execution-tree.test.ts +792 -0
- package/src/__tests__/family.test.ts +150 -0
- package/src/__tests__/find.test.ts +342 -1
- package/src/__tests__/index.test.ts +50 -0
- package/src/__tests__/parser.test.ts +2 -1
- package/src/__tests__/real-data.ts +20 -0
- package/src/__tests__/render.test.ts +2 -1
- package/src/__tests__/subagents.test.ts +323 -14
- package/src/__tests__/tool-handler.test.ts +872 -5
- package/src/__tests__/tree.test.ts +20 -0
- package/src/__tests__/turns.test.ts +2 -1
- package/src/__tests__/workflow.test.ts +353 -0
- package/src/core/execution-tree.ts +517 -0
- package/src/core/family.ts +40 -0
- package/src/core/tree.ts +14 -5
- package/src/core/workflow.ts +330 -0
- package/src/discovery/find.ts +167 -20
- package/src/discovery/roots.ts +3 -2
- package/src/discovery/subagents.ts +113 -152
- package/src/discovery/workflows.ts +164 -0
- package/src/index.ts +22 -4
- package/src/tool-handler.ts +334 -21
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { readFile, readdir, open } from 'node:fs/promises'
|
|
1
|
+
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 {
|
|
6
|
-
import type { Family, SessionRef, SubagentRef, WorkflowRef } from '../core/family.js'
|
|
5
|
+
import type { Family, SessionRef, SubagentRef } from '../core/family.js'
|
|
7
6
|
import { buildFamilyIndex, resolveFamily } from '../core/family.js'
|
|
8
7
|
import { listMainSessions, listSubagentSessions } from './roots.js'
|
|
8
|
+
import { resolveWorkflows } from './workflows.js'
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* [M2 discovery] 从文件系统构建某 session 的完整家族(IO 适配层)。
|
|
@@ -28,6 +28,13 @@ import { listMainSessions, listSubagentSessions } from './roots.js'
|
|
|
28
28
|
* @throws sessionId 不在任意 main session header → Error(M3 tool-adapter 层转 F1 恢复指引)
|
|
29
29
|
*/
|
|
30
30
|
export async function buildFamilyFromFs(sessionId: string, agentDir: string): Promise<Family> {
|
|
31
|
+
// ---- 0. record manifest 索引(U4:sessionFile → manifest,供 alive 扫描查富字段)----
|
|
32
|
+
// manifest 主路径:alive 文件的 meta.path 命中索引 → 透 task/slug/model/status/sessionFile 全字段。
|
|
33
|
+
// 索引未命中(场景 A:嵌套 subagent 的 manifest 不在当前 agentDir,11.5%)走 P-fallback 回退 identity。
|
|
34
|
+
const manifests = await listRecordManifests(agentDir)
|
|
35
|
+
const manifestBySessionFile = new Map<string, RecordManifest>()
|
|
36
|
+
for (const m of manifests) manifestBySessionFile.set(m.sessionFile, m)
|
|
37
|
+
|
|
31
38
|
// ---- 1. main sessions:首行 header → byId/childrenOf 素材 + fileStats + 路径反查 ----
|
|
32
39
|
const mainMetas = await listMainSessions(agentDir)
|
|
33
40
|
const headers: Entry[] = []
|
|
@@ -56,7 +63,9 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
|
|
|
56
63
|
pathToRef.set(meta.path, ref)
|
|
57
64
|
}
|
|
58
65
|
|
|
59
|
-
// ---- 2. subagent sessions:首行 header(真实 id)+
|
|
66
|
+
// ---- 2. subagent sessions:首行 header(真实 id)+ manifest 主/identity 回退 → 富字段 identity ----
|
|
67
|
+
// U4 数据流:manifest 命中透全字段;未命中 P-fallback 读尾行 identity 取 task/slug/agent
|
|
68
|
+
//(model/status 不可回退,留 undefined);无 manifest 无 identity(运行中/异常)跳过。
|
|
60
69
|
const subMetas = await listSubagentSessions(agentDir)
|
|
61
70
|
const identities: Entry[] = []
|
|
62
71
|
/** 已扫描到的 subagent 文件路径集合,供 manifest 孤儿判定(alive 则跳过 manifest) */
|
|
@@ -69,17 +78,42 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
|
|
|
69
78
|
// header 可解析即视为 alive(MF-3):identity 在文件尾行、完成时才写入,运行中的 subagent
|
|
70
79
|
// 无 identity。若此处跳过,步骤 3 会把活文件(含其 manifest)当孤儿收编 → cleanedUp=true,
|
|
71
80
|
// family 把活着的 subagent 显示成 [已清理],真实 sessionId 永远无法关联。
|
|
72
|
-
// 注意:sessionIdToPath/pathToRef 仍需 identity(依赖 realId 的 rootSessionId/slug)。
|
|
73
81
|
aliveSubPaths.add(meta.path)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
|
|
83
|
+
// identity data 组装:manifest 主路径或 P-fallback identity 回退,二选一
|
|
84
|
+
const manifest = manifestBySessionFile.get(meta.path)
|
|
85
|
+
let data: Record<string, unknown>
|
|
86
|
+
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
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
// P-fallback(ES-p-fallback-no-manifest):读尾行 identity 回退取 task/slug/agent
|
|
99
|
+
const ident = await readTailIdentity(meta.path, meta.size)
|
|
100
|
+
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
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// id 修正:entry.id 用真实 header.id 替换 sa-xxx 占位
|
|
77
111
|
identities.push({
|
|
78
112
|
type: 'custom',
|
|
79
113
|
id: realId,
|
|
80
114
|
parentId: null,
|
|
81
115
|
customType: 'subagent-identity',
|
|
82
|
-
data
|
|
116
|
+
data,
|
|
83
117
|
})
|
|
84
118
|
fileStats.set(realId, { mtime: meta.mtime, size: meta.size })
|
|
85
119
|
sessionIdToPath.set(realId, meta.path)
|
|
@@ -92,11 +126,11 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
|
|
|
92
126
|
})
|
|
93
127
|
}
|
|
94
128
|
|
|
95
|
-
// ---- 3. records manifest → 孤儿(cleanedUp)----
|
|
129
|
+
// ---- 3. records manifest → 孤儿(cleanedUp,ES-orphan-manifest)----
|
|
96
130
|
// manifest 在 subagent 创建时写入,.jsonl 被 GC 后仍残留。alive 的(sessionFile 已在步骤 2
|
|
97
|
-
// 扫到)跳过;未扫到的 = 文件已 GC →
|
|
98
|
-
//
|
|
99
|
-
|
|
131
|
+
// 扫到)跳过;未扫到的 = 文件已 GC → 孤儿。用 manifest 完整富字段填 SubagentRef,
|
|
132
|
+
// sessionFile 保留 manifest 的 GC 路径(不置空,供 LLM 知晓原位置),cleanedUp 由
|
|
133
|
+
// buildFamilyIndex 的 !fileStats.has(ident.id) 判 true(ident.id=manifest.id 不在 fileStats)。
|
|
100
134
|
for (const m of manifests) {
|
|
101
135
|
if (aliveSubPaths.has(m.sessionFile)) continue
|
|
102
136
|
identities.push({
|
|
@@ -104,8 +138,16 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
|
|
|
104
138
|
id: m.id,
|
|
105
139
|
parentId: null,
|
|
106
140
|
customType: 'subagent-identity',
|
|
107
|
-
|
|
108
|
-
|
|
141
|
+
data: {
|
|
142
|
+
rootSessionId: m.rootSessionId,
|
|
143
|
+
// 孤儿 slug 随文件 GC 丢失:优先 manifest.slug,回退 agentName(agent 类型名),再兜底空串
|
|
144
|
+
slug: m.slug ?? m.agentName ?? '',
|
|
145
|
+
task: m.task,
|
|
146
|
+
agent: m.agentName, // 同语义异名映射
|
|
147
|
+
model: m.model,
|
|
148
|
+
status: m.status,
|
|
149
|
+
sessionFile: m.sessionFile,
|
|
150
|
+
},
|
|
109
151
|
})
|
|
110
152
|
}
|
|
111
153
|
|
|
@@ -179,29 +221,48 @@ function parseHeaderLine(line: string | undefined): SessionHeader | null {
|
|
|
179
221
|
}
|
|
180
222
|
|
|
181
223
|
/**
|
|
182
|
-
* 读 subagent 文件尾部(最后 64KB)找 subagent-identity entry,返回 rootSessionId + slug。
|
|
224
|
+
* 读 subagent 文件尾部(最后 64KB)找 subagent-identity entry,返回 rootSessionId + slug + task + agent。
|
|
183
225
|
*
|
|
184
226
|
* identity 在文件尾行(探查确认;design §3.3 D-7 "尾部")。用 lastIndexOf 定位最后一个
|
|
185
227
|
* subagent-identity 标记(多次重写时取最新),提取该行边界内的 JSON 解析。identity 行
|
|
186
228
|
* 超 64KB(极罕见,实测 1/3430)会截断 → 解析失败 → 返回 undefined(该 subagent 不收)。
|
|
229
|
+
*
|
|
230
|
+
* U4 扩展返回 task/agent(P-fallback 富化用):从 identity.data.task / data.agent 提取,
|
|
231
|
+
* 存在则带。model/status 在 identity 不存在(探针 15/15 无),P-fallback 时由调用方留 undefined。
|
|
232
|
+
*
|
|
233
|
+
* M3b 扩展(IF3 三级数据源 ②):返回 parentRecordId(identity.data.parentRecordId,新版本
|
|
234
|
+
* session-runner 才写;当前本机旧数据无此字段→undefined)。size 改 optional——buildFamilyFromFs
|
|
235
|
+
* 传 size(已有 meta.size 省一次 stat),buildExecutionTree 不传 size(内部 stat 获取)。
|
|
187
236
|
*/
|
|
188
|
-
async function readTailIdentity(
|
|
237
|
+
export async function readTailIdentity(
|
|
189
238
|
path: string,
|
|
190
|
-
size
|
|
191
|
-
): Promise<
|
|
192
|
-
|
|
239
|
+
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
|
+
}
|
|
253
|
+
if (resolvedSize === 0) return undefined
|
|
193
254
|
let fh: FileHandle | undefined
|
|
194
255
|
try {
|
|
195
256
|
fh = await open(path, 'r')
|
|
196
|
-
const len = Math.min(TAIL_READ_BYTES,
|
|
257
|
+
const len = Math.min(TAIL_READ_BYTES, resolvedSize)
|
|
197
258
|
const buf = Buffer.alloc(len)
|
|
198
|
-
await fh.read(buf, 0, len, Math.max(0,
|
|
259
|
+
await fh.read(buf, 0, len, Math.max(0, resolvedSize - len))
|
|
199
260
|
const text = buf.toString('utf8')
|
|
200
261
|
const idx = text.lastIndexOf('subagent-identity')
|
|
201
262
|
if (idx < 0) return undefined
|
|
202
263
|
// 行首若在读窗口外(identity 行 > 64KB,整行塞不下)→ 无法可靠解析,跳过
|
|
203
264
|
const lineStartSearch = text.lastIndexOf('\n', idx)
|
|
204
|
-
if (lineStartSearch < 0 &&
|
|
265
|
+
if (lineStartSearch < 0 && resolvedSize > len) return undefined
|
|
205
266
|
const start = lineStartSearch < 0 ? 0 : lineStartSearch + 1
|
|
206
267
|
let end = text.indexOf('\n', idx)
|
|
207
268
|
if (end < 0) end = text.length
|
|
@@ -219,6 +280,10 @@ async function readTailIdentity(
|
|
|
219
280
|
return {
|
|
220
281
|
rootSessionId: data.rootSessionId,
|
|
221
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,
|
|
222
287
|
}
|
|
223
288
|
} catch {
|
|
224
289
|
return undefined
|
|
@@ -231,12 +296,28 @@ async function readTailIdentity(
|
|
|
231
296
|
// records manifest(孤儿 / cleanedUp 来源)
|
|
232
297
|
// ============================================================
|
|
233
298
|
|
|
234
|
-
interface RecordManifest {
|
|
299
|
+
export interface RecordManifest {
|
|
235
300
|
id: string
|
|
236
301
|
rootSessionId: string
|
|
237
302
|
agentName?: string
|
|
238
303
|
/** subagent session.jsonl 绝对路径(创建时写入;文件 GC 后路径仍残留) */
|
|
239
304
|
sessionFile: string
|
|
305
|
+
/** subagent 任务文本(探针 20/20 全有;旧 manifest 缺→undefined) */
|
|
306
|
+
task?: string
|
|
307
|
+
/** slug 标签(探针 20/20 全有;旧 manifest 缺→undefined) */
|
|
308
|
+
slug?: string
|
|
309
|
+
/** 模型 id(探针 20/20 全有;旧 manifest 缺→undefined) */
|
|
310
|
+
model?: string
|
|
311
|
+
/** 终态 completed/failed/running(探针 20/20 全有;旧 manifest 缺→undefined) */
|
|
312
|
+
status?: string
|
|
313
|
+
/**
|
|
314
|
+
* 直接父 subagent 的 record id(M3a 落盘镜像;depth=0 顶层 subagent 为 undefined)。
|
|
315
|
+
*
|
|
316
|
+
* session-reader 读侧镜像(IF2):manifest 主路径透出,旧 manifest 缺此字段(undefined)。
|
|
317
|
+
* isRecordManifest 校验不改(3 必填不变)。buildExecutionTree(core/execution-tree.ts)
|
|
318
|
+
* 据此建精确父子链,三级数据源优先级 ①(DM4)。
|
|
319
|
+
*/
|
|
320
|
+
parentRecordId?: string
|
|
240
321
|
}
|
|
241
322
|
|
|
242
323
|
function isRecordManifest(v: unknown): v is RecordManifest {
|
|
@@ -264,7 +345,7 @@ async function tryReadManifest(path: string): Promise<RecordManifest | undefined
|
|
|
264
345
|
}
|
|
265
346
|
}
|
|
266
347
|
|
|
267
|
-
async function listRecordManifests(agentDir: string): Promise<RecordManifest[]> {
|
|
348
|
+
export async function listRecordManifests(agentDir: string): Promise<RecordManifest[]> {
|
|
268
349
|
const root = join(agentDir, 'subagents')
|
|
269
350
|
const out: RecordManifest[] = []
|
|
270
351
|
async function walk(dir: string): Promise<void> {
|
|
@@ -289,142 +370,22 @@ async function listRecordManifests(agentDir: string): Promise<RecordManifest[]>
|
|
|
289
370
|
}
|
|
290
371
|
|
|
291
372
|
// ============================================================
|
|
292
|
-
//
|
|
373
|
+
// 文件名 sessionId 提取(find.ts + discovery/workflows.ts 共用 helper)
|
|
293
374
|
// ============================================================
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
* - NEW (v="wf-run-v1"):state.calls[],每项顶层 .sessionFile(258 文件 / 1590 sessionFile)
|
|
300
|
-
* - OLD (无 v):callCache[]=[{key,value}],value.sessionFile(112 文件 / 0 sessionFile,旧 pi 不持久化)
|
|
301
|
-
*/
|
|
302
|
-
function extractCallSessionFiles(snap: unknown): string[] {
|
|
303
|
-
const out: string[] = []
|
|
304
|
-
if (typeof snap !== 'object' || snap === null) return out
|
|
305
|
-
const s = snap as Record<string, unknown>
|
|
306
|
-
const isNew = s.v === 'wf-run-v1'
|
|
307
|
-
let callsRaw: unknown
|
|
308
|
-
if (isNew) {
|
|
309
|
-
const state = s.state
|
|
310
|
-
callsRaw = typeof state === 'object' && state !== null ? (state as Record<string, unknown>).calls : undefined
|
|
311
|
-
} else {
|
|
312
|
-
callsRaw = s.callCache
|
|
313
|
-
}
|
|
314
|
-
if (!Array.isArray(callsRaw)) return out
|
|
315
|
-
for (const c of callsRaw) {
|
|
316
|
-
if (typeof c !== 'object' || c === null) continue
|
|
317
|
-
const co = c as Record<string, unknown>
|
|
318
|
-
// NEW: call 本身;OLD: {key, value},取 value
|
|
319
|
-
const item: Record<string, unknown> = isNew
|
|
320
|
-
? co
|
|
321
|
-
: typeof co.value === 'object' && co.value !== null
|
|
322
|
-
? (co.value as Record<string, unknown>)
|
|
323
|
-
: co
|
|
324
|
-
const sf = item.sessionFile
|
|
325
|
-
if (typeof sf === 'string') {
|
|
326
|
-
out.push(sf)
|
|
327
|
-
continue
|
|
328
|
-
}
|
|
329
|
-
const result = item.result
|
|
330
|
-
const sf2 =
|
|
331
|
-
typeof result === 'object' && result !== null
|
|
332
|
-
? (result as Record<string, unknown>).sessionFile
|
|
333
|
-
: undefined
|
|
334
|
-
if (typeof sf2 === 'string') out.push(sf2)
|
|
335
|
-
}
|
|
336
|
-
return out
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* 读 wf-state 文件,从最后一个有效快照行提取 calls 的 sessionFile 路径。
|
|
341
|
-
*
|
|
342
|
-
* wf 文件每行是一个完整快照(多行 = 周期性追加,最后一行最新)。从尾向头找首个可解析行。
|
|
343
|
-
*/
|
|
344
|
-
async function readWorkflowCallSessionFiles(wfPath: string): Promise<string[]> {
|
|
345
|
-
let content: string
|
|
346
|
-
try {
|
|
347
|
-
content = await readFile(wfPath, 'utf8')
|
|
348
|
-
} catch {
|
|
349
|
-
return [] // wf 文件不存在/读失败 → 空 calls(不抛错)
|
|
350
|
-
}
|
|
351
|
-
const lines = content.split('\n')
|
|
352
|
-
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
353
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
354
|
-
if (lines[i].trim() === '') continue
|
|
355
|
-
try {
|
|
356
|
-
return extractCallSessionFiles(JSON.parse(lines[i]))
|
|
357
|
-
} catch {
|
|
358
|
-
continue // 坏行,试上一行
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
return []
|
|
362
|
-
}
|
|
375
|
+
//
|
|
376
|
+
// workflow-state 发现链路(resolveWorkflows / extractCallSessionFiles /
|
|
377
|
+
// readRunSnapshot / sessionRefFromPath)已迁至 discovery/workflows.ts(w5 架构归位,
|
|
378
|
+
// SSOT §6.3 workflow 与 subagent 发现解耦)。本函数被 find.ts + workflows.ts 共用,
|
|
379
|
+
// 故留原位 export。
|
|
363
380
|
|
|
364
381
|
/** 从文件名(<timestamp>_<sessionId>.jsonl)提取 sessionId;非 uuid 特征返回空串。 */
|
|
365
|
-
function extractSessionIdFromFilename(name: string): string {
|
|
382
|
+
export function extractSessionIdFromFilename(name: string): string {
|
|
366
383
|
const noExt = name.replace(/\.jsonl.*$/, '')
|
|
367
384
|
const idx = noExt.lastIndexOf('_')
|
|
368
385
|
const candidate = idx >= 0 ? noExt.slice(idx + 1) : noExt
|
|
369
386
|
return /^[0-9a-f-]{8,}$/i.test(candidate) ? candidate : ''
|
|
370
387
|
}
|
|
371
388
|
|
|
372
|
-
/**
|
|
373
|
-
* 从 sessionFile 绝对路径反查 SessionRef。优先用已扫描的 pathToRef(含真实 id/cwd/stat);
|
|
374
|
-
* 找不到(文件 GC/路径迁移)返回 fileName-only 最小 SessionRef(不抛错)。
|
|
375
|
-
*/
|
|
376
|
-
function sessionRefFromPath(path: string, pathToRef: Map<string, SessionRef>): SessionRef {
|
|
377
|
-
const existing = pathToRef.get(path)
|
|
378
|
-
if (existing) return existing
|
|
379
|
-
return {
|
|
380
|
-
sessionId: extractSessionIdFromFilename(basename(path)),
|
|
381
|
-
fileName: path,
|
|
382
|
-
mtime: 0,
|
|
383
|
-
sizeBytes: 0,
|
|
384
|
-
cwd: '',
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/**
|
|
389
|
-
* 读目标 session 文件全文,解析 workflow-state-link custom entries,构造 WorkflowRef[]。
|
|
390
|
-
* 同一 runId 的多个 link(workflow 多次更新产生)按 runId 去重,取最新 link(path 相同)。
|
|
391
|
-
*/
|
|
392
|
-
async function resolveWorkflows(
|
|
393
|
-
sessionId: string,
|
|
394
|
-
sessionIdToPath: Map<string, string>,
|
|
395
|
-
pathToRef: Map<string, SessionRef>,
|
|
396
|
-
): Promise<WorkflowRef[]> {
|
|
397
|
-
const targetPath = sessionIdToPath.get(sessionId)
|
|
398
|
-
if (!targetPath) return [] // 兜底(buildFamilyFromFs 已校验 sessionId 存在)
|
|
399
|
-
let content: string
|
|
400
|
-
try {
|
|
401
|
-
content = await readFile(targetPath, 'utf8')
|
|
402
|
-
} catch {
|
|
403
|
-
return []
|
|
404
|
-
}
|
|
405
|
-
const { entries } = parseSessionContent(content)
|
|
406
|
-
const linkByRunId = new Map<string, { runId: string; path: string }>()
|
|
407
|
-
for (const e of entries) {
|
|
408
|
-
if (e.customType !== 'workflow-state-link') continue
|
|
409
|
-
const data = e.data as Record<string, unknown> | undefined
|
|
410
|
-
const runId = data?.runId
|
|
411
|
-
const path = data?.path
|
|
412
|
-
if (typeof runId === 'string' && typeof path === 'string') {
|
|
413
|
-
linkByRunId.set(runId, { runId, path }) // 后写覆盖前写(取最新 link)
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
const workflows: WorkflowRef[] = []
|
|
417
|
-
for (const { runId, path } of linkByRunId.values()) {
|
|
418
|
-
const sessionFiles = await readWorkflowCallSessionFiles(path)
|
|
419
|
-
workflows.push({
|
|
420
|
-
runId,
|
|
421
|
-
stateFile: path,
|
|
422
|
-
calls: sessionFiles.map((sf) => sessionRefFromPath(sf, pathToRef)),
|
|
423
|
-
})
|
|
424
|
-
}
|
|
425
|
-
return workflows
|
|
426
|
-
}
|
|
427
|
-
|
|
428
389
|
// ============================================================
|
|
429
390
|
// enrich:补 M1 占位字段(fileName / subagent cwd)
|
|
430
391
|
// ============================================================
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { basename } from 'node:path'
|
|
3
|
+
import { parseSessionContent } from '../core/parser.js'
|
|
4
|
+
import type { SessionRef, WorkflowRef } from '../core/family.js'
|
|
5
|
+
import { extractSessionIdFromFilename } from './subagents.js'
|
|
6
|
+
|
|
7
|
+
// ============================================================
|
|
8
|
+
// workflow-state 发现链路(w5 从 subagents.ts 物理迁移,架构归位 SSOT §6.3)
|
|
9
|
+
// ============================================================
|
|
10
|
+
//
|
|
11
|
+
// 本文件持有 workflow run 的发现与 sessionFile 提取逻辑(IO 适配层):
|
|
12
|
+
// - resolveWorkflows:读目标 session 的 workflow-state-link custom entry → 每个 link 的
|
|
13
|
+
// wf-state 文件 → 提 calls 的 sessionFile → SessionRef[]。返回 WorkflowRef[](family.workflows 腿)。
|
|
14
|
+
// - readRunSnapshot:读 wf-state 文件尾向找首个可解析行,返回原始对象(unknown,格式收窄交 core 层)。
|
|
15
|
+
// - extractCallSessionFiles:从快照对象提 calls 的 sessionFile 绝对路径数组(NEW/OLD 双格式)。
|
|
16
|
+
// - sessionRefFromPath:sessionFile 路径 → SessionRef(命中 pathToRef 取完整,否则文件名提取最小 ref)。
|
|
17
|
+
//
|
|
18
|
+
// 分层约定(w5 TC-wf-core-pure-logic):IO 全在 discovery/,core/workflow.ts 的
|
|
19
|
+
// parseRunSnapshot/renderWorkflowOverview 是纯逻辑零 IO(喂 mock 可单测)。readRunSnapshot 返
|
|
20
|
+
// unknown 不收窄——NEW/OLD 双格式的类型化是 core 层 parseRunSnapshot 的职责(TC-wf-snapshot-version-union)。
|
|
21
|
+
//
|
|
22
|
+
// extractSessionIdFromFilename 留在 subagents.ts(find.ts + 导出契约测试直接消费),此处反向 import。
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 从 wf-state 快照对象提取 calls[].sessionFile(绝对路径数组)。
|
|
26
|
+
*
|
|
27
|
+
* 两种格式(探查确认,本机 371 个 wf 文件):
|
|
28
|
+
* - NEW (v="wf-run-v1" 或 "wf-run-v2",读取面形状一致):state.calls[],每项顶层
|
|
29
|
+
* .sessionFile(258 文件 / 1590 sessionFile)。v2 由 pi-subagent-workflow 8.x 一次性
|
|
30
|
+
* 生命周期收敛引入(status 两态、无 pausedAt),calls[].sessionFile/result 保留
|
|
31
|
+
* - OLD (无 v):callCache[]=[{key,value}],value.sessionFile(112 文件 / 0 sessionFile,旧 pi 不持久化)
|
|
32
|
+
*/
|
|
33
|
+
export function extractCallSessionFiles(snap: unknown): string[] {
|
|
34
|
+
const out: string[] = []
|
|
35
|
+
if (typeof snap !== 'object' || snap === null) return out
|
|
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
|
+
}
|
|
48
|
+
if (!Array.isArray(callsRaw)) return out
|
|
49
|
+
for (const c of callsRaw) {
|
|
50
|
+
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)
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 读 wf-state 文件,从尾向头找首个 trim 非空且 JSON.parse 成功的行,返回解析后的原始对象。
|
|
75
|
+
*
|
|
76
|
+
* 拆解自原 readWorkflowCallSessionFiles 的读行职责(w5 TC-wf-core-pure-logic)——后者=读行+
|
|
77
|
+
* 提 sessionFile 的胶水,迁移后由 readRunSnapshot(读行,返 unknown)+ extractCallSessionFiles
|
|
78
|
+
* (提 sessionFile)组合替代。返回类型 unknown:IO 层不假设格式,类型收窄交 core/workflow.ts
|
|
79
|
+
* 的 parseRunSnapshot(C-readrunsnapshot-unknown,TC-wf-snapshot-version-union)。
|
|
80
|
+
*
|
|
81
|
+
* 尾向回退策略(沿用原实现,ES-wf-snapshot-partial):wf 文件是 rewrite 覆盖模式,读撞 rewrite
|
|
82
|
+
* 中点时末行是半截 JSON(parse 失败)→ 试上一完整行;单行文件半行 → 全失败 → undefined。
|
|
83
|
+
* 文件不存在/读失败/全行不可解析 → undefined(不抛错,调用方 resolveWorkflows 据此 calls=[])。
|
|
84
|
+
*/
|
|
85
|
+
export async function readRunSnapshot(wfPath: string): Promise<unknown | undefined> {
|
|
86
|
+
let content: string
|
|
87
|
+
try {
|
|
88
|
+
content = await readFile(wfPath, 'utf8')
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined // wf 文件不存在/读失败 → undefined(不抛错)
|
|
91
|
+
}
|
|
92
|
+
const lines = content.split('\n')
|
|
93
|
+
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
94
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
95
|
+
if (lines[i].trim() === '') continue
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(lines[i])
|
|
98
|
+
} catch {
|
|
99
|
+
continue // 坏行(含 rewrite 中点半截 JSON),试上一行
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 从 sessionFile 绝对路径反查 SessionRef。优先用已扫描的 pathToRef(含真实 id/cwd/stat);
|
|
107
|
+
* 找不到(文件 GC/路径迁移)返回 fileName-only 最小 SessionRef(不抛错)。
|
|
108
|
+
*/
|
|
109
|
+
function sessionRefFromPath(path: string, pathToRef: Map<string, SessionRef>): SessionRef {
|
|
110
|
+
const existing = pathToRef.get(path)
|
|
111
|
+
if (existing) return existing
|
|
112
|
+
return {
|
|
113
|
+
sessionId: extractSessionIdFromFilename(basename(path)),
|
|
114
|
+
fileName: path,
|
|
115
|
+
mtime: 0,
|
|
116
|
+
sizeBytes: 0,
|
|
117
|
+
cwd: '',
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 读目标 session 文件全文,解析 workflow-state-link custom entries,构造 WorkflowRef[]。
|
|
123
|
+
* 同一 runId 的多个 link(workflow 多次更新产生)按 runId 去重,取最新 link(path 相同)。
|
|
124
|
+
*
|
|
125
|
+
* 内部用 readRunSnapshot + extractCallSessionFiles 组合替代原 readWorkflowCallSessionFiles
|
|
126
|
+
*(w5 迁移,行为等价)。**签名与返回值结构(WorkflowRef[]{runId,stateFile,calls:SessionRef[]})
|
|
127
|
+
* 完全不变**(C-resolveworkflows-signature,保 m1 已冻结交付的消费者)。
|
|
128
|
+
*/
|
|
129
|
+
export async function resolveWorkflows(
|
|
130
|
+
sessionId: string,
|
|
131
|
+
sessionIdToPath: Map<string, string>,
|
|
132
|
+
pathToRef: Map<string, SessionRef>,
|
|
133
|
+
): Promise<WorkflowRef[]> {
|
|
134
|
+
const targetPath = sessionIdToPath.get(sessionId)
|
|
135
|
+
if (!targetPath) return [] // 兜底(buildFamilyFromFs 已校验 sessionId 存在)
|
|
136
|
+
let content: string
|
|
137
|
+
try {
|
|
138
|
+
content = await readFile(targetPath, 'utf8')
|
|
139
|
+
} catch {
|
|
140
|
+
return []
|
|
141
|
+
}
|
|
142
|
+
const { entries } = parseSessionContent(content)
|
|
143
|
+
const linkByRunId = new Map<string, { runId: string; path: string }>()
|
|
144
|
+
for (const e of entries) {
|
|
145
|
+
if (e.customType !== 'workflow-state-link') continue
|
|
146
|
+
const data = e.data as Record<string, unknown> | undefined
|
|
147
|
+
const runId = data?.runId
|
|
148
|
+
const path = data?.path
|
|
149
|
+
if (typeof runId === 'string' && typeof path === 'string') {
|
|
150
|
+
linkByRunId.set(runId, { runId, path }) // 后写覆盖前写(取最新 link)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const workflows: WorkflowRef[] = []
|
|
154
|
+
for (const { runId, path } of linkByRunId.values()) {
|
|
155
|
+
const snap = await readRunSnapshot(path)
|
|
156
|
+
const sessionFiles = snap === undefined ? [] : extractCallSessionFiles(snap)
|
|
157
|
+
workflows.push({
|
|
158
|
+
runId,
|
|
159
|
+
stateFile: path,
|
|
160
|
+
calls: sessionFiles.map((sf) => sessionRefFromPath(sf, pathToRef)),
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
return workflows
|
|
164
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -21,16 +21,16 @@ 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'],
|
|
24
|
+
['find', 'family', 'outline', 'expand', 'detail', 'search', 'export', 'extract', 'workflow'],
|
|
25
25
|
{
|
|
26
26
|
description:
|
|
27
|
-
'Action to perform: find (locate session), family (fork/subagent/workflow relations), 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).',
|
|
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).',
|
|
28
28
|
},
|
|
29
29
|
),
|
|
30
30
|
session: Type.Optional(
|
|
31
31
|
Type.String({
|
|
32
32
|
description:
|
|
33
|
-
'Session id
|
|
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/workflow. # prefix auto-stripped.',
|
|
34
34
|
}),
|
|
35
35
|
),
|
|
36
36
|
query: Type.Optional(
|
|
@@ -83,6 +83,11 @@ const SessionReadSchema = Type.Object({
|
|
|
83
83
|
cwd: Type.Optional(
|
|
84
84
|
Type.String({ description: 'find: filter by cwd. Optional.' }),
|
|
85
85
|
),
|
|
86
|
+
source: Type.Optional(
|
|
87
|
+
StringEnum(['main', 'subagent'], {
|
|
88
|
+
description: 'find action: filter by source. "main" = sessions/, "subagent" = subagents/. Default both (merged).',
|
|
89
|
+
}),
|
|
90
|
+
),
|
|
86
91
|
limit: Type.Optional(
|
|
87
92
|
Type.Number({ description: 'find/search: max results. Default 20.' }),
|
|
88
93
|
),
|
|
@@ -99,6 +104,18 @@ const SessionReadSchema = Type.Object({
|
|
|
99
104
|
description: 'extract action: filter commands/tool-results by tool name (e.g. "bash").',
|
|
100
105
|
}),
|
|
101
106
|
),
|
|
107
|
+
runId: Type.Optional(
|
|
108
|
+
Type.String({
|
|
109
|
+
description:
|
|
110
|
+
'workflow action: focus a single run by runId (disambiguate multiple runs). Omit to see all run overviews.',
|
|
111
|
+
}),
|
|
112
|
+
),
|
|
113
|
+
recursive: Type.Optional(
|
|
114
|
+
Type.Boolean({
|
|
115
|
+
description:
|
|
116
|
+
'family action: return nested execution tree (arbitrary-depth subagent↔workflow-call nesting, precise parentRecordId chain with flat-fallback for legacy records). Default false (flat family).',
|
|
117
|
+
}),
|
|
118
|
+
),
|
|
102
119
|
})
|
|
103
120
|
|
|
104
121
|
// ---- guidelines(注入 LLM,design §3.4)----
|
|
@@ -109,12 +126,13 @@ const guidelines = [
|
|
|
109
126
|
'outline before detail. Never read raw .jsonl files—use this tool.',
|
|
110
127
|
'family traces fork parents/children, subagent sessions, and workflow runs.',
|
|
111
128
|
'extract what=<type> to pull user messages / commands / files / commits / tool results across turns (optional tool= filter for commands/tool-results).',
|
|
129
|
+
"workflow action to see workflow run overviews (status/budget/steps). Each step's call sessionId can jump to outline/detail for deep reading.",
|
|
112
130
|
'Errors carry a 👉 recovery hint—follow it to retry in one step.',
|
|
113
131
|
]
|
|
114
132
|
|
|
115
133
|
// ---- 工具 description(design §3.4,照搬措辞)----
|
|
116
134
|
|
|
117
|
-
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.
|
|
135
|
+
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).`
|
|
118
136
|
|
|
119
137
|
/**
|
|
120
138
|
* 已注册过 TUI provider/command 的 pi 实例集合。
|