@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
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
4
- import { join } from 'node:path'
4
+ import { join, dirname } from 'node:path'
5
5
 
6
6
  import { readZcodeManifest, listZcodeManifests } from '../discovery/zcode-manifest.js'
7
7
  import { findZcodeEntryAnchor } from '../discovery/entry-anchor.js'
@@ -10,10 +10,10 @@ import { findZcodeEntryAnchor } from '../discovery/entry-anchor.js'
10
10
  // 变体 M(设计 §4 场景 1a):zcode manifest 直读(§3.1 第①步主路径)+ 枚举(family 用)。
11
11
  //
12
12
  // fixture 对齐 F21 磁盘真实形态:zcode manifest **无 sessionFile 键**
13
- // (投影函数照写 sessionFile: undefined,JSON.stringify 丢键)、engine:'zcode'、
13
+ // (派生视图函数照写 sessionFile: undefined,JSON.stringify 丢键)、engine:'zcode'、
14
14
  // engineHandle.sessionRef 双键齐。
15
15
  //
16
- // 单元边界:只测「认出 zcode manifest + 提取锚」;白名单闸 / 错误码映射(U9)、
16
+ // 单元边界:只测「认出 zcode manifest + 提取锚」;白名单门禁 / 错误码映射(U9)、
17
17
  // entry 兜底定位语义(U7 已交付)不在本文件展开。
18
18
  // ============================================================
19
19
 
@@ -21,9 +21,13 @@ const DB_PATH = '/data/agent/engines/zcode/session-db/db.sqlite'
21
21
 
22
22
  let agentDir: string
23
23
 
24
- /** 在 <agentDir>/subagents/<slug>/records/ 下写一个 manifest 文件(手写 JSON 字符串,忠实控制键) */
25
- function writeManifest(slug: string, name: string, content: string): string {
26
- const recordsDir = join(agentDir, 'subagents', slug, 'records')
24
+ /**
25
+ * 在存储树分区 <root>/<sid>/subagents/records/ 下写一个 manifest 文件(手写 JSON 字符串,
26
+ * 忠实控制键)。[session-store-layout-inversion D6/单新根] 新布局无 cwd-slug 层,
27
+ * slug 参数消亡(保留签名兼容既有调用点,弃用改名 _slug);分区名固定合法 sid。
28
+ */
29
+ function writeManifest(_slug: string, name: string, content: string): string {
30
+ const recordsDir = join(dirname(agentDir), 'ext-sessions', 'root-session-test', 'subagents', 'records')
27
31
  mkdirSync(recordsDir, { recursive: true })
28
32
  const file = join(recordsDir, name)
29
33
  writeFileSync(file, content, 'utf8')
@@ -73,7 +77,10 @@ function recordLine(saId: string, sessionRef: Record<string, string>): string {
73
77
  }
74
78
 
75
79
  beforeEach(() => {
76
- agentDir = mkdtempSync(join(tmpdir(), 'zcode-manifest-test-'))
80
+ // agentDir 两层化(<mkdtemp>/agent):存储树推导 = dirname(agentDir)/ext-sessions,
81
+ // 单层直接当 agentDir 会把扫描根泄到 tmpdir 公共层(跨测试污染——先例 reaper/family-zcode 同修)
82
+ agentDir = join(mkdtempSync(join(tmpdir(), 'zcode-manifest-test-')), 'agent')
83
+ mkdirSync(agentDir, { recursive: true })
77
84
  })
78
85
  afterEach(() => {
79
86
  rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
@@ -353,12 +360,13 @@ describe('listZcodeManifests 枚举(family 节点列表来源)', () => {
353
360
  expect(manifests.find((m) => m.id === 'sa-s2')?.slug).toBe('worker') // agentName 兜底
354
361
  })
355
362
 
356
- it('subagents 根不存在 → 空数组(不报错;单节点不可达不炸整屏)', async () => {
357
- const bareDir = mkdtempSync(join(tmpdir(), 'zcode-manifest-bare-'))
363
+ it('存储树根不存在 → 空数组(不报错;单节点不可达不炸整屏)', async () => {
364
+ // agentDir 两层化:扫描根 = dirname(agentDir)/ext-sessions(mkdtemp 私有层内,防公共层污染)
365
+ const bareDir = join(mkdtempSync(join(tmpdir(), 'zcode-manifest-bare-')), 'agent')
358
366
  try {
359
367
  expect(await listZcodeManifests(bareDir)).toEqual([])
360
368
  } finally {
361
- rmSync(bareDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
369
+ rmSync(dirname(bareDir), { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
362
370
  }
363
371
  })
364
372
  })
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs'
4
- import { join } from 'node:path'
4
+ import { join, dirname } from 'node:path'
5
5
  import { DatabaseSync } from 'node:sqlite'
6
6
  import { Check } from 'typebox/value'
7
7
 
@@ -16,7 +16,7 @@ import sessionReaderExtension from '../index.js'
16
16
  import { setPiHandle } from '@zhushanwen/pi-extension-logger'
17
17
 
18
18
  // ============================================================
19
- // U9 路由 + 白名单闸 + 错误面集成测试(design session-reader-shared-core
19
+ // U9 路由 + 白名单闸 + 错误范围集成测试(design session-reader-shared-core
20
20
  // §3.1 三层定位链 / §3.4 七码 + 检查顺序三段递进 / §4 场景 1a 变体 M/E +
21
21
  // 场景 2 family + 场景 5 八类失败 + 场景 3 pi 零回归 + D4 schema 零变化)。
22
22
  //
@@ -53,8 +53,8 @@ beforeEach(() => {
53
53
  mkdirSync(agentDir, { recursive: true })
54
54
  mkdirSync(liveDir, { recursive: true })
55
55
  dbPath = join(root, ...ISOLATED_DB_SEGMENTS)
56
- // 结构化日志捕获:logger.warn 走 appendEntry 通道(不进 LLM 可见面——本文件同时
57
- // 是「降级留痕不进 LLM 可见面」契约的断言面)。测试后清注入防跨文件泄漏。
56
+ // 结构化日志捕获:logger.warn 走 appendEntry 通道(不进 LLM 可见部分——本文件同时
57
+ // 是「降级留痕不进 LLM 可见部分」契约的断言范围)。测试后清注入防跨文件泄漏。
58
58
  appendEntries = vi.fn()
59
59
  setPiHandle({ appendEntry: appendEntries } as unknown as Parameters<typeof setPiHandle>[0])
60
60
  })
@@ -135,7 +135,7 @@ function assistantMsg(
135
135
 
136
136
  /**
137
137
  * 建真实 fixture 隔离库(WAL + session/message/part/schema_migration 四表,行形态 =
138
- * zcode-session-source helpers 同款宿主 schema 0.16.5 消费面子集)。
138
+ * zcode-session-source helpers 同款宿主 schema 0.16.5 消费方子集)。
139
139
  * 测试进程跑 node(node:sqlite 可用);生产读链的驱动选择在 source 包内。
140
140
  */
141
141
  function createZcodeDb(path: string, schemaVersion = '0.16.5', sessions: SessionRows[] = []): void {
@@ -175,7 +175,9 @@ function writeZcodeManifest(
175
175
  saId: string,
176
176
  opts: { sessionRef?: Record<string, string>; omitEngineHandle?: boolean; engine?: string | true } = {},
177
177
  ): void {
178
- const recordsDir = join(agentDir, 'subagents', 'proj-slug', 'records')
178
+ // [session-store-layout-inversion D6/单新根] records 读侧 = 存储树分区固定位置
179
+ // <root>/<rootSid>/subagents/records/(旧根 <agentDir>/subagents 不再读写)。
180
+ const recordsDir = join(dirname(agentDir), 'ext-sessions', ROOT_SESSION, 'subagents', 'records')
179
181
  mkdirSync(recordsDir, { recursive: true })
180
182
  const m: Record<string, unknown> = {
181
183
  id: saId,
@@ -210,8 +212,8 @@ function recordLine(entryId: string, saId: string, sessionRef: Record<string, st
210
212
 
211
213
  /**
212
214
  * pi 形态的 subagent-record custom entry 行(回归 A1 instrument):engine 键缺省 +
213
- * sessionRef 无 dbPath——subagent-core record-entry.ts 的 pi 投影形态(engineHandle
214
- * 可选、sessionRef 整体透传不枚举内部键;最小可信子集,跨包漂移由 entry-anchor.test 守卫)。
215
+ * sessionRef 无 dbPath——subagent-core record-entry.ts 的 pi 派生视图形态(engineHandle
216
+ * 可选、sessionRef 整体透传不枚举内部键;最小可信子集,跨包漂移由 entry-anchor.test 检查)。
215
217
  */
216
218
  function piRecordLine(entryId: string, saId: string): string {
217
219
  return JSON.stringify({
@@ -220,7 +222,7 @@ function piRecordLine(entryId: string, saId: string): string {
220
222
  id: entryId,
221
223
  parentId: null,
222
224
  data: {
223
- // 登记 §3.3:条目面 = v2 族——pi 形态 = 注册条(身份)+ 终态条(engineHandle 半键)。
225
+ // 登记 §3.3:条目范围 = v2 族——pi 形态 = 注册条(身份)+ 终态条(engineHandle 半键)。
224
226
  v: 2,
225
227
  kind: 'settled',
226
228
  id: saId,
@@ -260,7 +262,7 @@ function writeRootSessionFile(): void {
260
262
 
261
263
  /** pi manifest + pi subagent session 文件(pi 穿插断言用;engine 缺省 = pi 存量形态)。 */
262
264
  function writePiFixture(): void {
263
- const subDir = join(agentDir, 'subagents', 'pi-slug', 'sessions')
265
+ const subDir = join(dirname(agentDir), 'ext-sessions', ROOT_SESSION, 'subagents', 'sessions')
264
266
  mkdirSync(subDir, { recursive: true })
265
267
  const piSession = join(subDir, '019e6c96-pi-sub-agent-00000000004a4.jsonl')
266
268
  writeFileSync(
@@ -272,7 +274,7 @@ function writePiFixture(): void {
272
274
  ].join('\n') + '\n',
273
275
  'utf8',
274
276
  )
275
- const recordsDir = join(agentDir, 'subagents', 'pi-slug', 'records')
277
+ const recordsDir = join(dirname(agentDir), 'ext-sessions', ROOT_SESSION, 'subagents', 'records')
276
278
  mkdirSync(recordsDir, { recursive: true })
277
279
  writeFileSync(
278
280
  join(recordsDir, 'sa-pi-1.json'),
@@ -433,7 +435,7 @@ describe('场景 5 八类失败(§3.4 表逐行 + 检查顺序三段递进)'
433
435
  await expect(handleSessionRead({ action: 'result', session: SA_M }, signals())).rejects.toThrow(
434
436
  'zcode_anchor_missing',
435
437
  )
436
- // 归因进结构化日志不进 LLM 可见面(§3.4):缺失键名可区分(missing-dbPath)
438
+ // 归因进结构化日志不进 LLM 可见部分(§3.4):缺失键名可区分(missing-dbPath)
437
439
  expect(logCallsWithReason('missing-dbPath')).toBeGreaterThanOrEqual(1)
438
440
  })
439
441
 
@@ -521,14 +523,14 @@ describe('场景 5 八类失败(§3.4 表逐行 + 检查顺序三段递进)'
521
523
  })
522
524
 
523
525
  // ---------------------------------------------------------------------------
524
- // 驱动探测失败的错误面契约(跨包漂移守卫 + L4 包装形态映射)
526
+ // 驱动探测失败的错误契约(跨包漂移检查 + L4 包装形态映射)
525
527
  // ---------------------------------------------------------------------------
526
528
 
527
- describe('驱动探测失败错误面:跨包契约 + L4 包装形态映射', () => {
529
+ describe('驱动探测失败错误范围:跨包契约 + L4 包装形态映射', () => {
528
530
  it('跨包契约:SQLITE_DRIVER_UNSUPPORTED_MARK 子串在 zcode-session-source sqlite-driver 源文内(漂移即红)', () => {
529
531
  // 驱动探测错误无专用错误类型(该包不导出子类),reader 按消息特征识别——mark 与
530
532
  // sqlite-driver 源内错误字面量的绑定由本测试机器锚定(entry-anchor.test.ts 协议
531
- // 字面量跨包漂移守卫同范式):源文措辞变更不再含 mark → host_unsupported 面死亡,红。
533
+ // 字面量跨包漂移检查同范式):源文措辞变更不再含 mark → host_unsupported 契约死亡,红。
532
534
  const source = readFileSync(
533
535
  new URL('../../../../../packages/zcode-session-source/src/sqlite-driver.ts', import.meta.url),
534
536
  'utf8',
@@ -689,7 +691,7 @@ describe('pi 穿插不受影响:同 fixture 内 pi 会话读取与 zcode 调
689
691
  /** `session_read` 输入 schema 的 JSON 序列化快照(防无意漂移的逐字节锚)。
690
692
  *
691
693
  * 快照锚的意图 = 无意漂移拦截(参数增删 / 文案改动即红,迫使有意确认);
692
- * 有意的声明面压缩须同批重新生成快照。当前快照 = tool-declaration-slimming
694
+ * 有意的声明侧压缩须同批重新生成快照。当前快照 = tool-declaration-slimming
693
695
  * U-A1 压缩终态(D1 三层分工:action 功能语义唯一承载点 = 枚举 description;
694
696
  * 工具 description 只留定位 + 边界;guidelines 只留纪律)。重新生成:
695
697
  * tsx 起 extension 捕获 registerTool 的 parameters 后 JSON.stringify。
@@ -748,12 +750,12 @@ describe('session_read schema 快照(D4:不加任何工具参数,逐字节
748
750
 
749
751
  describe('session_read 锚词保留清单(D2:guidelines 纪律逐词锚定)', () => {
750
752
  // SCHEMA_SNAPSHOT 只逐字节锚 parameters schema,不覆盖 guidelines——本块是
751
- // guidelines 锚词的机器防线:弱模型回归(P7/PB-weak)后对本工具声明面的
753
+ // guidelines 锚词的机器防线:弱模型回归(P7/PB-weak)后对本工具声明侧的
752
754
  // 再压缩 / 措辞改动,逐词红灯迫使有意确认。清单与 index.ts 注释登记一致:
753
755
  // 1. "Progressive reading" —— 阅读纪律 outline → expand → detail
754
756
  // 2. "Never read raw .jsonl" —— 禁直读原始 session 文件
755
- // 3. "sa- ids route" —— zcode 路由对 LLM 的唯一可见面(U9)
756
- // 4. "👉 recovery hint" —— 错误面恢复指针契约
757
+ // 3. "sa- ids route" —— zcode 路由对 LLM 的唯一可见部分(U9)
758
+ // 4. "👉 recovery hint" —— 错误范围恢复指针契约
757
759
  // 5. "doctor" —— 环境自检入口(另被上方快照 action 枚举间接锚定)
758
760
  it('guidelines 5 锚词逐个在位(缺一词即红)', () => {
759
761
  const registerTool = vi.fn()
@@ -77,7 +77,7 @@ describe('assertZcodeDbPathAllowed:检查顺序三段递进(存在性先于
77
77
  )
78
78
  })
79
79
 
80
- it('第 2 段路径:存在但集合外 → zcode_db_path_forbidden(安全面错误语义更重)', () => {
80
+ it('第 2 段路径:存在但集合外 → zcode_db_path_forbidden(安全侧错误语义更重)', () => {
81
81
  const outside = join(root, 'evil.sqlite')
82
82
  writeFileSync(outside, '', 'utf8')
83
83
  try {
@@ -38,7 +38,7 @@ export interface SubagentRef extends SessionRef {
38
38
  task?: string
39
39
  /** agent 类型名(manifest.agentName,P-fallback 时 identity.data.agent;同语义异名) */
40
40
  agentName?: string
41
- /** 模型 id(仅 manifest 有;P-fallback 时 undefined)。仅 details 可见,不进文本渲染(D2②:展示面只取 status/agentName/task) */
41
+ /** 模型 id(仅 manifest 有;P-fallback 时 undefined)。仅 details 可见,不进文本渲染(D2②:展示侧只取 status/agentName/task) */
42
42
  model?: string
43
43
  /** 终态 completed/failed/running(仅 manifest 有;P-fallback 时 undefined) */
44
44
  status?: string
@@ -60,7 +60,7 @@ export interface Family {
60
60
  forks: SessionRef[]
61
61
  /** 含隔代(design §3.3 D-7 Q1:对 fork 链每个节点查 subagentsByRoot 合并) */
62
62
  subagents: SubagentRef[]
63
- /** M1 恒返回 [],workflow 腿需读 workflow-state 文件(IO)归 M2 */
63
+ /** M1 恒返回 [],workflow 路径需读 workflow-state 文件(IO)归 M2 */
64
64
  workflows: WorkflowRef[]
65
65
  }
66
66
 
@@ -146,12 +146,12 @@ export function buildZcodeSubagentsByRoot(
146
146
  return byRoot
147
147
  }
148
148
 
149
- // ---- 类型守卫:从 unknown 的 entry.data 提取 subagent identity 字段 ----
149
+ // ---- 类型检查:从 unknown 的 entry.data 提取 subagent identity 字段 ----
150
150
 
151
151
  /**
152
152
  * identity 尾行 data 结构(读取视图,DM-IdentityData)。
153
153
  *
154
- * 守卫只强制 rootSessionId+slug(m0 契约),其余富字段全 optional——由 buildFamilyFromFs
154
+ * 检查只强制 rootSessionId+slug(m0 契约),其余富字段全 optional——由 buildFamilyFromFs
155
155
  * 按 manifest 主/P-fallback 回退路径组装时决定是否填入(manifest 主填全,P-fallback
156
156
  * 只填 task/agent/sessionFile,model/status 缺)。
157
157
  */
@@ -178,7 +178,7 @@ interface SubagentIdentityData {
178
178
  function isSubagentIdentityData(v: unknown): v is SubagentIdentityData {
179
179
  if (typeof v !== 'object' || v === null) return false
180
180
  const obj = v as Record<string, unknown>
181
- // 守卫放宽:只校验 rootSessionId+slug 必填(m0 契约),task/agent/model/status/sessionFile optional
181
+ // 检查放宽:只校验 rootSessionId+slug 必填(m0 契约),task/agent/model/status/sessionFile optional
182
182
  return typeof obj.rootSessionId === 'string' && typeof obj.slug === 'string'
183
183
  }
184
184
 
@@ -380,7 +380,7 @@ export function resolveFamily(sessionId: string, index: FamilyIndex): Family {
380
380
  }
381
381
  }
382
382
 
383
- // 5. workflows:M1 恒 [],workflow 腿需读 workflow-state 文件(IO)归 M2
383
+ // 5. workflows:M1 恒 [],workflow 路径需读 workflow-state 文件(IO)归 M2
384
384
  const workflows: WorkflowRef[] = []
385
385
 
386
386
  return { root, parents, forks, subagents, workflows }
@@ -79,7 +79,7 @@ export interface OutlineOptions {
79
79
  }
80
80
 
81
81
  // ---------------------------------------------------------------------------
82
- // content 类型守卫与提取(message.content 是 unknown,design §3.4)
82
+ // content 类型检查与提取(message.content 是 unknown,design §3.4)
83
83
  // ---------------------------------------------------------------------------
84
84
 
85
85
  interface ContentBlock {
@@ -454,7 +454,7 @@ export function renderOutline(
454
454
  const allBranches = options?.allBranches ?? false
455
455
  const granularity = options?.granularity ?? 'turn'
456
456
 
457
- // 字节/条目统计保持全量口径(折叠是展示层行为,不丢数据面);行与轮次计数走对话集
457
+ // 字节/条目统计保持全量口径(折叠是展示层行为,不丢数据);行与轮次计数走对话集
458
458
  const parsedBytes = leafParsedBytes(turns)
459
459
  const leafEntryCount = turns.reduce((s, t) => s + t.entries.length, 0)
460
460
  // totalEntries = leaf + 旁支子树 + 孤儿;totalBytes 无原始文件字节数(签名不含 ParseResult),
@@ -69,7 +69,7 @@ function coerceArgs(raw: unknown): Record<string, unknown> {
69
69
  /**
70
70
  * 从 entry.message.content 的 `{type:"toolCall"}` block 提取工具调用列表。
71
71
  *
72
- * content 是 unknown 做类型守卫;非数组或无 toolCall block 返 []。
72
+ * content 是 unknown 做类型检查;非数组或无 toolCall block 返 []。
73
73
  * 仅纳入 id + name 均为 string 的 block(O2 靠 id 关联 toolResult,O1 靠 name 聚合;
74
74
  * 缺 id 的块无法关联,缺 name 无法摘要,跳过——实测 0 缺失)。
75
75
  */
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { isRecord } from '@zhushanwen/pi-ext-guards'
17
17
 
18
- // ---- 类型守卫 helpers ----
18
+ // ---- 类型检查 helpers ----
19
19
 
20
20
  /** unknown → string 收窄(非 string → undefined)。快照可选字符串字段的统一入口。 */
21
21
  function strOr(v: unknown): string | undefined {
@@ -74,9 +74,9 @@ export interface WorkflowOverview {
74
74
  runId: string
75
75
  /** WorkflowRef.stateFile 透传 */
76
76
  stateFile: string
77
- /** NEW state.status / OLD 顶层 status / record 流投影([D2] 三态:running | interrupted | done) */
77
+ /** NEW state.status / OLD 顶层 status / record 流派生视图([D2] 三态:running | interrupted | done) */
78
78
  status: string
79
- /** details 格式标记(随 details.runs 透传给程序化消费方,文本渲染不读)。v2 读取面形状与 v1 一致(pi-subagent-workflow 8.x 一次性生命周期) */
79
+ /** details 格式标记(随 details.runs 透传给程序化消费方,文本渲染不读)。v2 读侧形状与 v1 一致(pi-subagent-workflow 8.x 一次性生命周期) */
80
80
  version: 'wf-run-v1' | 'wf-run-v2' | 'legacy'
81
81
  /** NEW spec.scriptName / spec.name / OLD name */
82
82
  script?: string
@@ -222,12 +222,12 @@ function parseLegacyRunSnapshot(
222
222
  *
223
223
  * 分支(C-parserunsnapshot-dualformat,TC-wf-snapshot-version-union):
224
224
  * - 非对象 → null(调用方跳过,ES-wf-snapshot-unparseable)
225
- * - NEW (snapshot.v === 'wf-run-v1' 或 'wf-run-v2',读取面形状一致):state.* / meta.* / spec.*
225
+ * - NEW (snapshot.v === 'wf-run-v1' 或 'wf-run-v2',读侧形状一致):state.* / meta.* / spec.*
226
226
  * - OLD (无 v,有 callCache 数组或顶层 status):顶层 status/budget/startedAt + callCache
227
227
  * - 既非 NEW 也非 OLD → null(未来版本(如 wf-run-v3)/ 异构内容)
228
228
  *
229
229
  * runId/stateFile 透传参数(不读 snapshot.runId,保证与 family.workflows 一致,避免 OLD 顶层
230
- * runId 可信度低的不一致)。零 any(全程 typeof/Array.isArray/isRecord 守卫收窄)。
230
+ * runId 可信度低的不一致)。零 any(全程 typeof/Array.isArray/isRecord 检查收窄)。
231
231
  */
232
232
  export function parseRunSnapshot(
233
233
  snapshot: unknown,
@@ -236,7 +236,7 @@ export function parseRunSnapshot(
236
236
  ): WorkflowOverview | null {
237
237
  if (!isRecord(snapshot)) return null
238
238
 
239
- // NEW 格式(v === 'wf-run-v1' || 'wf-run-v2',v2 读取面形状兼容 v1)
239
+ // NEW 格式(v === 'wf-run-v1' || 'wf-run-v2',v2 读侧形状兼容 v1)
240
240
  const v = snapshot.v
241
241
  if (v === 'wf-run-v1' || v === 'wf-run-v2') {
242
242
  return parseNewRunSnapshot(snapshot, v, runId, stateFile)
@@ -331,7 +331,7 @@ interface RecordStreamLine {
331
331
  [key: string]: unknown
332
332
  }
333
333
 
334
- /** 流解析产物:run 装配面(runId 透传 + stateFile=record 流路径)。 */
334
+ /** 流解析产物:run 装配侧(runId 透传 + stateFile=record 流路径)。 */
335
335
  interface RecordStreamFold {
336
336
  workflowName?: string
337
337
  startedAt?: number
@@ -371,7 +371,7 @@ function parseRecordStreamLine(line: string): RecordStreamLine | undefined {
371
371
  * sessionId——家族链跳转入口);
372
372
  * - run-interrupted:[D2] 中断转移 → status 'interrupted'(非终局);
373
373
  * - run-resumed:[D2] 复活转移 → 清中断标记(status 回 running——lifecycle fold
374
- * 投影语义:run-resumed 是 interrupted → running 的转移证据,跳过会使 resume
374
+ * 派生视图语义:run-resumed 是 interrupted → running 的转移证据,跳过会使 resume
375
375
  * 续跑期间的概览停在 interrupted 直至终局);
376
376
  * - run-settled:终局帧 → status 'done' + outcome(done/failed/cancelled/time_limited);
377
377
  * - 其余(phase-started / phase-settled):跳过(状态机转移不进概览文本——phase 分组展示
@@ -445,7 +445,7 @@ function applyAgentSettledToFold(
445
445
  })
446
446
  }
447
447
 
448
- /** [parseRunRecordStream 拆分] status 三态投影([D2]):终局帧 > 中断转移 > running(空流也 running——活跃兜底)。 */
448
+ /** [parseRunRecordStream 拆分] status 三态派生视图([D2]):终局帧 > 中断转移 > running(空流也 running——活跃兜底)。 */
449
449
  function projectRecordStreamStatus(fold: RecordStreamFold): {
450
450
  status: string
451
451
  reason: string | undefined
@@ -0,0 +1,221 @@
1
+ /**
2
+ * 归档簿记域(session-archive 设计 D7:session-reader 归档根感知)。
3
+ *
4
+ * taiji 归档功能把闲置 session 整体搬入 `<agentDir>/archive/`(D1 镜像布局)并
5
+ * zstd 压缩主文件,每个归档单元一份明文簿记 `<原主文件名>.archive.json`(D3
6
+ * schema)。本模块是 extension 侧的簿记消费子集:解析 + 扫描 + 归档态渲染——
7
+ * **类型自含**(universal 包不依赖 taiji workspace shared,簿记字段子集在本地
8
+ * 接口声明),零内部依赖(不 import 本包其他模块,roots.ts 单向消费本模块,
9
+ * 规避 archive-ledger→find→roots 的值级循环 import)。
10
+ *
11
+ * 收集契约(D7 硬约束):归档根**只收 `.archive.json` 簿记、不收 `.jsonl`**——
12
+ * staging 期归档目录内暂存明文主文件,一旦误收会被当「可完整读取的在场 session」
13
+ * 进候选集,归档标注随之失效;`.restoring` 后缀(恢复中,D9)不以
14
+ * `.archive.json` 结尾,天然不进收集。
15
+ */
16
+ import { readdir, readFile, stat } from 'node:fs/promises'
17
+ import type { Dirent } from 'node:fs'
18
+ import { join } from 'node:path'
19
+
20
+ /** 归档根目录名(`<agentDir>/archive/`,与 sessions/ 平级——D1)。 */
21
+ export const ARCHIVE_DIRNAME = 'archive'
22
+
23
+ /** 归档根目录(`<agentDir>/archive/`,agentDir 注入推导)。 */
24
+ export function resolveArchiveDir(agentDir: string): string {
25
+ return join(agentDir, ARCHIVE_DIRNAME)
26
+ }
27
+
28
+ /**
29
+ * 存储树分区目录名合法性正则(镜像 shared `PI_SESSION_ID_PATTERN` 与 roots.ts
30
+ * 同字面副本:首尾字母数字,中间 [A-Za-z0-9._-],禁 `:`)。簿记 sessionId 是
31
+ * 路径消费与展示的锚,形态非法(含穿越片段)的簿记整条拒收。
32
+ */
33
+ const PI_SESSION_ID_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/
34
+
35
+ /** 摘要单 turn(D4 算法产出:user 原文 + agent 最后一段文本;agent null = 该 turn 以工具调用结束)。 */
36
+ export interface ArchiveLedgerTurn { // oe-exempt:20261020:framework:SDK 契约接口——跨进程/跨包协议载体,接口先立、单实现常态
37
+ user: string
38
+ agent: string | null
39
+ }
40
+
41
+ /**
42
+ * 归档簿记消费子集(D3 schema 的本包视角)。仅声明本域消费字段——rawBytes/
43
+ * codec/sha256 等压缩侧字段不进(extension 侧不消费压缩体,D7:命中归档
44
+ * session 时不读压缩体)。
45
+ */
46
+ export interface ArchiveLedgerEntry { // oe-exempt:20261020:framework:SDK 契约接口——跨进程/跨包协议载体,接口先立、单实现常态
47
+ /** 簿记文件绝对路径(`archive/<encodeCwd>/<原主文件名>.archive.json`) */
48
+ ledgerPath: string
49
+ sessionId: string
50
+ /** 归档时从明文 JSONL 提取的名称(免解压名称数据源,D3——名称片段匹配的键之一) */
51
+ name?: string
52
+ /** staging(搬移完成、摘要/压缩未完成)| archived(终态,摘要已入簿记) */
53
+ status: 'staging' | 'archived'
54
+ /** 摘要 turns;staging 期缺省 null(归档态返回走占位文案) */
55
+ summary: ArchiveLedgerTurn[] | null
56
+ /** 原工作目录(簿记可选字段,展示备用) */
57
+ cwd?: string
58
+ /** 归档时间(ISO 字符串,消歧展示用) */
59
+ archivedAt?: string
60
+ /** 簿记文件 mtime(ms,多命中排序键——簿记终态原子重写时间即归档完成时间) */
61
+ mtime: number
62
+ }
63
+
64
+ /** summary 字段的解析结果:turns 数组 / null / 非法(undefined)。 */
65
+ function parseSummary(raw: unknown): ArchiveLedgerTurn[] | null | undefined {
66
+ if (raw === null) return null
67
+ // raw 非 null 已由上一行保证(summary 合法形态恰为 turns 数组或显式 null),此处只需挡非对象。
68
+ if (typeof raw !== 'object') return undefined
69
+ const turnsRaw = (raw as Record<string, unknown>).turns
70
+ if (!Array.isArray(turnsRaw)) return undefined
71
+ const turns: ArchiveLedgerTurn[] = []
72
+ for (const t of turnsRaw) {
73
+ if (typeof t !== 'object' || t === null) return undefined
74
+ const obj = t as Record<string, unknown>
75
+ if (typeof obj.user !== 'string') return undefined
76
+ if (obj.agent !== null && typeof obj.agent !== 'string') return undefined
77
+ turns.push({ user: obj.user, agent: obj.agent as string | null })
78
+ }
79
+ return turns
80
+ }
81
+
82
+ /**
83
+ * 解析簿记 JSON 文本(D3 消费子集)。非法形态返回 undefined 整条拒收:
84
+ * sessionId 缺失/形态非法、status 词表外、summary 结构非法——坏簿记不中断
85
+ * 扫描(调用方跳过),与 scanJsonlRecursive 的容错哲学一致。
86
+ */
87
+ export function parseArchiveLedgerText(text: string): Omit<ArchiveLedgerEntry, 'ledgerPath' | 'mtime'> | undefined {
88
+ let raw: unknown
89
+ try {
90
+ raw = JSON.parse(text)
91
+ } catch {
92
+ return undefined
93
+ }
94
+ if (typeof raw !== 'object' || raw === null) return undefined
95
+ const obj = raw as Record<string, unknown>
96
+ if (typeof obj.sessionId !== 'string' || !PI_SESSION_ID_PATTERN.test(obj.sessionId)) {
97
+ return undefined
98
+ }
99
+ if (obj.status !== 'staging' && obj.status !== 'archived') return undefined
100
+ const summary = parseSummary(obj.summary)
101
+ if (summary === undefined) return undefined
102
+ const out: Omit<ArchiveLedgerEntry, 'ledgerPath' | 'mtime'> = {
103
+ sessionId: obj.sessionId,
104
+ status: obj.status,
105
+ summary,
106
+ }
107
+ if (typeof obj.name === 'string' && obj.name.length > 0) out.name = obj.name
108
+ if (typeof obj.cwd === 'string' && obj.cwd.length > 0) out.cwd = obj.cwd
109
+ if (typeof obj.archivedAt === 'string' && obj.archivedAt.length > 0) out.archivedAt = obj.archivedAt
110
+ return out
111
+ }
112
+
113
+ /**
114
+ * 归档根扫描时整体跳过的子目录名:`assets/` 是附件三目录区(D1 映射表),
115
+ * 体积可达归档单元的大头且无簿记,递归进去只耗 IO。
116
+ */
117
+ const SKIP_DIRS_ARCHIVE = new Set(['assets'])
118
+
119
+ /**
120
+ * 递归扫描归档根下全部 `.archive.json` 簿记并解析(D7 独立簿记收集器)。
121
+ * 只收 `.archive.json` 后缀——`.jsonl`(staging 明文主文件)与 `.restoring`
122
+ * (恢复中簿记)天然排除。单个簿记读/解析失败跳过不中断(容错)。根不存在/
123
+ * 无权限 → 空数组,不抛错(roots.ts 单根失败契约)。
124
+ */
125
+ export async function scanArchiveLedgers(archiveRoot: string): Promise<ArchiveLedgerEntry[]> {
126
+ const results: ArchiveLedgerEntry[] = []
127
+
128
+ async function walk(currentDir: string): Promise<void> {
129
+ let entries: Dirent[]
130
+ try {
131
+ entries = await readdir(currentDir, { withFileTypes: true })
132
+ } catch {
133
+ return // 目录不存在/无权限 → 静默返回(容错,同 scanJsonlRecursive)
134
+ }
135
+ for (const entry of entries) {
136
+ const full = join(currentDir, entry.name)
137
+ if (entry.isDirectory()) {
138
+ if (SKIP_DIRS_ARCHIVE.has(entry.name)) continue
139
+ await walk(full)
140
+ } else if (entry.isFile() && entry.name.endsWith('.archive.json')) {
141
+ let text: string
142
+ try {
143
+ text = await readFile(full, 'utf8')
144
+ } catch {
145
+ continue // 并发删除/不可读 → 跳过该簿记
146
+ }
147
+ const parsed = parseArchiveLedgerText(text)
148
+ if (parsed === undefined) continue // 坏簿记跳过
149
+ try {
150
+ const s = await stat(full)
151
+ results.push({ ...parsed, ledgerPath: full, mtime: s.mtimeMs })
152
+ } catch {
153
+ continue
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ await walk(archiveRoot)
160
+ return results
161
+ }
162
+
163
+ // ============================================================
164
+ // 归档态渲染(D7 返回形态——硬契约:agent 必须能区分摘要与完整历史)
165
+ // ============================================================
166
+
167
+ /** 归档态头部标注(D7 字面契约)。 */
168
+ const ARCHIVED_HEADER = '状态: 已归档 (archived) — 完整内容已压缩离线,以下仅为摘要'
169
+
170
+ /** staging 态头部标注(V10:头部 + 占位句 + 名称/id 元数据)。 */
171
+ const STAGING_HEADER = '状态: 已归档 (staging) — 摘要生成中,请稍后再查'
172
+
173
+ /** 尾注恢复指引(D7 字面契约)。 */
174
+ const RESTORE_NOTE = '注: 如需完整历史,请让用户在归档列表中恢复该会话。'
175
+
176
+ /** 纯图片/附件 user 消息的占位(D4:空串 user 文本,D11 同款占位语义)。 */
177
+ const USER_ATTACHMENT_PLACEHOLDER = '[图片/附件消息]'
178
+
179
+ /** turn 无 agent 文本的占位(D4:agent null = 以工具调用结束)。 */
180
+ const AGENT_NO_TEXT_PLACEHOLDER = '(此 turn 以工具调用结束,无文本回复)'
181
+
182
+ /**
183
+ * 渲染归档态返回正文(纯函数,两态):
184
+ *
185
+ * - archived:头部标注 + 摘要 turns(`── turn N ──` 分隔,user / assistant (摘要)
186
+ * 两行,两类占位规则同 D11)+ 尾注恢复指引——**不含任何完整历史内容**
187
+ * (turns 只来自簿记 summary,agent 可区分摘要与完整历史的硬契约)。
188
+ * - staging:头部(staging 标注)+ 名称/id 元数据 + 尾注;无摘要可渲染。
189
+ */
190
+ export function formatArchivedText(entry: ArchiveLedgerEntry): string {
191
+ const lines: string[] = []
192
+ if (entry.status === 'staging') {
193
+ lines.push(`[session-reader] session: ${entry.name ?? entry.sessionId}(id: ${entry.sessionId})`)
194
+ lines.push(STAGING_HEADER)
195
+ lines.push(RESTORE_NOTE)
196
+ return lines.join('\n')
197
+ }
198
+ lines.push(`[session-reader] session: ${entry.name ?? entry.sessionId}`)
199
+ lines.push(ARCHIVED_HEADER)
200
+ const turns = entry.summary ?? []
201
+ turns.forEach((t, i) => {
202
+ lines.push(`── turn ${i + 1} ──`)
203
+ lines.push(`user: ${t.user.length > 0 ? t.user : USER_ATTACHMENT_PLACEHOLDER}`)
204
+ lines.push(`assistant (摘要): ${t.agent ?? AGENT_NO_TEXT_PLACEHOLDER}`)
205
+ })
206
+ lines.push(RESTORE_NOTE)
207
+ return lines.join('\n')
208
+ }
209
+
210
+ /** 归档多命中消歧文案(F2 同构:列候选 + 👉 更精确重试,不抛错)。 */
211
+ export function formatArchivedDisambiguation(query: string, entries: ArchiveLedgerEntry[]): string {
212
+ // mtime 倒序(最新归档在前,与 find 排序口径一致)
213
+ const sorted = [...entries].sort((a, b) => b.mtime - a.mtime)
214
+ const lines = [`多个归档 session 匹配 "${query}",按归档时间倒序:`]
215
+ for (const e of sorted) {
216
+ const when = e.archivedAt !== undefined ? `,归档于 ${e.archivedAt}` : ''
217
+ lines.push(` - ${e.name ?? '(未命名)'}(id: ${e.sessionId}${when})`)
218
+ }
219
+ lines.push('👉 用完整 session id 或更长的名称片段重试(归档 session 直接用 session 参数引用,返回摘要)。')
220
+ return lines.join('\n')
221
+ }