@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.
- package/package.json +3 -2
- package/src/__tests__/cross-package-subagent-core.test.ts +321 -0
- package/src/__tests__/execution-tree.test.ts +1 -1
- package/src/__tests__/find.test.ts +18 -4
- package/src/__tests__/hash-provider.test.ts +2 -2
- package/src/__tests__/result.test.ts +341 -0
- package/src/__tests__/roots.test.ts +2 -2
- package/src/__tests__/session-command.test.ts +2 -2
- package/src/__tests__/subagents.test.ts +3 -3
- package/src/__tests__/tool-handler.test.ts +146 -9
- package/src/__tests__/toolcall.test.ts +13 -0
- package/src/__tests__/workflow.test.ts +59 -1
- package/src/core/family.ts +43 -17
- package/src/core/parser.ts +28 -17
- package/src/core/render.ts +59 -34
- package/src/core/toolcall.ts +105 -69
- package/src/core/workflow.ts +139 -135
- package/src/discovery/find.ts +120 -74
- package/src/discovery/subagents.ts +194 -110
- package/src/discovery/workflows.ts +44 -29
- package/src/index.ts +20 -5
- package/src/result-action.ts +244 -0
- package/src/tool-handler.ts +190 -101
- package/vitest.config.ts +17 -0
|
@@ -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
|
|
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 =
|
|
37
|
-
for (const m of manifests) manifestBySessionFile.set(m.sessionFile, m)
|
|
37
|
+
const manifestBySessionFile = indexManifestsBySessionFile(manifests)
|
|
38
38
|
|
|
39
|
-
// ---- 1. main
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
83
|
+
sessionIdToPath: Map<string, string>
|
|
45
84
|
/** path → 完整 SessionRef(含真实 cwd/fileName/mtime/size),供 enrich + workflow calls 反查 */
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
347
|
-
*
|
|
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
|
|
38
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
[
|
|
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({
|
|
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.
|
|
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 实例集合。
|