@zhushanwen/pi-session-reader 0.3.0 → 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__/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 +188 -106
- package/src/discovery/workflows.ts +44 -29
- package/src/tool-handler.ts +103 -48
- package/vitest.config.ts +2 -0
package/src/discovery/find.ts
CHANGED
|
@@ -278,26 +278,38 @@ async function matchSubagentMetadata(
|
|
|
278
278
|
)
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
281
|
+
// ============================================================
|
|
282
|
+
// findSessions 编排 helpers(按处理阶段拆分:收集 → 匹配 → 排序截断 → 预览补读)
|
|
283
|
+
// ============================================================
|
|
284
|
+
|
|
285
|
+
/** meta + header → 候选构造(ref.cwd 归一化空串 + parentSession 按需挂载)。 */
|
|
286
|
+
function buildCandidate(
|
|
287
|
+
meta: SessionFileMeta,
|
|
288
|
+
header: SessionHeader,
|
|
289
|
+
src: SessionSource,
|
|
290
|
+
): Candidate {
|
|
291
|
+
const ref: SessionRef = {
|
|
292
|
+
sessionId: header.id,
|
|
293
|
+
// 完整绝对路径(与 parentSession 同构,便于 family 按 includes(sid) 反查)
|
|
294
|
+
fileName: meta.path,
|
|
295
|
+
mtime: meta.mtime,
|
|
296
|
+
sizeBytes: meta.size,
|
|
297
|
+
cwd: header.cwd ?? '',
|
|
298
|
+
}
|
|
299
|
+
if (header.parentSession) ref.parentSession = header.parentSession
|
|
300
|
+
return { meta, ref, source: src }
|
|
301
|
+
}
|
|
296
302
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
303
|
+
/** 步骤 0+1:按来源收集文件列表(source 过滤在文件列表层)+ 逐个首行扫描建候选(cwd 过滤在此应用)。 */
|
|
304
|
+
async function collectCandidates(
|
|
305
|
+
agentDir: string,
|
|
306
|
+
sourceFilter: SessionSource | undefined,
|
|
307
|
+
cwdFilter: string | undefined,
|
|
308
|
+
): Promise<Candidate[]> {
|
|
309
|
+
// source 过滤在文件列表层:source==='main' 只扫 sessions/、'subagent' 只扫 subagents/、
|
|
310
|
+
// undefined 两者合并——决策二性能意图:不扫被过滤目录。两路目录扫描相互独立 →
|
|
311
|
+
// Promise.allSettled(AGENTS.md:独立请求用 allSettled),任一目录不存在(roots.ts
|
|
312
|
+
// 静默返回空数组)不影响另一路。
|
|
301
313
|
const sources: SessionSource[] =
|
|
302
314
|
sourceFilter === undefined ? ['main', 'subagent'] : [sourceFilter]
|
|
303
315
|
const listResults = await Promise.allSettled(
|
|
@@ -307,77 +319,84 @@ export async function findSessions(
|
|
|
307
319
|
})),
|
|
308
320
|
)
|
|
309
321
|
|
|
310
|
-
//
|
|
322
|
+
// 首行扫描建候选 SessionRef(按来源打 source 标记)
|
|
311
323
|
const candidates: Candidate[] = []
|
|
312
324
|
for (const r of listResults) {
|
|
313
325
|
if (r.status !== 'fulfilled') continue
|
|
314
326
|
const { src, files } = r.value
|
|
315
327
|
for (const meta of files) {
|
|
316
|
-
const
|
|
317
|
-
const header = parseHeader(headerLine)
|
|
328
|
+
const header = parseHeader(await readFirstLine(meta.path))
|
|
318
329
|
if (!header) continue // 非 session 文件/坏 header → 跳过
|
|
319
330
|
if (cwdFilter !== undefined && (header.cwd ?? '') !== cwdFilter) continue
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
331
|
+
candidates.push(buildCandidate(meta, header, src))
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return candidates
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 步骤 2 关键词层(U5 扩展):manifest 元数据(+ P-fallback identity 回退)与首消息预览并列匹配,命中任一即入选。 */
|
|
338
|
+
async function matchByKeywords(
|
|
339
|
+
candidates: Candidate[],
|
|
340
|
+
query: string,
|
|
341
|
+
agentDir: string,
|
|
342
|
+
): Promise<Matched[]> {
|
|
343
|
+
const manifestIndex = await buildManifestIndex(
|
|
344
|
+
agentDir,
|
|
345
|
+
candidates.some((c) => c.source === 'subagent'),
|
|
346
|
+
)
|
|
347
|
+
const keywordHits: Matched[] = []
|
|
348
|
+
for (const c of candidates) {
|
|
349
|
+
// - subagent 候选:先查 manifest 索引(命中走元数据子串,未命中 P-fallback 读尾行 identity);
|
|
350
|
+
// 元数据命中即入选(preview 留空,第 5 步补读首消息),未命中仍可走首消息 fallback
|
|
351
|
+
// - main / subagent 元数据未命中:首消息预览 query 子串匹配(m0 现状路径不变)
|
|
352
|
+
if (c.source === 'subagent' && (await matchSubagentMetadata(c, query, manifestIndex))) {
|
|
353
|
+
keywordHits.push({ ...c })
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
const text = await readFirstUserMessageText(c.meta.path)
|
|
357
|
+
if (text && text.includes(query)) {
|
|
358
|
+
keywordHits.push({ ...c, preview: text.slice(0, PREVIEW_MAX) })
|
|
330
359
|
}
|
|
331
360
|
}
|
|
361
|
+
return keywordHits
|
|
362
|
+
}
|
|
332
363
|
|
|
333
|
-
|
|
334
|
-
|
|
364
|
+
/** 步骤 2 三路匹配:recent / uuid 片段(sessionId 或文件路径含 query)/ 名称关键词+U5 元数据。 */
|
|
365
|
+
async function matchCandidates(
|
|
366
|
+
candidates: Candidate[],
|
|
367
|
+
query: string,
|
|
368
|
+
agentDir: string,
|
|
369
|
+
): Promise<Matched[]> {
|
|
335
370
|
if (query === 'recent') {
|
|
336
371
|
// recent:不经片段匹配,全部候选按 mtime 倒序后截 limit
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
// 关键词层(U5 扩展):subagent manifest 元数据 task/slug/agentName(+ P-fallback identity
|
|
350
|
-
// 回退)与 main/subagent 首消息预览并列匹配,命中任一即入选(TC-find-match-priority)。
|
|
351
|
-
// - subagent 候选:先查 manifest 索引(命中走元数据子串,未命中 P-fallback 读尾行 identity);
|
|
352
|
-
// 元数据命中即入选(preview 留空,第 5 步补读首消息),未命中仍可走首消息 fallback
|
|
353
|
-
// - main / subagent 元数据未命中:首消息预览 query 子串匹配(m0 现状路径不变)
|
|
354
|
-
const manifestIndex = await buildManifestIndex(
|
|
355
|
-
agentDir,
|
|
356
|
-
candidates.some((c) => c.source === 'subagent'),
|
|
357
|
-
)
|
|
358
|
-
const keywordHits: Matched[] = []
|
|
359
|
-
for (const c of candidates) {
|
|
360
|
-
if (c.source === 'subagent' && (await matchSubagentMetadata(c, query, manifestIndex))) {
|
|
361
|
-
keywordHits.push({ ...c })
|
|
362
|
-
continue
|
|
363
|
-
}
|
|
364
|
-
const text = await readFirstUserMessageText(c.meta.path)
|
|
365
|
-
if (text && text.includes(query)) {
|
|
366
|
-
keywordHits.push({ ...c, preview: text.slice(0, PREVIEW_MAX) })
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
matched = keywordHits
|
|
370
|
-
}
|
|
372
|
+
return candidates.map((c) => ({ ...c }))
|
|
373
|
+
}
|
|
374
|
+
// 先 uuid 片段匹配(sessionId 或文件路径含 query)——cheap,已有 header
|
|
375
|
+
const uuidHits = candidates.filter(
|
|
376
|
+
(c) => c.ref.sessionId.includes(query) || c.meta.path.includes(query),
|
|
377
|
+
)
|
|
378
|
+
if (uuidHits.length > 0) {
|
|
379
|
+
return uuidHits.map((c) => ({ ...c }))
|
|
380
|
+
}
|
|
381
|
+
if (looksLikeUuidFragment(query)) {
|
|
382
|
+
// query 像 uuid 片段但无匹配 → uuid 写错的可能性高,不对全部候选深读首消息
|
|
383
|
+
return []
|
|
371
384
|
}
|
|
385
|
+
return matchByKeywords(candidates, query, agentDir)
|
|
386
|
+
}
|
|
372
387
|
|
|
373
|
-
|
|
388
|
+
/** 步骤 3+4:mtime 倒序 + limit 截断(truncated 标记是否截断)。 */
|
|
389
|
+
function sortByMtimeAndTruncate(
|
|
390
|
+
matched: Matched[],
|
|
391
|
+
limit: number,
|
|
392
|
+
): { items: Matched[]; truncated: boolean } {
|
|
374
393
|
matched.sort((a, b) => b.ref.mtime - a.ref.mtime)
|
|
375
|
-
|
|
376
|
-
// 4. 截断
|
|
377
394
|
const truncated = matched.length > limit
|
|
378
|
-
|
|
395
|
+
return { items: truncated ? matched.slice(0, limit) : matched, truncated }
|
|
396
|
+
}
|
|
379
397
|
|
|
380
|
-
|
|
398
|
+
/** 步骤 5:填 firstMessagePreview(recent/uuid 路径未读,对最终 limit 个补读——最多 limit 个 IO)。 */
|
|
399
|
+
async function fillFirstMessagePreviews(sliced: Matched[]): Promise<MatchedSession[]> {
|
|
381
400
|
const result: MatchedSession[] = []
|
|
382
401
|
for (const m of sliced) {
|
|
383
402
|
const out: MatchedSession = { ...m.ref, source: m.source }
|
|
@@ -389,6 +408,33 @@ export async function findSessions(
|
|
|
389
408
|
}
|
|
390
409
|
result.push(out)
|
|
391
410
|
}
|
|
411
|
+
return result
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* 按 query 找 session(接口冻结,design §3.4 find action)。
|
|
416
|
+
*
|
|
417
|
+
* 返回按 mtime 倒序,limit 截断(默认 20),truncated 标记是否截断。
|
|
418
|
+
* cwd 过滤:opts.cwd 提供时只留 header.cwd === opts.cwd 的(在匹配前过滤,减少 fallback 深读量)。
|
|
419
|
+
* 匹配为空 → `{ matches: [], truncated: false }`(F1 恢复指引在 M3 tool-adapter 层)。
|
|
420
|
+
*/
|
|
421
|
+
export async function findSessions(
|
|
422
|
+
query: string,
|
|
423
|
+
agentDir: string,
|
|
424
|
+
opts?: { cwd?: string; limit?: number; source?: SessionSource },
|
|
425
|
+
): Promise<{ matches: MatchedSession[]; truncated: boolean }> {
|
|
426
|
+
const limit = opts?.limit ?? DEFAULT_LIMIT
|
|
427
|
+
const cwdFilter = opts?.cwd
|
|
428
|
+
const sourceFilter = opts?.source
|
|
429
|
+
|
|
430
|
+
// 0+1. 按来源收集文件列表 → 首行扫描建候选
|
|
431
|
+
const candidates = await collectCandidates(agentDir, sourceFilter, cwdFilter)
|
|
432
|
+
// 2. 三路匹配(recent / uuid 片段 / 名称关键词+U5 元数据)
|
|
433
|
+
const matched = await matchCandidates(candidates, query, agentDir)
|
|
434
|
+
// 3+4. mtime 倒序 + limit 截断
|
|
435
|
+
const { items, truncated } = sortByMtimeAndTruncate(matched, limit)
|
|
436
|
+
// 5. 填 firstMessagePreview(对最终 limit 个补读)
|
|
437
|
+
const matches = await fillFirstMessagePreviews(items)
|
|
392
438
|
|
|
393
|
-
return { matches
|
|
439
|
+
return { matches, truncated }
|
|
394
440
|
}
|
|
@@ -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
|
/**
|
|
@@ -33,26 +33,77 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
|
|
|
33
33
|
// manifest 主路径:alive 文件的 meta.path 命中索引 → 透 task/slug/model/status/sessionFile 全字段。
|
|
34
34
|
// 索引未命中(场景 A:嵌套 subagent 的 manifest 不在当前 agentDir,11.5%)走 P-fallback 回退 identity。
|
|
35
35
|
const manifests = await listRecordManifests(agentDir)
|
|
36
|
-
const manifestBySessionFile =
|
|
37
|
-
for (const m of manifests) manifestBySessionFile.set(m.sessionFile, m)
|
|
36
|
+
const manifestBySessionFile = indexManifestsBySessionFile(manifests)
|
|
38
37
|
|
|
39
|
-
// ---- 1. main
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
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 }>
|
|
43
81
|
/** sessionId → 真实文件路径,供 workflows 找目标文件 + enrich 补 fileName */
|
|
44
|
-
|
|
82
|
+
sessionIdToPath: Map<string, string>
|
|
45
83
|
/** path → 完整 SessionRef(含真实 cwd/fileName/mtime/size),供 enrich + workflow calls 反查 */
|
|
46
|
-
|
|
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
|
+
}
|
|
47
95
|
|
|
96
|
+
/** 步骤 1:main sessions —— 首行 header → headers + fileStats + 路径反查 */
|
|
97
|
+
async function collectMainSessions(agentDir: string, scan: FamilyFsScan): Promise<void> {
|
|
98
|
+
const mainMetas = await listMainSessions(agentDir)
|
|
48
99
|
for (const meta of mainMetas) {
|
|
49
100
|
const h = parseHeaderLine(await readFirstLine(meta.path))
|
|
50
101
|
if (!h) continue // 非 session/坏 header → 跳过(不入 byId)
|
|
51
102
|
const entry: Entry = { type: 'session', id: h.id, parentId: null, cwd: h.cwd ?? '' }
|
|
52
103
|
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)
|
|
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)
|
|
56
107
|
const ref: SessionRef = {
|
|
57
108
|
sessionId: h.id,
|
|
58
109
|
fileName: meta.path,
|
|
@@ -61,17 +112,47 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
|
|
|
61
112
|
cwd: h.cwd ?? '',
|
|
62
113
|
}
|
|
63
114
|
if (h.parentSession) ref.parentSession = h.parentSession
|
|
64
|
-
pathToRef.set(meta.path, ref)
|
|
115
|
+
scan.pathToRef.set(meta.path, ref)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
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,
|
|
65
144
|
}
|
|
145
|
+
}
|
|
66
146
|
|
|
67
|
-
|
|
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> {
|
|
68
153
|
// U4 数据流:manifest 命中透全字段;未命中 P-fallback 读尾行 identity 取 task/slug/agent
|
|
69
154
|
//(model/status 不可回退,留 undefined);无 manifest 无 identity(运行中/异常)跳过。
|
|
70
155
|
const subMetas = await listSubagentSessions(agentDir)
|
|
71
|
-
const identities: Entry[] = []
|
|
72
|
-
/** 已扫描到的 subagent 文件路径集合,供 manifest 孤儿判定(alive 则跳过 manifest) */
|
|
73
|
-
const aliveSubPaths = new Set<string>()
|
|
74
|
-
|
|
75
156
|
for (const meta of subMetas) {
|
|
76
157
|
const h = parseHeaderLine(await readFirstLine(meta.path))
|
|
77
158
|
if (!h) continue // 非 session/坏 header → 无真实 session id,无法 id 修正,跳过
|
|
@@ -79,46 +160,30 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
|
|
|
79
160
|
// header 可解析即视为 alive(MF-3):identity 在文件尾行、完成时才写入,运行中的 subagent
|
|
80
161
|
// 无 identity。若此处跳过,步骤 3 会把活文件(含其 manifest)当孤儿收编 → cleanedUp=true,
|
|
81
162
|
// family 把活着的 subagent 显示成 [已清理],真实 sessionId 永远无法关联。
|
|
82
|
-
aliveSubPaths.add(meta.path)
|
|
163
|
+
scan.aliveSubPaths.add(meta.path)
|
|
83
164
|
|
|
84
165
|
// identity data 组装:manifest 主路径或 P-fallback identity 回退,二选一
|
|
85
166
|
const manifest = manifestBySessionFile.get(meta.path)
|
|
86
167
|
let data: Record<string, unknown>
|
|
87
168
|
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
|
-
}
|
|
169
|
+
data = manifestIdentityData(manifest)
|
|
98
170
|
} else {
|
|
99
171
|
// P-fallback(ES-p-fallback-no-manifest):读尾行 identity 回退取 task/slug/agent
|
|
100
172
|
const ident = await readTailIdentity(meta.path, meta.size)
|
|
101
173
|
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
|
-
}
|
|
174
|
+
data = fallbackIdentityData(meta, ident)
|
|
110
175
|
}
|
|
111
176
|
// id 修正:entry.id 用真实 header.id 替换 sa-xxx 占位
|
|
112
|
-
identities.push({
|
|
177
|
+
scan.identities.push({
|
|
113
178
|
type: 'custom',
|
|
114
179
|
id: realId,
|
|
115
180
|
parentId: null,
|
|
116
181
|
customType: 'subagent-identity',
|
|
117
182
|
data,
|
|
118
183
|
})
|
|
119
|
-
fileStats.set(realId, { mtime: meta.mtime, size: meta.size })
|
|
120
|
-
sessionIdToPath.set(realId, meta.path)
|
|
121
|
-
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, {
|
|
122
187
|
sessionId: realId,
|
|
123
188
|
fileName: meta.path,
|
|
124
189
|
mtime: meta.mtime,
|
|
@@ -126,15 +191,19 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
|
|
|
126
191
|
cwd: h.cwd ?? '',
|
|
127
192
|
})
|
|
128
193
|
}
|
|
194
|
+
}
|
|
129
195
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 {
|
|
135
204
|
for (const m of manifests) {
|
|
136
|
-
if (aliveSubPaths.has(m.sessionFile)) continue
|
|
137
|
-
identities.push({
|
|
205
|
+
if (scan.aliveSubPaths.has(m.sessionFile)) continue
|
|
206
|
+
scan.identities.push({
|
|
138
207
|
type: 'custom',
|
|
139
208
|
id: m.id,
|
|
140
209
|
parentId: null,
|
|
@@ -151,23 +220,6 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
|
|
|
151
220
|
},
|
|
152
221
|
})
|
|
153
222
|
}
|
|
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
223
|
}
|
|
172
224
|
|
|
173
225
|
// ============================================================
|
|
@@ -221,6 +273,15 @@ function parseHeaderLine(line: string | undefined): SessionHeader | null {
|
|
|
221
273
|
return h
|
|
222
274
|
}
|
|
223
275
|
|
|
276
|
+
/** readTailIdentity 的成功返回形状(rootSessionId 必有,其余存在才带) */
|
|
277
|
+
type TailIdentity = {
|
|
278
|
+
rootSessionId: string
|
|
279
|
+
slug: string
|
|
280
|
+
task?: string
|
|
281
|
+
agent?: string
|
|
282
|
+
parentRecordId?: string
|
|
283
|
+
}
|
|
284
|
+
|
|
224
285
|
/**
|
|
225
286
|
* 读 subagent 文件尾部(最后 64KB)找 subagent-identity entry,返回 rootSessionId + slug + task + agent。
|
|
226
287
|
*
|
|
@@ -238,54 +299,36 @@ function parseHeaderLine(line: string | undefined): SessionHeader | null {
|
|
|
238
299
|
export async function readTailIdentity(
|
|
239
300
|
path: string,
|
|
240
301
|
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
|
-
}
|
|
302
|
+
): Promise<TailIdentity | undefined> {
|
|
303
|
+
const resolvedSize = await resolveTailSize(path, size)
|
|
304
|
+
if (resolvedSize === undefined) return undefined // 文件不存在/读失败 → undefined
|
|
254
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> {
|
|
255
325
|
let fh: FileHandle | undefined
|
|
256
326
|
try {
|
|
257
327
|
fh = await open(path, 'r')
|
|
258
328
|
const len = Math.min(TAIL_READ_BYTES, resolvedSize)
|
|
259
329
|
const buf = Buffer.alloc(len)
|
|
260
330
|
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
|
-
}
|
|
331
|
+
return buf.toString('utf8')
|
|
289
332
|
} catch {
|
|
290
333
|
return undefined
|
|
291
334
|
} finally {
|
|
@@ -293,6 +336,45 @@ export async function readTailIdentity(
|
|
|
293
336
|
}
|
|
294
337
|
}
|
|
295
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
|
+
|
|
296
378
|
// ============================================================
|
|
297
379
|
// records manifest(孤儿 / cleanedUp 来源)
|
|
298
380
|
// ============================================================
|