@zhushanwen/pi-session-reader 0.11.0 → 0.13.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.
Files changed (42) hide show
  1. package/package.json +5 -5
  2. package/src/__tests__/archive-ledger.test.ts +447 -0
  3. package/src/__tests__/cross-package-subagent-core.test.ts +34 -25
  4. package/src/__tests__/entry-anchor.test.ts +3 -3
  5. package/src/__tests__/execution-tree.test.ts +85 -86
  6. package/src/__tests__/family-zcode.test.ts +24 -14
  7. package/src/__tests__/family.test.ts +4 -4
  8. package/src/__tests__/find.test.ts +50 -5
  9. package/src/__tests__/index.test.ts +3 -3
  10. package/src/__tests__/rebuild-manifest-dual-write.test.ts +29 -22
  11. package/src/__tests__/render.test.ts +2 -2
  12. package/src/__tests__/result.test.ts +20 -11
  13. package/src/__tests__/roots.test.ts +64 -26
  14. package/src/__tests__/subagents.test.ts +108 -52
  15. package/src/__tests__/tool-handler.test.ts +133 -83
  16. package/src/__tests__/workflow.test.ts +5 -5
  17. package/src/__tests__/zcode-manifest.test.ts +18 -10
  18. package/src/__tests__/zcode-routing.test.ts +21 -19
  19. package/src/__tests__/zcode-whitelist.test.ts +1 -1
  20. package/src/core/family.ts +6 -6
  21. package/src/core/render.ts +2 -2
  22. package/src/core/toolcall.ts +1 -1
  23. package/src/core/workflow.ts +9 -9
  24. package/src/discovery/archive-ledger.ts +221 -0
  25. package/src/discovery/archive-match.ts +84 -0
  26. package/src/discovery/entry-anchor.ts +5 -5
  27. package/src/discovery/find.ts +12 -1
  28. package/src/discovery/roots.ts +149 -14
  29. package/src/discovery/subagents.ts +24 -17
  30. package/src/discovery/whitelist.ts +4 -4
  31. package/src/discovery/workflows.ts +8 -8
  32. package/src/discovery/zcode-manifest.ts +22 -27
  33. package/src/doctor.ts +3 -0
  34. package/src/extract.ts +1 -1
  35. package/src/index.ts +5 -2
  36. package/src/no-match.ts +29 -4
  37. package/src/result-action.ts +7 -1
  38. package/src/search-across.ts +3 -3
  39. package/src/tool-format.ts +24 -13
  40. package/src/tool-handler.ts +68 -29
  41. package/src/tui/session-command.ts +1 -1
  42. package/src/zcode-anchor-classify.ts +2 -2
@@ -0,0 +1,84 @@
1
+ /**
2
+ * [session-archive D7] 归档片段匹配编排 + 归档态 ToolResult 构造。
3
+ *
4
+ * 独立模块的原因(不并入 archive-ledger.ts):匹配复用 find.ts 的
5
+ * normalizeUuidKey(uuid 归一化口径单一源,§6.7 子决策 1),而 archive-ledger
6
+ * 被 roots.ts 单向消费——并入会构成 archive-ledger→find→roots→archive-ledger
7
+ * 的值级循环 import。本模块依赖方向:find(纯函数)+ archive-ledger(扫描/
8
+ * 渲染)+ tool-handler(仅 type import,同 tool-format 先例),无环。
9
+ *
10
+ * 消费方:tool-handler 的 resolveByFragment(归档回退层)与 result-action
11
+ * (archivedResult)、tool-format 的 resolveNonOkResult(multi 消歧与归档态的
12
+ * 统一 non-ok 分发)。
13
+ */
14
+ import { normalizeUuidKey } from './find.js'
15
+ import {
16
+ formatArchivedDisambiguation,
17
+ formatArchivedText,
18
+ resolveArchiveDir,
19
+ scanArchiveLedgers,
20
+ type ArchiveLedgerEntry,
21
+ } from './archive-ledger.js'
22
+ import type { ToolResult } from '../tool-handler.js'
23
+
24
+ /** 归档命中的两支形态(ResolveResult 对应支的结构子集)。 */
25
+ export type ArchiveResolve =
26
+ | { kind: 'archived'; archive: ArchiveLedgerEntry }
27
+ | { kind: 'archived-multi'; query: string; entries: ArchiveLedgerEntry[] }
28
+
29
+ /**
30
+ * 归档簿记片段匹配:sessionId(等值/子串/归一化,复用 find 的 normalizeUuidKey
31
+ * 两级口径)+ 簿记 name(名称片段的免解压数据源,D3)。归档根不存在/无簿记 →
32
+ * 空数组(无匹配路径零变化)。
33
+ */
34
+ export async function matchArchiveLedgers(
35
+ query: string,
36
+ agentDir: string,
37
+ ): Promise<ArchiveLedgerEntry[]> {
38
+ const archiveRoot = resolveArchiveDir(agentDir)
39
+ const entries = await scanArchiveLedgers(archiveRoot)
40
+ const nq = normalizeUuidKey(query)
41
+ return entries.filter((e) => {
42
+ if (e.sessionId === query) return true
43
+ if (e.sessionId.includes(query)) return true
44
+ if (nq.length > 0 && normalizeUuidKey(e.sessionId).includes(nq)) return true
45
+ return e.name !== undefined && e.name.includes(query)
46
+ })
47
+ }
48
+
49
+ /**
50
+ * 归档回退层的解析入口(resolveByFragment 在场零命中后调用):单命中 →
51
+ * archived;多命中 → archived-multi(消歧);无簿记命中 → undefined(落 F1)。
52
+ */
53
+ export async function resolveArchiveFragment(
54
+ query: string,
55
+ agentDir: string,
56
+ ): Promise<ArchiveResolve | undefined> {
57
+ const hits = await matchArchiveLedgers(query, agentDir)
58
+ if (hits.length === 1) {
59
+ return { kind: 'archived', archive: hits[0]! }
60
+ }
61
+ if (hits.length > 1) {
62
+ return { kind: 'archived-multi', query, entries: hits }
63
+ }
64
+ return undefined
65
+ }
66
+
67
+ /**
68
+ * 归档命中的统一 ToolResult(单条 + 多命中消歧):正文 = archive-ledger 两态
69
+ * 渲染(头部标注 + 摘要 turns + 尾注 / staging 占位),不读压缩体(D7)。details
70
+ * 顶层 `archived: true` 供程序化消费方一眼区分归档态与常规结果。
71
+ */
72
+ export function archivedResult(resolved: ArchiveResolve): ToolResult {
73
+ return resolved.kind === 'archived'
74
+ ? {
75
+ content: [{ type: 'text', text: formatArchivedText(resolved.archive) }],
76
+ details: { archived: true, entry: resolved.archive },
77
+ }
78
+ : {
79
+ content: [
80
+ { type: 'text', text: formatArchivedDisambiguation(resolved.query, resolved.entries) },
81
+ ],
82
+ details: { archived: true, candidates: resolved.entries },
83
+ }
84
+ }
@@ -29,16 +29,16 @@ export interface ZcodeAnchor {
29
29
  * `subagent-record` custom entry 的 customType 值。
30
30
  *
31
31
  * 磁盘 JSONL 协议字符串(跨侧契约):写侧单源 = subagent-core record-entry.ts 的
32
- * SUBAGENT_RECORD_CUSTOM_TYPE。reader 生产依赖面不引 subagent-core(该包是
32
+ * SUBAGENT_RECORD_CUSTOM_TYPE。reader 生产依赖范围不引 subagent-core(该包是
33
33
  * devDependency + vitest alias 形态),故按协议字符串本地持有;两侧漂移由
34
- * entry-anchor.test.ts 的跨包断言守卫。
34
+ * entry-anchor.test.ts 的跨包断言检查。
35
35
  */
36
36
  const SUBAGENT_RECORD_CUSTOM_TYPE = 'subagent-record'
37
37
 
38
38
  /**
39
39
  * v2 条目 data 的认识版本词表(W1 起 = 2)。与写侧 subagent-core
40
- * SUBAGENT_RECORD_ENTRY_VERSION 同值、同「本地持有 + 测试守卫漂移」模式——写侧
41
- * 再升 v3 时本守卫先红,提醒评估新版本的锚读取面。
40
+ * SUBAGENT_RECORD_ENTRY_VERSION 同值、同「本地持有 + 测试检查漂移」模式——写侧
41
+ * 再升 v3 时本检查先红,提醒评估新版本的锚读取范围。
42
42
  */
43
43
  const SUBAGENT_RECORD_ENTRY_V2 = 2
44
44
 
@@ -46,7 +46,7 @@ const SUBAGENT_RECORD_ENTRY_V2 = 2
46
46
  * 从单条 entry 提取 zcode 锚。形状校验窄而严,任一不满足 = 该条不算命中(返回
47
47
  * undefined,调用方继续扫——缺键条目不遮蔽更早的完整条目):
48
48
  * - `type === 'custom'` ∧ `customType === 'subagent-record'`
49
- * - 版本/判别键(schema 版本守卫,不认识的版本跳过而非猜测——写侧
49
+ * - 版本/判别键(schema 版本检查,不认识的版本跳过而非猜测——写侧
50
50
  * record-entry.ts classifySubagentRecordEntryData v2-only 同口径):
51
51
  * `data.v === 2` ∧ `data.kind === 'settled'`(W1 注册/终态两条小条目——
52
52
  * **锚只取自终态条**:registered 条目不携带 engineHandle,且设计 D1 裁决
@@ -354,6 +354,9 @@ async function loadTitleIndex(ctx: MetadataContext): Promise<Map<string, Session
354
354
  if (root.dedupedInto !== undefined) continue
355
355
  if (!root.exists) continue // guard:仅对存在的根调用
356
356
  if (!isFlatRoot(root)) continue // 策略 ②:含子目录根跳过 listAll
357
+ // [session-archive D7] 归档根不是标题数据源(listAll 只看一层 .jsonl,归档根
358
+ // 第一层是 encodeCwd 目录恒空手而归)——跳过多余调用
359
+ if (root.kind === 'archive') continue
357
360
  push(root.path)
358
361
  }
359
362
 
@@ -433,6 +436,14 @@ async function matchCandidates(
433
436
  }
434
437
  // 第一级 uuid 片段匹配(sessionId 或文件路径含 query)——cheap,已有 header。
435
438
  // 零命中才进入第二级(互斥回退,非合并排序,§6.7 子决策 1)。
439
+ // [session-store-layout-inversion] 完整 id 精确等值短路:存储树分区目录名 =
440
+ // rootSessionId(`<root>/<rootSessionId>/subagents/sessions/<sid>.jsonl`),根会话 id
441
+ // 会出现在其全部 subagent 候选的路径里——不短路则完整 id 直查根会话被伪 multi 消歧。
442
+ // 等值命中是 includes 命中的子集,短路只消伪 multi 不减真召回。
443
+ const exactIdHits = candidates.filter((c) => c.ref.sessionId === query)
444
+ if (exactIdHits.length > 0) {
445
+ return exactIdHits.map((c) => ({ ...c }))
446
+ }
436
447
  const exactHits = candidates.filter(
437
448
  (c) => c.ref.sessionId.includes(query) || c.meta.path.includes(query),
438
449
  )
@@ -626,7 +637,7 @@ export interface SessionFileRef {
626
637
  *
627
638
  * id 取自首行 header(复用 collectCandidates 的首行扫描——与 find 候选、resolveSessionId
628
639
  * 同一 id 语义,agent 从 find 输出复制的完整 id 恒能命中;不按文件名反推,避免「文件改名
629
- * 后与 header id 失配」的误报面)。workflow-state 跳过、坏 header 跳过、被去重根不产候选
640
+ * 后与 header id 失配」的误报风险)。workflow-state 跳过、坏 header 跳过、被去重根不产候选
630
641
  * 均随 collectCandidates 继承。同 id 多根出现([live] 与 [default] 不去重,V2 语义)时取
631
642
  * mtime 新者(与 loadTitleIndex 合并语义一致)。
632
643
  *
@@ -2,6 +2,11 @@ import { readdir, stat, realpath } from 'node:fs/promises'
2
2
  import type { Dirent } from 'node:fs'
3
3
  import { join, basename, dirname } from 'node:path'
4
4
  import { performance } from 'node:perf_hooks'
5
+ import {
6
+ resolveArchiveDir,
7
+ scanArchiveLedgers,
8
+ type ArchiveLedgerEntry,
9
+ } from './archive-ledger.js'
5
10
 
6
11
  /**
7
12
  * M2 discovery 发现层:文件系统扫描(design §3.3 D-5 首行扫描策略的文件定位部分)。
@@ -33,10 +38,10 @@ export interface SessionRootSignals {
33
38
  liveSessionDir?: string
34
39
  }
35
40
 
36
- /** 候选根来源标签;kind 优先级 live > default > legacy > subagent(§6.1 信号序) */
37
- export type SessionRootKind = 'live' | 'default' | 'legacy' | 'subagent'
41
+ /** 候选根来源标签;kind 优先级 live > default > legacy > subagent > archive(§6.1 信号序 + D7 追加) */
42
+ export type SessionRootKind = 'live' | 'default' | 'legacy' | 'subagent' | 'archive'
38
43
 
39
- /** main 源 = 主 session 根(live/default/legacy);subagent 源 = subagent 会话根 */
44
+ /** main 源 = 主 session 根(live/default/legacy/archive);subagent 源 = subagent 会话根 */
40
45
  export type SessionRootSource = 'main' | 'subagent'
41
46
 
42
47
  /** 候选根(含扫描结果)。去重后每 kind 至多一个根,kind 即 doctor 表行键。 */
@@ -53,6 +58,12 @@ export interface SessionRoot {
53
58
  scanMs?: number
54
59
  /** 扫描所得文件列表(design §7B 要点 8「本就在返回值里」);未扫描时空数组 */
55
60
  files: SessionFileMeta[]
61
+ /**
62
+ * 归档根(kind='archive')专属:簿记收集器产出(D7 独立收集契约——收
63
+ * `.archive.json` 不收 `.jsonl`,故 files 恒空、fileCount = 簿记数)。
64
+ * 其余根不挂此字段。
65
+ */
66
+ archiveEntries?: ArchiveLedgerEntry[]
56
67
  /**
57
68
  * realpath 与更高优先级根同路径 → 指向保留根的 kind,本根未扫描(doctor 渲染
58
69
  * 「与 N 同路径,已去重」注记的依据,§7B 要点 4)。
@@ -64,6 +75,68 @@ export interface SessionRoot {
64
75
  const SESSIONS_DIRNAME = 'sessions'
65
76
  const SUBAGENTS_DIRNAME = 'subagents'
66
77
 
78
+ /**
79
+ * taiji session 存储树根段(`<dataDir>/ext-sessions/`)。
80
+ *
81
+ * session-reader 是 extension 不 import @taiji/shared——本常量是 shared
82
+ * `SESSION_STORE_DIR_SEGMENT` 的同字面镜像,dataDir 推导先例 = `dirname(agentDir)`
83
+ * 基址(布局 B `agentDir = <dataDir>/agent`;同 tool-handler doExport 导出落点)。
84
+ * 字面量一致性由对拍机检锚点 `scripts/check-artifact-dir-formula-sync.mjs`(域 9
85
+ * session-reader discovery 断言位)锁定,树根段改名时 shared 与此处同批改。
86
+ */
87
+ const SESSION_STORE_DIR_SEGMENT = 'ext-sessions'
88
+
89
+ /**
90
+ * 存储树分区目录名合法性正则(镜像 shared `PI_SESSION_ID_PATTERN`,同域规则:首尾
91
+ * 字母数字,中间允许 [A-Za-z0-9._-];禁 `:`——btw 虚拟 id 必拒)。对拍机检锚点同上。
92
+ *
93
+ * [session-store-layout-inversion] 存储树扫描(subagent 根 / records manifest)以
94
+ * 此过滤 `<root>/` 一级目录——既是剪枝也是路径注入防线(目录名即 sessionId)。
95
+ */
96
+ const PI_SESSION_ID_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/
97
+
98
+ /**
99
+ * session 存储树根目录(`<dataDir>/ext-sessions/`,dataDir = dirname(agentDir))。
100
+ *
101
+ * 新布局全部 session 维度衍生数据的统一落点(布局权威 = shared getSessionStoreDir,
102
+ * 本函数是 extension 侧镜像推导);旧布局 `<agentDir>/subagents` 不再读写(设计裁决
103
+ * D6:旧布局存量原位保留、不可见)。
104
+ */
105
+ export function resolveSessionStoreRoot(agentDir: string): string {
106
+ return join(dirname(agentDir), SESSION_STORE_DIR_SEGMENT)
107
+ }
108
+
109
+ /** 存储树某根会话分区的 subagent 域目录(`<root>/<rootSessionId>/subagents/`)。 */
110
+ export function resolveStoreSubagentsDir(agentDir: string, rootSessionId: string): string {
111
+ return join(resolveSessionStoreRoot(agentDir), rootSessionId, SUBAGENTS_DIRNAME)
112
+ }
113
+
114
+ /** subagent 会话 jsonl 目录(`<root>/<rootSessionId>/subagents/sessions/`)。 */
115
+ export function resolveStoreSubagentSessionsDir(agentDir: string, rootSessionId: string): string {
116
+ return join(resolveStoreSubagentsDir(agentDir, rootSessionId), 'sessions')
117
+ }
118
+
119
+ /** ExecutionRecord manifest 目录(`<root>/<rootSessionId>/subagents/records/`)。 */
120
+ export function resolveStoreSubagentRecordsDir(agentDir: string, rootSessionId: string): string {
121
+ return join(resolveStoreSubagentsDir(agentDir, rootSessionId), 'records')
122
+ }
123
+
124
+ /**
125
+ * 枚举存储树 `<root>/` 一级合法会话分区目录名(isPiSessionId 过滤,见
126
+ * PI_SESSION_ID_PATTERN 注释)。根不存在/无权限 → 空数组,不抛错。
127
+ */
128
+ export async function listSessionStorePartitions(storeRoot: string): Promise<string[]> {
129
+ let entries: Dirent[]
130
+ try {
131
+ entries = await readdir(storeRoot, { withFileTypes: true })
132
+ } catch {
133
+ return []
134
+ }
135
+ return entries
136
+ .filter((e) => e.isDirectory() && PI_SESSION_ID_PATTERN.test(e.name))
137
+ .map((e) => e.name)
138
+ }
139
+
67
140
  /**
68
141
  * `[live]` 规范化(§6.1/§7B 要点 3):`getSessionDir()` 在纯 pi 返回按 cwd 编码的子目录
69
142
  *(`<agentDir>/sessions/--Users-x--`),在 taiji 覆盖态返回根本身。判据:basename
@@ -78,7 +151,8 @@ export function normalizeLiveSessionDir(liveSessionDir: string): string {
78
151
 
79
152
  /**
80
153
  * 从信号包推导候选根(未扫描骨架),数组序 = 优先级序(§6.1)。
81
- * `[subagent]` 恒在(常量推导);`[live]` 仅在信号存在时出现。
154
+ * `[subagent]` 恒在(常量推导);`[live]` 仅在信号存在时出现;`[archive]` 恒在
155
+ * 骨架中(D7 第五根,纳入与否由 resolveSessionRoots 按「存在才扫」判定)。
82
156
  */
83
157
  function sessionRootSpecs(signals: SessionRootSignals): SessionRoot[] {
84
158
  // 空 agentDir 防御:派生式会退化为 cwd 相对路径('sessions'/'./sessions'),可能扫到
@@ -90,7 +164,14 @@ function sessionRootSpecs(signals: SessionRootSignals): SessionRoot[] {
90
164
  }
91
165
  specs.push(spec('default', 'main', join(signals.agentDir, SESSIONS_DIRNAME)))
92
166
  specs.push(spec('legacy', 'main', join(dirname(signals.agentDir), SESSIONS_DIRNAME)))
93
- specs.push(spec('subagent', 'subagent', join(signals.agentDir, SUBAGENTS_DIRNAME)))
167
+ // [session-store-layout-inversion] subagent 根 = 存储树根(`<dataDir>/ext-sessions`),
168
+ // 扫描在 sid 分区内定向探 subagents/sessions/(scanStoreSubagentJsonl);旧布局
169
+ // `<agentDir>/subagents` 不再读(设计裁决 D6:存量原位保留、不可见)。
170
+ specs.push(spec('subagent', 'subagent', resolveSessionStoreRoot(signals.agentDir)))
171
+ // [session-archive D7] 第五根 = 归档根(`<agentDir>/archive`,镜像 sessions 平级布局)。
172
+ // 收集契约与四根不同:不收 .jsonl、收 .archive.json 簿记(见 resolveSessionRoots
173
+ // 分流与 scanArchiveLedgers);source=main(归档对象是主 session)。
174
+ specs.push(spec('archive', 'main', resolveArchiveDir(signals.agentDir)))
94
175
  return specs
95
176
  }
96
177
 
@@ -120,7 +201,7 @@ const SKIP_DIRS_NONE = new Set<string>()
120
201
 
121
202
  // ============================================================
122
203
  // 扫描 options(u8 追加:doctor「同一数据源两处渲染,不重复实现探测」§6.3——
123
- // doctor 经 options 驱动 subagent 根不扫。原 u8 的进程内缓存注入面已删除
204
+ // doctor 经 options 驱动 subagent 根不扫。原 u8 的进程内缓存注入点已删除
124
205
  // (ext-simplify-04 U3):每次调用实扫,本模块不再持有任何缓存语义)
125
206
  // ============================================================
126
207
 
@@ -137,13 +218,16 @@ export interface SessionRootScanOptions {
137
218
  /**
138
219
  * 解析候选根并逐根扫描(design 2026-09-10 §6.1 候选根探测 / §7B 要点 3/4/6)。
139
220
  *
140
- * 优先级 live > default > legacy > subagent;按 realpath 去重:同路径多信号只扫一次,
141
- * 保留最高优先级 kind 标签,被去重的根以 `dedupedInto` 标注(不产生 fileCount/scanMs)。
142
- * 逐根复用 scanJsonlRecursive(main 源跳 workflow-state;subagent 源不跳)。单根失败
143
- * (不存在/无权限)→ 空结果继续,不抛错(沿用 listXxxSessions 契约)。
221
+ * 优先级 live > default > legacy > subagent > archive;按 realpath 去重:同路径多信号
222
+ * 只扫一次,保留最高优先级 kind 标签,被去重的根以 `dedupedInto` 标注(不产生
223
+ * fileCount/scanMs)。逐根扫描:main 源整树递归(跳 workflow-state);subagent 源 =
224
+ * 存储树定向扫描([session-store-layout-inversion] 单新根,见 scanStoreSubagentJsonl);
225
+ * archive 源 = 簿记收集器([session-archive D7] 收 .archive.json 不收 .jsonl,
226
+ * **存在才纳入**——目录不存在时该根不进返回列表,对无归档目录的环境零行为变化)。
227
+ * 单根失败(不存在/无权限)→ 空结果继续,不抛错(沿用 listXxxSessions 契约)。
144
228
  *
145
229
  * options(u8 追加,全部缺省安全):`subagents:'stat'` 让 subagent 根只做存在性检查。
146
- * 无缓存——每次调用实扫(原 u8 cache 注入面已删除,ext-simplify-04 U3;find/F1
230
+ * 无缓存——每次调用实扫(原 u8 cache 注入点已删除,ext-simplify-04 U3;find/F1
147
231
  * 自检行计数与 doctor 表计数同取本次实扫,§7B 要点 8)。
148
232
  */
149
233
  export async function resolveSessionRoots(
@@ -154,6 +238,35 @@ export async function resolveSessionRoots(
154
238
  const out: SessionRoot[] = []
155
239
  const byRealPath = new Map<string, SessionRoot>()
156
240
  for (const candidate of sessionRootSpecs(signals)) {
241
+ if (candidate.kind === 'archive') {
242
+ // [session-archive D7] 存在才纳入:目录不存在 → 整根不进返回(备注「目录存在即
243
+ // 纳入」的逆向——非 taiji/纯 pi 用户无 archive 目录,doctor 表与 F1 自检行零变化)
244
+ if (!(await pathExists(candidate.path))) continue
245
+ const key = await safeRealpath(candidate.path)
246
+ const kept = byRealPath.get(key)
247
+ if (kept) {
248
+ // 同 realpath:不扫描,标注归属(与普通根 kept 检查同构——archive kind 优先级
249
+ // 最低,归档目录与更高优先级根同路径时只保留一根,doctor 表不重复计数;
250
+ // 漏此检查会既出重复根、又以本根覆盖 byRealPath 致后续根 dedupedInto 失效)
251
+ out.push({ ...candidate, exists: true, dedupedInto: kept.kind })
252
+ continue
253
+ }
254
+ const t0 = performance.now()
255
+ const archiveEntries = await scanArchiveLedgers(candidate.path)
256
+ const scanMs = performance.now() - t0
257
+ // 归档根不走 scanJsonlRecursive(.jsonl 不收):files 恒空,fileCount = 簿记数
258
+ const root: SessionRoot = {
259
+ ...candidate,
260
+ exists: true,
261
+ fileCount: archiveEntries.length,
262
+ scanMs,
263
+ files: [],
264
+ archiveEntries,
265
+ }
266
+ byRealPath.set(key, root)
267
+ out.push(root)
268
+ continue
269
+ }
157
270
  const key = await safeRealpath(candidate.path)
158
271
  const kept = byRealPath.get(key)
159
272
  if (kept) {
@@ -166,9 +279,13 @@ export async function resolveSessionRoots(
166
279
  out.push({ ...candidate, exists: await pathExists(candidate.path) })
167
280
  continue
168
281
  }
169
- const skipDirs = candidate.source === 'main' ? SKIP_DIRS_MAIN : SKIP_DIRS_NONE
282
+ // subagent 源 = 存储树定向扫描(sid 分区剪枝,见 scanStoreSubagentJsonl);
283
+ // main 源维持整树递归(跳 workflow-state)
170
284
  const t0 = performance.now()
171
- const files = await scanJsonlRecursive(candidate.path, skipDirs)
285
+ const files =
286
+ candidate.source === 'subagent'
287
+ ? await scanStoreSubagentJsonl(candidate.path)
288
+ : await scanJsonlRecursive(candidate.path, SKIP_DIRS_MAIN)
172
289
  const scanMs = performance.now() - t0
173
290
  const exists = await pathExists(candidate.path)
174
291
  const root: SessionRoot = {
@@ -194,7 +311,7 @@ export interface SessionFileMeta {
194
311
  /**
195
312
  * main sessions 扫描时整体跳过的子目录名。
196
313
  * `workflow-state` 目录存放 workflow 运行状态文件(wf-*.jsonl,首行 `{"v":"wf-run-v1"|"wf-run-v2"...}`,
197
- * 版本随 subagent-workflow 快照格式演进,读取侧 v1/v2 兼容),非 session 文件——属 family 腿
314
+ * 版本随 subagent-workflow 快照格式演进,读取侧 v1/v2 兼容),非 session 文件——属 family 路径
198
315
  * 独立处理(design §3.3 D-7),扫描 main sessions 时排除,
199
316
  * 否则会把 wf 文件误收为 session(且 find.ts 读其首行 header 时会因 type≠session 被丢弃,
200
317
  * 在此排除可避免这批无效首行扫描)。
@@ -210,6 +327,24 @@ function isSessionJsonl(name: string): boolean {
210
327
  return name.endsWith('.jsonl')
211
328
  }
212
329
 
330
+ /**
331
+ * 存储树 subagent 根定向扫描([session-store-layout-inversion] 读侧单新根):
332
+ * 只探 `<root>/<rootSessionId>/subagents/sessions/` 固定位置。`<root>/` 一级只进
333
+ * isPiSessionId 形态目录(剪枝:不进 logs/attachments 等大目录,也挡非分区名目录);
334
+ * 每 sid 分区内 records/ model-switch/ 不扫(会话文件域独立)。sessions/ 内部维持
335
+ * 递归 + stat(复用 scanJsonlRecursive,`.finalized` 天然排除)。存储树根或分区目录
336
+ * 不存在/无权限 → 空结果,不抛错(listXxxSessions 契约)。
337
+ */
338
+ async function scanStoreSubagentJsonl(storeRoot: string): Promise<SessionFileMeta[]> {
339
+ const results: SessionFileMeta[] = []
340
+ for (const sid of await listSessionStorePartitions(storeRoot)) {
341
+ results.push(
342
+ ...(await scanJsonlRecursive(join(storeRoot, sid, SUBAGENTS_DIRNAME, 'sessions'), SKIP_DIRS_NONE)),
343
+ )
344
+ }
345
+ return results
346
+ }
347
+
213
348
  /**
214
349
  * 递归扫描 rootDir 下所有 .jsonl 文件(排除 .finalized),返回绝对路径 + mtime + size。
215
350
  * `skipDirs` 命名的目录整体跳过。目录不存在/无权限 → 返回空数组,不抛错(design §2 坏路径容错)。
@@ -1,11 +1,11 @@
1
1
  import { readFile, readdir, open, stat } from 'node:fs/promises'
2
2
  import type { FileHandle } from 'node:fs/promises'
3
- import { join, basename } from 'node:path'
3
+ import { join } from 'node:path'
4
4
  import type { Entry } from '@zhushanwen/session-core'
5
5
  import type { Family, SessionRef } from '../core/family.js'
6
6
  import { buildFamilyIndex, resolveFamily } from '../core/family.js'
7
7
  import { listZcodeManifests } from './zcode-manifest.js'
8
- import { resolveSessionRoots, type SessionFileMeta } from './roots.js'
8
+ import { resolveSessionRoots, resolveSessionStoreRoot, resolveStoreSubagentRecordsDir, listSessionStorePartitions, type SessionFileMeta } from './roots.js'
9
9
  import { parseSessionHeader, readSessionHeaderFirstLine } from './session-header.js'
10
10
  import { resolveWorkflows } from './workflows.js'
11
11
 
@@ -186,7 +186,8 @@ async function collectSubagentIdentities(
186
186
  // U4 数据流:manifest 命中透全字段;未命中 P-fallback 读尾行 identity 取 task/slug/agent
187
187
  //(model/status 不可回退,留 undefined);无 manifest 无 identity(运行中/异常)跳过。
188
188
  // A3(ext-simplify-04):直调 resolveSessionRoots(原 listSubagentSessions 薄包装已删除)
189
- //——agentDir 信号包下 [subagent] 根 = <agentDir>/subagents(常量推导,恒唯一)
189
+ //——[subagent] 根 = session 存储树([session-store-layout-inversion] 单新根,常量
190
+ // 推导,恒唯一;扫描定向探各 sid 分区的 subagents/sessions/)。
190
191
  const subMetas =
191
192
  (await resolveSessionRoots({ agentDir })).find((r) => r.kind === 'subagent')?.files ?? []
192
193
  for (const meta of subMetas) {
@@ -418,31 +419,37 @@ async function tryReadManifest(path: string): Promise<RecordManifest | undefined
418
419
  }
419
420
 
420
421
  /**
421
- * 扫描 subagents/<cwdSlug>/records/*.json —— subagent 注册清单。
422
- * 每个 manifest 由终态 finalize 或 sync 批落标出口写入(创建时不写),持久存在即使
422
+ * 扫描 records/*.json —— subagent 注册清单(读侧单新根,[session-store-layout-inversion]
423
+ * D6 裁决:旧布局存量原位保留、不再读写——旧 record 从 agent 面 result/find 消失是该
424
+ * 裁决取舍清单明示项,不做旧根兼容扫描)。
425
+ * 每个 manifest 由终态 finalize 或 rebuildIndexes 投影写入(创建时不写),持久存在即使
423
426
  * .jsonl 被 GC。坏 manifest(缺必填字段/JSON 损坏)跳过,不中断扫描。
427
+ *
428
+ * 单新根:`<dataDir>/ext-sessions`(dataDir = dirname(agentDir),布局 B agentDir =
429
+ * `<dataDir>/agent`;树根段镜像 shared SESSION_STORE_DIR_SEGMENT,对拍机检锚点 =
430
+ * scripts/check-artifact-dir-formula-sync.mjs 域 9)。新布局 records 在
431
+ * `<root>/<sid>/subagents/records/`(分区键 = rootSessionId,嵌套派发全层同值——
432
+ * 嵌套 subagent 的 manifest 同落根会话分区,固定位置扫描完整覆盖)。扫描剪枝:
433
+ * `<root>/` 一级只进 isPiSessionId 形态目录(不进 logs/attachments 等大目录),每个
434
+ * sid 分区内只探 subagents/records/ 固定位置,不整树递归。根不存在时静默空读。
424
435
  */
425
436
  export async function listRecordManifests(agentDir: string): Promise<RecordManifest[]> {
426
- const root = join(agentDir, 'subagents')
437
+ const storeRoot = resolveSessionStoreRoot(agentDir)
427
438
  const out: RecordManifest[] = []
428
- async function walk(dir: string): Promise<void> {
439
+ for (const sid of await listSessionStorePartitions(storeRoot)) {
440
+ const recordsDir = resolveStoreSubagentRecordsDir(agentDir, sid)
429
441
  let entries
430
442
  try {
431
- entries = await readdir(dir, { withFileTypes: true })
443
+ entries = await readdir(recordsDir, { withFileTypes: true })
432
444
  } catch {
433
- return // 目录不存在/无权限 → 静默返回
445
+ continue // 该分区无 records/(无 subagent 的会话)→ 静默跳过
434
446
  }
435
447
  for (const e of entries) {
436
- const full = join(dir, e.name)
437
- if (e.isDirectory()) {
438
- await walk(full)
439
- } else if (e.isFile() && e.name.endsWith('.json') && basename(dir) === 'records') {
440
- const m = await tryReadManifest(full)
441
- if (m) out.push(m)
442
- }
448
+ if (!e.isFile() || !e.name.endsWith('.json')) continue
449
+ const m = await tryReadManifest(join(recordsDir, e.name))
450
+ if (m) out.push(m)
443
451
  }
444
452
  }
445
- await walk(root)
446
453
  return out
447
454
  }
448
455
 
@@ -16,8 +16,8 @@ import { zcodeImportDbAllowlist } from '@zhushanwen/zcode-session-source'
16
16
  *
17
17
  * **检查顺序铁律(§3.4 三段递进,实现与测试双锚定)**:存在性先于路径——
18
18
  * `existsSync` 为 false 报 `zcode_db_unreadable`(库文件不存在)而非
19
- * `zcode_db_path_forbidden`,避免把「只是还没落库」误报成安全事件(forbidden 是
20
- * 安全面错误,语义更重、更惊扰 agent)。存在才 realpath 后与派生允许集合精确比对。
19
+ * `zcode_db_path_forbidden`,避免把「只是还没写入仓库」误报成安全事件(forbidden 是
20
+ * 安全侧错误,语义更重、更惊扰 agent)。存在才 realpath 后与派生允许集合精确比对。
21
21
  * 开库(第 3 段)由调用方在本闸通过后进行。
22
22
  *
23
23
  * **TOCTOU 残留(设计 §3.4 显式登记,不在本梯队硬化)**:realpath 之后仍按原
@@ -72,10 +72,10 @@ export function formatZcodeDbPathForbidden(dbPath: string): string {
72
72
  * 白名单闸(第 1 段存在性 → 第 2 段路径;通过才允许开库)。
73
73
  *
74
74
  * @throws Error(消费侧映射为 zcode_db_unreadable / zcode_db_path_forbidden,
75
- * 消息即 §3.4 的 👉 指引文案——tool-handler 直接 throw 给 pi 错误面)
75
+ * 消息即 §3.4 的 👉 指引文案——tool-handler 直接 throw 给 pi 错误范围)
76
76
  */
77
77
  export function assertZcodeDbPathAllowed(dbPath: string, agentDir: string): void {
78
- // 第 1 段 存在性(先于路径——「还没落库」不是安全事件)
78
+ // 第 1 段 存在性(先于路径——「还没写入仓库」不是安全事件)
79
79
  if (!existsSync(dbPath)) {
80
80
  throw new Error(formatZcodeDbUnreadable(dirname(agentDir), `(库文件不存在:${dbPath})`))
81
81
  }
@@ -17,7 +17,7 @@ import { extractSessionIdFromFilename } from './subagents.js'
17
17
  // ——家族链数据源同 record 流,无第二承载位);旧后缀锚点(.events.jsonl 形态)=
18
18
  // 历史实体([D1] 历史数据处置:旧两件不读)→ calls=[] 仅 run 存在性兜底。
19
19
  // 历史格式条目(v1 全量快照 / workflow-state-link 指针)不识别、静默从发现链消失
20
- // (不拒读、不报错)——run 侧 v1 读面已删除(ADR-0095,同 record 侧裁决:项目未
20
+ // (不拒读、不报错)——run 侧 v1 读侧已删除(ADR-0095,同 record 侧裁决:项目未
21
21
  // 上线无历史数据,不迁移不兼容)。
22
22
  // - readRunSnapshot:读 wf-state 文件尾向找首个可解析行,返回原始对象(unknown,格式
23
23
  // 收窄交 core 层)。唯一消费方 = tool-handler workflow 概览的快照链分支。
@@ -82,16 +82,16 @@ function sessionRefFromPath(path: string, pathToRef: Map<string, SessionRef>): S
82
82
  }
83
83
 
84
84
  // ============================================================
85
- // workflow-record 条目契约(v2 注册条目判别——本地字面量 + 测试守卫漂移)
85
+ // workflow-record 条目契约(v2 注册条目判别——本地字面量 + 测试检查漂移)
86
86
  // ============================================================
87
87
 
88
88
  /**
89
89
  * `workflow-record` custom entry 的 customType 值。
90
90
  *
91
91
  * 磁盘 JSONL 协议字符串(跨侧契约):写侧单源 = subagent-core
92
- * workflow-record-entry.ts 的 WORKFLOW_RECORD_CUSTOM_TYPE。reader 生产依赖面不引
92
+ * workflow-record-entry.ts 的 WORKFLOW_RECORD_CUSTOM_TYPE。reader 生产依赖范围不引
93
93
  * subagent-core(同 entry-anchor.ts 的协议字面量模式),两侧漂移由 workflow.test.ts
94
- * 的跨包断言守卫。
94
+ * 的跨包断言检查。
95
95
  */
96
96
  const WORKFLOW_RECORD_CUSTOM_TYPE = 'workflow-record'
97
97
 
@@ -102,7 +102,7 @@ const WORKFLOW_RECORD_ENTRY_V2 = 2
102
102
  * run record 流文件名尾段(`<runId>.record.jsonl`)。写侧 subagent-core
103
103
  * RUN_EVENTS_SUFFIX 同值([D1] record 单源后缀)——v2 注册条目的
104
104
  * recordPath 锚点后缀判定用(新锚点直读 record 流;旧 `.events.jsonl` 锚点 =
105
- * 历史实体分流),本地持有 + 测试守卫漂移。
105
+ * 历史实体分流),本地持有 + 测试检查漂移。
106
106
  */
107
107
  export const RUN_RECORD_STREAM_SUFFIX = '.record.jsonl'
108
108
 
@@ -142,7 +142,7 @@ function collectRegisteredRecordPaths(entries: readonly Entry[]): RegisteredReco
142
142
  /**
143
143
  * [D16③] recordPath 后缀分流:`.record.jsonl` 锚点 = 新形态(record 流直读);
144
144
  * 其余(含旧 `.events.jsonl` 锚点)= 历史实体([D1] 历史数据处置——旧两件不读
145
- * 不写,历史 run 从发现链数据面退空即预期行为)。
145
+ * 不写,历史 run 从发现链的数据中退空即预期行为)。
146
146
  */
147
147
  function isNewRecordStreamAnchor(recordPath: string): boolean {
148
148
  return recordPath.endsWith(RUN_RECORD_STREAM_SUFFIX)
@@ -155,7 +155,7 @@ interface AgentSettledSessionFileView {
155
155
  result: { sessionFile: string }
156
156
  }
157
157
 
158
- /** [extractRecordStreamSessionFiles 守卫] 单帧是否为携带非空 result.sessionFile
158
+ /** [extractRecordStreamSessionFiles 检查] 单帧是否为携带非空 result.sessionFile
159
159
  * 的 agent-settled 帧:逐字段 typeof 校验(type → taskIndex → result →
160
160
  * sessionFile),不过关 = 宽容跳过(与 readRunSnapshot 同容忍度)。 */
161
161
  function isAgentSettledSessionFileFrame(v: unknown): v is AgentSettledSessionFileView {
@@ -194,7 +194,7 @@ export function extractRecordStreamSessionFiles(content: string): string[] {
194
194
  /**
195
195
  * 读目标 session 文件全文,从 workflow-record v2 注册条目构造 WorkflowRef[]
196
196
  * (单档发现链——v1 快照条目与 workflow-state-link 指针条目不再识别,历史 run
197
- * 从发现链数据面退空即预期行为,见 ADR-0095)。同一 runId 的多条目后写覆盖
197
+ * 从发现链的数据中退空即预期行为,见 ADR-0095)。同一 runId 的多条目后写覆盖
198
198
  * (条目顺序 = 时间顺序,取最新锚点)。
199
199
  *
200
200
  * **签名与返回值结构(WorkflowRef[]{runId,stateFile,calls:SessionRef[]})不变**