@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.
- package/package.json +1 -1
- package/src/__tests__/find.test.ts +15 -1
- package/src/__tests__/render.test.ts +2 -2
- package/src/__tests__/tool-handler.test.ts +148 -11
- package/src/__tests__/toolcall.test.ts +13 -0
- package/src/__tests__/workflow.test.ts +59 -1
- package/src/core/execution-tree.ts +0 -1
- package/src/core/family.ts +45 -20
- package/src/core/parser.ts +28 -17
- package/src/core/render.ts +60 -35
- 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 +196 -119
- package/src/discovery/workflows.ts +44 -29
- package/src/index.ts +9 -9
- package/src/tool-handler.ts +115 -67
- package/src/tui/hash-provider.ts +0 -3
- package/src/tui/session-command.ts +1 -1
- package/vitest.config.ts +2 -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
|
/**
|
|
@@ -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 文件绝对路径)→
|
|
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 =
|
|
36
|
-
for (const m of manifests) manifestBySessionFile.set(m.sessionFile, m)
|
|
36
|
+
const manifestBySessionFile = indexManifestsBySessionFile(manifests)
|
|
37
37
|
|
|
38
|
-
// ---- 1. main
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
82
|
+
sessionIdToPath: Map<string, string>
|
|
44
83
|
/** path → 完整 SessionRef(含真实 cwd/fileName/mtime/size),供 enrich + workflow calls 反查 */
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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:
|
|
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:
|
|
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 (~
|
|
127
|
-
'find first to locate a session by uuid fragment or name. TUI #references are
|
|
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, ~
|
|
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 实例集合。
|