@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.
@@ -278,26 +278,38 @@ async function matchSubagentMetadata(
278
278
  )
279
279
  }
280
280
 
281
- /**
282
- * 按 query 找 session(接口冻结,design §3.4 find action)。
283
- *
284
- * 返回按 mtime 倒序,limit 截断(默认 20),truncated 标记是否截断。
285
- * cwd 过滤:opts.cwd 提供时只留 header.cwd === opts.cwd 的(在匹配前过滤,减少 fallback 深读量)。
286
- * 匹配为空 → `{ matches: [], truncated: false }`(F1 恢复指引在 M3 tool-adapter 层)。
287
- */
288
- export async function findSessions(
289
- query: string,
290
- agentDir: string,
291
- opts?: { cwd?: string; limit?: number; source?: SessionSource },
292
- ): Promise<{ matches: MatchedSession[]; truncated: boolean }> {
293
- const limit = opts?.limit ?? DEFAULT_LIMIT
294
- const cwdFilter = opts?.cwd
295
- const sourceFilter = opts?.source
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
- // 0. 按来源收集文件列表(source 过滤在文件列表层:source==='main' 只扫 sessions/、
298
- // 'subagent' 只扫 subagents/、undefined 两者合并——决策二性能意图:不扫被过滤目录)。
299
- // 两路目录扫描相互独立 → Promise.allSettled(AGENTS.md:独立请求用 allSettled),
300
- // 任一目录不存在(roots.ts 静默返回空数组)不影响另一路。
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
- // 1. 首行扫描建候选 SessionRef(cwd 过滤在此应用;按来源打 source 标记)
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 headerLine = await readFirstLine(meta.path)
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
- const ref: SessionRef = {
321
- sessionId: header.id,
322
- // 完整绝对路径(与 parentSession 同构,便于 family 按 includes(sid) 反查)
323
- fileName: meta.path,
324
- mtime: meta.mtime,
325
- sizeBytes: meta.size,
326
- cwd: header.cwd ?? '',
327
- }
328
- if (header.parentSession) ref.parentSession = header.parentSession
329
- candidates.push({ meta, ref, source: src })
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
- // 2. 匹配
334
- let matched: Matched[]
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
- matched = candidates.map((c) => ({ ...c }))
338
- } else {
339
- // 先 uuid 片段匹配(sessionId 或文件路径含 query)——cheap,已有 header
340
- const uuidHits = candidates.filter(
341
- (c) => c.ref.sessionId.includes(query) || c.meta.path.includes(query),
342
- )
343
- if (uuidHits.length > 0) {
344
- matched = uuidHits.map((c) => ({ ...c }))
345
- } else if (looksLikeUuidFragment(query)) {
346
- // query 像 uuid 片段但无匹配 → uuid 写错的可能性高,不对全部候选深读首消息
347
- matched = []
348
- } else {
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
- // 3. mtime 倒序
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
- const sliced = truncated ? matched.slice(0, limit) : matched
395
+ return { items: truncated ? matched.slice(0, limit) : matched, truncated }
396
+ }
379
397
 
380
- // 5. 填 firstMessagePreview(recent/uuid 路径未读,这里对最终 limit 个补读——最多 limit 个 IO)
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: result, truncated }
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 = new Map<string, RecordManifest>()
37
- for (const m of manifests) manifestBySessionFile.set(m.sessionFile, m)
36
+ const manifestBySessionFile = indexManifestsBySessionFile(manifests)
38
37
 
39
- // ---- 1. main sessions:首行 header → byId/childrenOf 素材 + fileStats + 路径反查 ----
40
- const mainMetas = await listMainSessions(agentDir)
41
- const headers: Entry[] = []
42
- const fileStats = new Map<string, { mtime: number; size: number }>()
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
- const sessionIdToPath = new Map<string, string>()
82
+ sessionIdToPath: Map<string, string>
45
83
  /** path → 完整 SessionRef(含真实 cwd/fileName/mtime/size),供 enrich + workflow calls 反查 */
46
- const pathToRef = new Map<string, SessionRef>()
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
- // ---- 2. subagent sessions:首行 header(真实 id)+ manifest 主/identity 回退 → 富字段 identity ----
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
- // manifest 主(TC-u4-manifest-enrich):透 task/slug/model/status/sessionFile 全字段
89
- data = {
90
- rootSessionId: manifest.rootSessionId,
91
- slug: manifest.slug ?? '', // slug 兼容旧 manifest(缺→空串兜底,m0 契约)
92
- task: manifest.task,
93
- agent: manifest.agentName, // 同语义异名:manifest.agentName ↔ identity.data.agent
94
- model: manifest.model,
95
- status: manifest.status,
96
- sessionFile: manifest.sessionFile,
97
- }
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
- // ---- 3. records manifest → 孤儿(cleanedUp,ES-orphan-manifest)----
131
- // manifest 在 subagent 创建时写入,.jsonl 被 GC 后仍残留。alive 的(sessionFile 已在步骤 2
132
- // 扫到)跳过;未扫到的 = 文件已 GC → 孤儿。用 manifest 完整富字段填 SubagentRef,
133
- // sessionFile 保留 manifest 的 GC 路径(不置空,供 LLM 知晓原位置),cleanedUp 由
134
- // buildFamilyIndex 的 !fileStats.has(ident.id) 判 true(ident.id=manifest.id 不在 fileStats)。
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
- | { rootSessionId: string; slug: string; task?: string; agent?: string; parentRecordId?: string }
243
- | undefined
244
- > {
245
- // size 未传时内部 stat 获取(execution-tree.ts 复用时无 size)
246
- let resolvedSize = size
247
- if (resolvedSize === undefined) {
248
- try {
249
- resolvedSize = (await stat(path)).size
250
- } catch {
251
- return undefined // 文件不存在/读失败 → undefined
252
- }
253
- }
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
- const text = buf.toString('utf8')
262
- const idx = text.lastIndexOf('subagent-identity')
263
- if (idx < 0) return undefined
264
- // 行首若在读窗口外(identity 行 > 64KB,整行塞不下)→ 无法可靠解析,跳过
265
- const lineStartSearch = text.lastIndexOf('\n', idx)
266
- if (lineStartSearch < 0 && resolvedSize > len) return undefined
267
- const start = lineStartSearch < 0 ? 0 : lineStartSearch + 1
268
- let end = text.indexOf('\n', idx)
269
- if (end < 0) end = text.length
270
- const line = text.slice(start, end)
271
- let raw: unknown
272
- try {
273
- raw = JSON.parse(line)
274
- } catch {
275
- return undefined
276
- }
277
- const data = (raw as Record<string, unknown> | undefined)?.data as
278
- | Record<string, unknown>
279
- | undefined
280
- if (!data || typeof data.rootSessionId !== 'string') return undefined
281
- return {
282
- rootSessionId: data.rootSessionId,
283
- slug: typeof data.slug === 'string' ? data.slug : '',
284
- task: typeof data.task === 'string' ? data.task : undefined,
285
- agent: typeof data.agent === 'string' ? data.agent : undefined,
286
- parentRecordId:
287
- typeof data.parentRecordId === 'string' ? data.parentRecordId : undefined,
288
- }
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
  // ============================================================