@zhushanwen/pi-session-reader 0.6.1 → 0.7.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 (36) hide show
  1. package/README.md +100 -0
  2. package/package.json +6 -3
  3. package/src/__tests__/entry-anchor.test.ts +226 -0
  4. package/src/__tests__/execution-tree.test.ts +6 -0
  5. package/src/__tests__/family-zcode.test.ts +255 -0
  6. package/src/__tests__/family.test.ts +1 -1
  7. package/src/__tests__/find.test.ts +23 -0
  8. package/src/__tests__/parser.test.ts +1 -2
  9. package/src/__tests__/render.test.ts +1 -2
  10. package/src/__tests__/result.test.ts +2 -2
  11. package/src/__tests__/tool-handler.test.ts +12 -8
  12. package/src/__tests__/toolcall.test.ts +1 -1
  13. package/src/__tests__/tree.test.ts +1 -1
  14. package/src/__tests__/turns.test.ts +1 -2
  15. package/src/__tests__/zcode-manifest.test.ts +361 -0
  16. package/src/__tests__/zcode-routing.test.ts +751 -0
  17. package/src/__tests__/zcode-whitelist.test.ts +119 -0
  18. package/src/core/execution-tree.ts +3 -1
  19. package/src/core/family.ts +80 -1
  20. package/src/core/render.ts +1 -1
  21. package/src/core/toolcall.ts +2 -2
  22. package/src/core/tree.ts +1 -1
  23. package/src/core/turns.ts +1 -1
  24. package/src/discovery/entry-anchor.ts +108 -0
  25. package/src/discovery/session-header.ts +30 -69
  26. package/src/discovery/subagents.ts +9 -2
  27. package/src/discovery/whitelist.ts +87 -0
  28. package/src/discovery/workflows.ts +1 -1
  29. package/src/discovery/zcode-manifest.ts +277 -0
  30. package/src/index.ts +7 -0
  31. package/src/result-action.ts +14 -5
  32. package/src/search-across.ts +1 -1
  33. package/src/tool-format.ts +455 -0
  34. package/src/tool-handler.ts +321 -325
  35. package/src/zcode-anchor-classify.ts +60 -0
  36. package/src/core/parser.ts +0 -167
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # @zhushanwen/pi-session-reader
2
+
3
+ 按语义结构读取 pi session 文件(对话历史)的工具:`session_read` 提供 11 个 action,覆盖 session 定位、fork/subagent/workflow 家族追踪、渐进式阅读(概览 → 单 turn → 全文)、全文/跨 session 搜索、素材提取、workflow run 概览、subagent 结果获取与环境自检——不需要直接读原始 `.jsonl`。
4
+
5
+ ## 提供的工具
6
+
7
+ ### `session_read`
8
+
9
+ 所有 action 共用一套参数,按 action 取用:
10
+
11
+ | action | 关键参数 | 用途 |
12
+ |--------|---------|------|
13
+ | `find` | `query`(uuid 片段 / 文件名 / name 关键词 / `"recent"`) | 定位 session;可选 `cwd` / `source`(`main` / `subagent`)/ `limit`(默认 20);零匹配返回提示不报错 |
14
+ | `family` | `session` | fork 父子 / subagent / workflow 关系;`recursive: true` 返回任意深度嵌套的执行树 |
15
+ | `outline` | `session` | turn 级概览(约 1500 token);`granularity: "entry"` 平铺到 entry、`allBranches` 含废弃侧分支 |
16
+ | `expand` | `session` + `turn`(如 `"T013"`) | 单 turn 的 entry 列表 |
17
+ | `detail` | `session` + `turns`(如 `"T013-T015"` 或 `"T013"`) | turn 全文;`includeToolResult` / `includeThinking` 控制是否包含噪音(默认省略) |
18
+ | `search` | `session` + `pattern` | 全文 grep(子串或正则);`session` 可传逗号分隔 ≤10 个完整 id 做跨 session 搜索;`scope` 过滤 `all`(默认)/ `user` / `assistant` / `toolResult` |
19
+ | `export` | `session` | 物化到文件;`format`: `outline`(默认)/ `full` / `family`;`includeToolResult` / `allBranches` 亦作用于本 action |
20
+ | `extract` | `session` + `what` | 按类型抽取素材:`user-messages` / `commands` / `files` / `commits` / `tool-results`;`tool` 按工具名过滤 commands/tool-results;可选 `turns` 限定范围(按全量分段序号,含 compaction 周期/旁支,与 outline 的 leaf turn 序号不一致) |
21
+ | `workflow` | `session` | workflow run 概览(status / budget / steps);`runId` 聚焦单个 run;step 的 call sessionId 可跳转 outline/detail |
22
+ | `result` | `session` | 取 subagent 最终结果正文(与其完成通知同内容);单个 id 或逗号分隔 ≤10 个批量;`limit` 限每条字符数(默认 8000,超长截断并附全文文件指引) |
23
+ | `doctor` | — | 环境自检:host 环境判定(纯 pi / taiji)+ 候选 session 根目录诊断;`includeSubagents: true` 附带扫描 subagent 根文件数 |
24
+
25
+ `session` 参数支持四种形态:完整 uuid、uuid 片段(如 `e6c96`)、subagent record id(`sa-xxx` 精确反查)、`.jsonl` 绝对路径(支持 `~` 前缀);`#` 前缀自动剥离。
26
+
27
+ **渐进式阅读**:`outline`(概览)→ `expand`(单 turn)→ `detail`(全文),默认省略 toolResult / thinking 噪音。不用于当前 session(宿主已提供当前会话访问),也不用于编辑 session(pi 有 `/resume` `/fork`)。
28
+
29
+ ## TUI 附加能力(仅 TUI 模式)
30
+
31
+ - `/session-pick` 命令:交互式选择当前 cwd 目录下的 session
32
+ - `#` 前缀自动补全:编辑器输入 `#` 弹出当前目录的 session 引用补全
33
+
34
+ RPC 模式(如 taiji 子进程)自动跳过 TUI 注册,`session_read` 工具本身不受影响。
35
+
36
+ ## 核心机制
37
+
38
+ - **分层**:`tool-handler.ts` 纯逻辑 handler(agentDir / 环境信号注入,零 pi 依赖,可单测)+ `index.ts` pi 注册层(registerTool + TUI 挂载)
39
+ - **多根发现与降级**:session 根按 [live](当前 session 目录)→ [default] → [legacy] 及 subagent 根多根合并扫描;`doctor` 可诊断实际生效的根
40
+ - **session 解析三形态**:绝对路径直读首行 header → `sa-` 前缀经 record manifest 精确反查 → 片段模糊匹配(多匹配返回消歧候选列表而非报错)
41
+ - **错误契约**:handler 抛出含 👉 恢复指引的 Error,pi 置 `isError: true`,模型可按指引一步修正重试
42
+ - **成本控制**:标题元数据走 pi `SessionManager.listAll`(仅当前 cwd 目录,惰性调用);search 长扫描可被 AbortSignal 中断
43
+
44
+ ## 安装
45
+
46
+ ```bash
47
+ # npm 方式(正式)
48
+ pi install npm:@zhushanwen/pi-session-reader
49
+ ```
50
+
51
+ 本地开发可用 `--extension` 直接加载:
52
+
53
+ ```bash
54
+ pi --extension <repo>/extensions/universal/session-reader/index.ts "<prompt>"
55
+ ```
56
+
57
+ ## 使用示例
58
+
59
+ ```jsonc
60
+ // 定位 session(片段匹配,多命中返回候选列表)
61
+ { "action": "find", "query": "e6c96" }
62
+
63
+ // 概览 → 深读某段 turn
64
+ { "action": "outline", "session": "e6c96" }
65
+ { "action": "detail", "session": "e6c96", "turns": "T013-T015" }
66
+
67
+ // 追踪 fork / subagent 家族(嵌套执行树)
68
+ { "action": "family", "session": "e6c96", "recursive": true }
69
+
70
+ // 取 subagent 最终结果(批量 ≤10)
71
+ { "action": "result", "session": "sa-abc123, sa-def456" }
72
+
73
+ // 跨 session 搜索用户消息
74
+ { "action": "search", "session": "<id1>,<id2>", "pattern": "登录逻辑", "scope": "user" }
75
+
76
+ // 抽取全部 bash 命令
77
+ { "action": "extract", "session": "e6c96", "what": "commands", "tool": "bash" }
78
+
79
+ // 环境自检
80
+ { "action": "doctor" }
81
+ ```
82
+
83
+ ## 文件结构
84
+
85
+ ```
86
+ session-reader/
87
+ ├── index.ts # 入口 — re-export src/index.ts
88
+ └── src/
89
+ ├── index.ts # pi 注册层(registerTool + promptGuidelines + TUI 命令/补全挂载)
90
+ ├── tool-handler.ts # 纯逻辑 handler(action 分发 + session 解析 + 各 action 编排)
91
+ ├── search-across.ts # search 管线(单 session / 跨 session)
92
+ ├── extract.ts # extract 预设(user-messages/commands/files/commits/tool-results)
93
+ ├── result-action.ts # result action(subagent 最终结果,批量 ≤10)
94
+ ├── doctor.ts # doctor action(host 环境判定 + 根目录诊断)
95
+ ├── no-match.ts # find 零匹配自检行
96
+ ├── handler-utils.ts # 低层小工具(pad/err/stripHash/turn 解析等)
97
+ ├── core/ # 解析与渲染(parser / tree / turns / family / execution-tree / workflow / render / toolcall)
98
+ ├── discovery/ # session 发现(roots / find / subagents / workflows / env / session-header)
99
+ └── tui/ # TUI 挂载(session-pick 命令 + # 补全 provider)
100
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-session-reader",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "taiji": {
@@ -16,12 +16,15 @@
16
16
  "pi-package"
17
17
  ],
18
18
  "dependencies": {
19
- "@zhushanwen/pi-ext-guards": "0.4.1"
19
+ "@zhushanwen/pi-extension-logger": "0.6.1",
20
+ "@zhushanwen/pi-ext-guards": "0.4.1",
21
+ "@zhushanwen/session-core": "0.11.0",
22
+ "@zhushanwen/zcode-session-source": "0.2.0"
20
23
  },
21
24
  "devDependencies": {
22
25
  "@vitest/coverage-v8": "^4.1.9",
23
26
  "vitest": "^4.1.8",
24
- "@zhushanwen/subagent-core": "0.10.5"
27
+ "@zhushanwen/subagent-core": "0.11.1"
25
28
  },
26
29
  "files": [
27
30
  "index.ts",
@@ -0,0 +1,226 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { tmpdir } from 'node:os'
3
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+
6
+ import { SUBAGENT_RECORD_CUSTOM_TYPE } from '@zhushanwen/subagent-core'
7
+
8
+ import { findZcodeEntryAnchor, type ZcodeAnchor } from '../discovery/entry-anchor.js'
9
+
10
+ // ============================================================
11
+ // 变体 E(设计 §4 场景 1a):manifest 缺席时 entry 兜底定位(F16 每轮覆写 → 取文件
12
+ // 顺序末条,collectLastRecordEntries 同构)。
13
+ //
14
+ // 单元边界:本文件只测「定位」(sa-id → sessionRef 锚);「读到 sess-B 行集内容」的
15
+ // 库读取断言属 U9 集成测试(场景 1a 变体 E 全量断言),此处不引入 sqlite 依赖。
16
+ // sess-A / sess-B 本身即两个可区分的锚值——取首条或合并的实现会直接失败。
17
+ // ============================================================
18
+
19
+ const DB_PATH = '/data/agent/engines/zcode/session-db/db.sqlite'
20
+
21
+ let dir: string
22
+
23
+ beforeEach(() => {
24
+ dir = mkdtempSync(join(tmpdir(), 'entry-anchor-test-'))
25
+ })
26
+ afterEach(() => {
27
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
28
+ })
29
+
30
+ /** subagent-record custom entry 行(顶层 id = entry id;data.id = record id 即 sa-id)。 */
31
+ function recordLine(
32
+ entryId: string,
33
+ saId: string,
34
+ sessionRef: Record<string, string>,
35
+ v = 1,
36
+ ): string {
37
+ return JSON.stringify({
38
+ type: 'custom',
39
+ customType: 'subagent-record',
40
+ id: entryId,
41
+ parentId: null,
42
+ data: { v, id: saId, engine: 'zcode', engineHandle: { sessionRef, poolKey: 'shared' } },
43
+ })
44
+ }
45
+
46
+ /** pi 形态 message 行(真实感填充)。 */
47
+ function messageLine(entryId: string, text: string): string {
48
+ return JSON.stringify({
49
+ type: 'message',
50
+ id: entryId,
51
+ parentId: null,
52
+ message: { role: 'user', content: text },
53
+ })
54
+ }
55
+
56
+ /** 缺 dbPath 的 sessionRef(pi record 形态:只有 sessionId 单键)。 */
57
+ const REF_NO_DBPATH = { sessionId: 'sess-pi-x' }
58
+
59
+ /** 缺 sessionId 的 sessionRef(半键)。 */
60
+ const REF_NO_SESSION_ID = { dbPath: DB_PATH }
61
+
62
+ function writeMainSession(name: string, lines: string[]): string {
63
+ mkdirSync(dir, { recursive: true })
64
+ const file = join(dir, name)
65
+ writeFileSync(file, lines.join('\n') + '\n', 'utf8')
66
+ return file
67
+ }
68
+
69
+ describe('变体 E:同 sa-id 多条 entry 取文件顺序末条(F16 覆写)', () => {
70
+ it('首条 sess-A / 末条 sess-B → 命中 sess-B 且 ≠ sess-A(取首条/合并必失败)', async () => {
71
+ const file = writeMainSession('main.jsonl', [
72
+ JSON.stringify({ type: 'session', id: 'main-1', cwd: '/tmp/proj' }),
73
+ messageLine('m1', 'dispatch subagent'),
74
+ recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
75
+ messageLine('m2', 'round 1 done'),
76
+ recordLine('e2', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
77
+ ])
78
+
79
+ const anchor = await findZcodeEntryAnchor([file], 'sa-1')
80
+
81
+ // 非平凡断言:两个不同锚值使「末条 vs 首条」结果可区分
82
+ expect(anchor).toBeDefined()
83
+ expect(anchor?.sessionId).toBe('sess-B')
84
+ expect(anchor?.sessionId).not.toBe('sess-A')
85
+ expect(anchor?.dbPath).toBe(DB_PATH)
86
+ })
87
+
88
+ it('多条不同 sa-id 交错:只命中目标 id 的末条,不串锚', async () => {
89
+ const file = writeMainSession('main.jsonl', [
90
+ JSON.stringify({ type: 'session', id: 'main-1' }),
91
+ recordLine('e1', 'sa-1', { sessionId: 'sa1-r1', dbPath: DB_PATH }),
92
+ recordLine('e2', 'sa-2', { sessionId: 'sa2-r1', dbPath: DB_PATH }),
93
+ recordLine('e3', 'sa-1', { sessionId: 'sa1-r2', dbPath: DB_PATH }),
94
+ recordLine('e4', 'sa-2', { sessionId: 'sa2-r2', dbPath: DB_PATH }),
95
+ recordLine('e5', 'sa-1', { sessionId: 'sa1-r3', dbPath: DB_PATH }),
96
+ ])
97
+
98
+ expect((await findZcodeEntryAnchor([file], 'sa-1'))?.sessionId).toBe('sa1-r3')
99
+ expect((await findZcodeEntryAnchor([file], 'sa-2'))?.sessionId).toBe('sa2-r2')
100
+ })
101
+ })
102
+
103
+ describe('行级容错(best-effort,不因一行坏 JSON 失败整文件)', () => {
104
+ it('坏行穿插 + 末行半行:正常命中末条完整条目', async () => {
105
+ const file = writeMainSession('main.jsonl', [
106
+ JSON.stringify({ type: 'session', id: 'main-1' }),
107
+ 'not-json-at-all{{{',
108
+ recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
109
+ '{"type":"custom","customType":"subagent-record","data":{"broken"',
110
+ ])
111
+
112
+ const anchor = await findZcodeEntryAnchor([file], 'sa-1')
113
+ expect(anchor?.sessionId).toBe('sess-A')
114
+ })
115
+ })
116
+
117
+ describe('形状校验窄而严:缺键条目不算命中,继续找更早条目', () => {
118
+ it('末条 sessionRef 缺 dbPath → 命中更早的完整条目', async () => {
119
+ const file = writeMainSession('main.jsonl', [
120
+ JSON.stringify({ type: 'session', id: 'main-1' }),
121
+ recordLine('e1', 'sa-1', { sessionId: 'sess-EARLY', dbPath: DB_PATH }),
122
+ recordLine('e2', 'sa-1', REF_NO_DBPATH),
123
+ ])
124
+
125
+ const anchor = await findZcodeEntryAnchor([file], 'sa-1')
126
+ expect(anchor?.sessionId).toBe('sess-EARLY')
127
+ })
128
+
129
+ it('全部条目缺双键 → not-found(undefined)', async () => {
130
+ const file = writeMainSession('main.jsonl', [
131
+ JSON.stringify({ type: 'session', id: 'main-1' }),
132
+ recordLine('e1', 'sa-1', REF_NO_DBPATH),
133
+ recordLine('e2', 'sa-1', REF_NO_SESSION_ID),
134
+ ])
135
+
136
+ expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
137
+ })
138
+
139
+ it('sessionRef 键为空串 → 不算命中(非空 string 才算锚)', async () => {
140
+ const file = writeMainSession('main.jsonl', [
141
+ JSON.stringify({ type: 'session', id: 'main-1' }),
142
+ recordLine('e1', 'sa-1', { sessionId: '', dbPath: DB_PATH }),
143
+ recordLine('e2', 'sa-1', { sessionId: 'sess-A', dbPath: '' }),
144
+ ])
145
+
146
+ expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
147
+ })
148
+
149
+ it('data.v ≠ 1 的条目不算命中,更早 v=1 条目仍可命中', async () => {
150
+ const file = writeMainSession('main.jsonl', [
151
+ JSON.stringify({ type: 'session', id: 'main-1' }),
152
+ recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
153
+ recordLine('e2', 'sa-1', { sessionId: 'sess-V2', dbPath: DB_PATH }, 2),
154
+ ])
155
+
156
+ const anchor = await findZcodeEntryAnchor([file], 'sa-1')
157
+ expect(anchor?.sessionId).toBe('sess-A')
158
+ expect(anchor?.sessionId).not.toBe('sess-V2')
159
+ })
160
+ })
161
+
162
+ describe('无关条目不命中', () => {
163
+ it('非 subagent-record 的 custom entry、id 不匹配的 record 均跳过', async () => {
164
+ const todoLine = JSON.stringify({
165
+ type: 'custom',
166
+ customType: 'todo',
167
+ id: 't1',
168
+ parentId: null,
169
+ data: { v: 1, id: 'sa-1', engineHandle: { sessionRef: { sessionId: 'sess-TODO', dbPath: DB_PATH } } },
170
+ })
171
+ const file = writeMainSession('main.jsonl', [
172
+ JSON.stringify({ type: 'session', id: 'main-1' }),
173
+ todoLine,
174
+ recordLine('e1', 'sa-other', { sessionId: 'sess-OTHER', dbPath: DB_PATH }),
175
+ ])
176
+
177
+ expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
178
+ expect((await findZcodeEntryAnchor([file], 'sa-other'))?.sessionId).toBe('sess-OTHER')
179
+ })
180
+ })
181
+
182
+ describe('候选文件列表:顺序回退 + 命中即止(越界语义由调用方承载)', () => {
183
+ it('前一候选未命中 → 后一候选命中', async () => {
184
+ const empty = writeMainSession('empty.jsonl', [JSON.stringify({ type: 'session', id: 's0' })])
185
+ const withAnchor = writeMainSession('with-anchor.jsonl', [
186
+ JSON.stringify({ type: 'session', id: 'main-2' }),
187
+ recordLine('e1', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
188
+ ])
189
+
190
+ const anchor: ZcodeAnchor | undefined = await findZcodeEntryAnchor([empty, withAnchor], 'sa-1')
191
+ expect(anchor?.sessionId).toBe('sess-B')
192
+ })
193
+
194
+ it('前一候选命中即止:不跨文件合并(后一候选的更新条目不覆盖)', async () => {
195
+ const first = writeMainSession('first.jsonl', [
196
+ JSON.stringify({ type: 'session', id: 'main-1' }),
197
+ recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
198
+ ])
199
+ const second = writeMainSession('second.jsonl', [
200
+ JSON.stringify({ type: 'session', id: 'main-2' }),
201
+ recordLine('e2', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
202
+ ])
203
+
204
+ const anchor = await findZcodeEntryAnchor([first, second], 'sa-1')
205
+ expect(anchor?.sessionId).toBe('sess-A')
206
+ expect(anchor?.sessionId).not.toBe('sess-B')
207
+ })
208
+
209
+ it('候选不可读(不存在)→ 跳过继续,全不可读 → not-found', async () => {
210
+ const withAnchor = writeMainSession('with-anchor.jsonl', [
211
+ JSON.stringify({ type: 'session', id: 'main-1' }),
212
+ recordLine('e1', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
213
+ ])
214
+
215
+ expect((await findZcodeEntryAnchor([join(dir, 'missing.jsonl'), withAnchor], 'sa-1'))?.sessionId).toBe('sess-B')
216
+ expect(await findZcodeEntryAnchor([join(dir, 'missing.jsonl')], 'sa-1')).toBeUndefined()
217
+ expect(await findZcodeEntryAnchor([], 'sa-1')).toBeUndefined()
218
+ })
219
+ })
220
+
221
+ describe('协议字面量漂移守卫(跨包契约)', () => {
222
+ it('entry-anchor 本地 customType 字面量 === subagent-core 写侧单源', () => {
223
+ // 源码本地持有磁盘协议字符串(生产依赖面不引 subagent-core),此处锚定两侧同值
224
+ expect(SUBAGENT_RECORD_CUSTOM_TYPE).toBe('subagent-record')
225
+ })
226
+ })
@@ -596,6 +596,12 @@ describe('buildExecutionTree - fixture', () => {
596
596
  const tree2 = await buildExecutionTree(MAIN, dir)
597
597
  expect(tree2.root.sessionFile).toBeUndefined()
598
598
  expect(tree2.totalNodes).toBe(1)
599
+
600
+ // 空串哨兵归一:mainSessionFile=''(zcode family 路由反查未命中的降级形态)与
601
+ // 不传等价——root 节点 details 不携带 sessionFile:"" 这类非路径占位值
602
+ const tree3 = await buildExecutionTree(MAIN, dir, '')
603
+ expect(tree3.root.sessionFile).toBeUndefined()
604
+ expect(tree3.totalNodes).toBe(1)
599
605
  })
600
606
 
601
607
  it('TC-m3b-max-depth:21+ 层 parentRecordId 链在 MAX_DEPTH(20) 截断,truncated=true 不抛错(MF-4)', async () => {
@@ -0,0 +1,255 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { tmpdir } from 'node:os'
3
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { DatabaseSync } from 'node:sqlite'
6
+
7
+ import { buildFamilyIndex, resolveFamily, buildZcodeSubagentsByRoot } from '../core/family.js'
8
+ import type { Entry, ZcodeFamilyNode } from '../core/family.js'
9
+ import { listZcodeManifests } from '../discovery/zcode-manifest.js'
10
+
11
+ // ============================================================
12
+ // family zcode 分支(design session-reader-shared-core §3.3 D5-1 / §4 场景 2)。
13
+ //
14
+ // 判据(两项、不开库):锚可解析(枚举谓词已过)∧ 库文件存在(stat)→ cleanedUp=false;
15
+ // 缺一 → true(GC 语义,不报错不抛异常)。刻意不含「库内 session 查询」第三项——
16
+ // 静息态 CANTOPEN 会误判活会话 + 列表视图不开库;反向断言(库文件在但库内 session
17
+ // 行被删 → 仍 false)即「列表视图不开库」的分工声明断言。
18
+ //
19
+ // 与 pi 判据不同构(D5-1 显式声明):pi = fileStats JSONL 存在性;zcode 节点无
20
+ // JSONL 文件,按「库可达」判。pi 侧既有 fixture 行为零变化由 family.test.ts 全绿守卫
21
+ // (本文件零改动 family.test.ts——更强的零涟漪证据)。
22
+ // ============================================================
23
+
24
+ const ROOT = 'root-session-1'
25
+
26
+ let dir: string
27
+
28
+ beforeEach(() => {
29
+ dir = mkdtempSync(join(tmpdir(), 'family-zcode-test-'))
30
+ })
31
+ afterEach(() => {
32
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
33
+ })
34
+
35
+ function header(id: string): Entry {
36
+ return { type: 'session', id, parentId: null, cwd: '/proj' }
37
+ }
38
+
39
+ /** zcode 节点(core 层判据输入,与 ZcodeManifestRecord 结构兼容) */
40
+ function zcodeNode(opts: Partial<ZcodeFamilyNode> & { id: string }): ZcodeFamilyNode {
41
+ return {
42
+ rootSessionId: ROOT,
43
+ slug: 'worker',
44
+ dbPath: join(dir, 'db.sqlite'),
45
+ dbFileExists: false,
46
+ ...opts,
47
+ }
48
+ }
49
+
50
+ /** 在 <agentDir>/subagents/<slug>/records/ 下写 zcode manifest(F21 磁盘真实形态:无 sessionFile 键) */
51
+ function writeZcodeManifest(
52
+ agentDir: string,
53
+ saId: string,
54
+ sessionRef: Record<string, string>,
55
+ ): void {
56
+ const recordsDir = join(agentDir, 'subagents', 'proj-a', 'records')
57
+ mkdirSync(recordsDir, { recursive: true })
58
+ writeFileSync(
59
+ join(recordsDir, `${saId}.json`),
60
+ JSON.stringify({
61
+ id: saId,
62
+ rootSessionId: ROOT,
63
+ agentName: 'worker',
64
+ status: 'running',
65
+ engine: 'zcode',
66
+ engineHandle: { sessionRef, poolKey: 'shared' },
67
+ }),
68
+ 'utf8',
69
+ )
70
+ }
71
+
72
+ /** 造一个真实 sqlite 库(session/message/part 三级表先例形态)并插入 sess 行 */
73
+ function createFixtureDb(dbPath: string, sessionId: string): void {
74
+ const db = new DatabaseSync(dbPath)
75
+ try {
76
+ db.exec('CREATE TABLE session (id TEXT PRIMARY KEY, title TEXT)')
77
+ db.exec('CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, sequence INTEGER)')
78
+ db.exec('CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT)')
79
+ db.prepare('INSERT INTO session (id, title) VALUES (?, ?)').run(sessionId, 'fixture')
80
+ } finally {
81
+ db.close()
82
+ }
83
+ }
84
+
85
+ describe('core:buildZcodeSubagentsByRoot / buildFamilyIndex 的 zcode 分支(D5-1 两态)', () => {
86
+ it('库文件存在 → cleanedUp=false;库文件缺席 → cleanedUp=true(缺一即 true,不抛错)', () => {
87
+ const index = buildFamilyIndex([header(ROOT)], [], new Map(), [
88
+ zcodeNode({ id: 'sa-alive', dbFileExists: true }),
89
+ zcodeNode({ id: 'sa-gone', dbFileExists: false }),
90
+ ])
91
+ const family = resolveFamily(ROOT, index)
92
+
93
+ const alive = family.subagents.find((s) => s.sessionId === 'sa-alive')
94
+ const gone = family.subagents.find((s) => s.sessionId === 'sa-gone')
95
+ expect(alive?.cleanedUp).toBe(false)
96
+ expect(gone?.cleanedUp).toBe(true)
97
+ })
98
+
99
+ it('zcode 节点按 rootSessionId 挂载,隔代场景与 pi 同链合并(resolveFamily 消费同一 subagentsByRoot)', () => {
100
+ const FORK = 'fork-session-1'
101
+ const headers = [header(ROOT), { ...header(FORK), parentSession: `/s/_${ROOT}.jsonl` }]
102
+ const index = buildFamilyIndex(headers, [], new Map(), [
103
+ zcodeNode({ id: 'sa-deep', rootSessionId: FORK }),
104
+ ])
105
+ // Q1 隔代:zcode subagent 挂在 fork 子代下,从 ROOT resolve 仍可见
106
+ expect(resolveFamily(ROOT, index).subagents.map((s) => s.sessionId)).toContain('sa-deep')
107
+ })
108
+
109
+ it('不传 zcodeNodes / 传空数组 → pi 行为不变(零涟漪等价)', () => {
110
+ const piIdent: Entry = {
111
+ type: 'custom',
112
+ id: 'sa-pi',
113
+ parentId: null,
114
+ customType: 'subagent-identity',
115
+ data: { rootSessionId: ROOT, slug: 'pi-sub' },
116
+ }
117
+ const stats = new Map([[ROOT, { mtime: 1, size: 2 }], ['sa-pi', { mtime: 3, size: 4 }]])
118
+ const without = buildFamilyIndex([header(ROOT)], [piIdent], stats)
119
+ const withEmpty = buildFamilyIndex([header(ROOT)], [piIdent], stats, [])
120
+ const a = resolveFamily(ROOT, without)
121
+ const b = resolveFamily(ROOT, withEmpty)
122
+ expect(b.subagents).toEqual(a.subagents)
123
+ expect(b.subagents[0]?.cleanedUp).toBe(false) // pi 判据:fileStats 命中
124
+ })
125
+
126
+ it('zcode 节点与 pi 孤儿节点并存:判据互不污染(不同构声明断言)', () => {
127
+ // pi 孤儿:identity 在但 fileStats 不含 → cleanedUp=true(JSONL GC)
128
+ const piOrphan: Entry = {
129
+ type: 'custom',
130
+ id: 'sa-pi-orphan',
131
+ parentId: null,
132
+ customType: 'subagent-identity',
133
+ data: { rootSessionId: ROOT, slug: 'orphan' },
134
+ }
135
+ const index = buildFamilyIndex([header(ROOT)], [piOrphan], new Map(), [
136
+ zcodeNode({ id: 'sa-zcode-gone', dbFileExists: false }),
137
+ ])
138
+ const subs = resolveFamily(ROOT, index).subagents
139
+ // 两节点都 cleanedUp=true,但判据来源不同:pi 走 fileStats,zcode 走 dbFileExists
140
+ expect(subs.find((s) => s.sessionId === 'sa-pi-orphan')?.cleanedUp).toBe(true)
141
+ expect(subs.find((s) => s.sessionId === 'sa-zcode-gone')?.cleanedUp).toBe(true)
142
+ })
143
+
144
+ it('SubagentRef 字段映射:富字段透传、sessionFile 不设、mtime/sizeBytes 占位 0', () => {
145
+ const byRoot = buildZcodeSubagentsByRoot([
146
+ zcodeNode({
147
+ id: 'sa-rich',
148
+ slug: 'rich-slug',
149
+ agentName: 'explorer',
150
+ task: 'do research',
151
+ model: 'glm-5.3',
152
+ status: 'closed',
153
+ dbFileExists: true,
154
+ }),
155
+ ])
156
+ const ref = byRoot.get(ROOT)?.[0]
157
+ expect(ref?.slug).toBe('rich-slug')
158
+ expect(ref?.agentName).toBe('explorer')
159
+ expect(ref?.task).toBe('do research')
160
+ expect(ref?.model).toBe('glm-5.3')
161
+ expect(ref?.status).toBe('closed')
162
+ expect(ref?.sessionFile).toBeUndefined() // zcode 无 session.jsonl
163
+ expect(ref?.mtime).toBe(0)
164
+ expect(ref?.sizeBytes).toBe(0)
165
+ })
166
+ })
167
+
168
+ describe('集成:listZcodeManifests → buildFamilyIndex → resolveFamily(场景 2 两态 + 反向断言)', () => {
169
+ it('库可达态(dbPath 指向真实 fixture 库)→ cleanedUp=false', async () => {
170
+ const dbPath = join(dir, 'isolated.sqlite')
171
+ createFixtureDb(dbPath, 'sess_live')
172
+ writeZcodeManifest(dir, 'sa-live', { sessionId: 'sess_live', dbPath })
173
+
174
+ const nodes = await listZcodeManifests(dir)
175
+ expect(nodes).toHaveLength(1)
176
+ expect(nodes[0]?.dbFileExists).toBe(true)
177
+
178
+ const family = resolveFamily(ROOT, buildFamilyIndex([header(ROOT)], [], new Map(), nodes))
179
+ const sub = family.subagents.find((s) => s.sessionId === 'sa-live')
180
+ expect(sub?.cleanedUp).toBe(false)
181
+ })
182
+
183
+ it('库不存在态(dbPath 指向不存在路径)→ cleanedUp=true(GC 语义,不报错)', async () => {
184
+ writeZcodeManifest(dir, 'sa-gone', {
185
+ sessionId: 'sess_gone',
186
+ dbPath: join(dir, 'subagents', 'proj-a', 'missing.sqlite'),
187
+ })
188
+
189
+ const nodes = await listZcodeManifests(dir)
190
+ expect(nodes[0]?.dbFileExists).toBe(false)
191
+
192
+ const family = resolveFamily(ROOT, buildFamilyIndex([header(ROOT)], [], new Map(), nodes))
193
+ expect(family.subagents.find((s) => s.sessionId === 'sa-gone')?.cleanedUp).toBe(true)
194
+ })
195
+
196
+ it('反向断言:库文件在但库内 session 行被删 → 仍 cleanedUp=false(列表视图不开库的分工声明)', async () => {
197
+ // 分工(D5-1):库内 session 已被 zcode GC 的情形由 read 路径 zcode_session_not_found
198
+ // 权威承接;family 只 stat 文件存在性。本用例删行留文件,断言 family 不受库内容影响。
199
+ const dbPath = join(dir, 'gc.sqlite')
200
+ createFixtureDb(dbPath, 'sess_victim')
201
+ writeZcodeManifest(dir, 'sa-gced', { sessionId: 'sess_victim', dbPath })
202
+
203
+ // 前置核实:fixture 库里确实有该 session 行(反向断言的非平凡性前提)
204
+ {
205
+ const db = new DatabaseSync(dbPath)
206
+ try {
207
+ const row = db.prepare('SELECT COUNT(*) AS c FROM session WHERE id = ?').get('sess_victim') as
208
+ | { c: number }
209
+ | undefined
210
+ expect(row?.c).toBe(1)
211
+ } finally {
212
+ db.close()
213
+ }
214
+ }
215
+
216
+ // 删库内 session 行(含 message/part 级联),db 文件保留
217
+ {
218
+ const db = new DatabaseSync(dbPath)
219
+ try {
220
+ db.prepare('DELETE FROM part WHERE session_id = ?').run('sess_victim')
221
+ db.prepare('DELETE FROM message WHERE session_id = ?').run('sess_victim')
222
+ db.prepare('DELETE FROM session WHERE id = ?').run('sess_victim')
223
+ } finally {
224
+ db.close()
225
+ }
226
+ }
227
+
228
+ const nodes = await listZcodeManifests(dir)
229
+ expect(nodes[0]?.dbFileExists).toBe(true) // 只 stat:文件在即 true,不看内容
230
+
231
+ const family = resolveFamily(ROOT, buildFamilyIndex([header(ROOT)], [], new Map(), nodes))
232
+ expect(family.subagents.find((s) => s.sessionId === 'sa-gced')?.cleanedUp).toBe(false)
233
+ })
234
+
235
+ it('坏 manifest 与缺库节点混排 → 好节点照常输出(单节点不可达不炸整屏)', async () => {
236
+ const dbPath = join(dir, 'ok.sqlite')
237
+ createFixtureDb(dbPath, 'sess_ok')
238
+ writeZcodeManifest(dir, 'sa-ok', { sessionId: 'sess_ok', dbPath })
239
+ // 缺库节点:合法 zcode 形态(dbFileExists=false,不抛错),不剔除出列表
240
+ writeZcodeManifest(dir, 'sa-broken-manifest', { sessionId: 'sess_x', dbPath: '/nonexistent/x.sqlite' })
241
+ // 坏 JSON manifest:枚举跳过(不中断)
242
+ writeFileSync(
243
+ join(dir, 'subagents', 'proj-a', 'records', 'sa-corrupt.json'),
244
+ '{{{broken',
245
+ 'utf8',
246
+ )
247
+
248
+ const nodes = await listZcodeManifests(dir)
249
+ expect(nodes.map((n) => n.id).sort()).toEqual(['sa-broken-manifest', 'sa-ok'])
250
+
251
+ const family = resolveFamily(ROOT, buildFamilyIndex([header(ROOT)], [], new Map(), nodes))
252
+ expect(family.subagents.find((s) => s.sessionId === 'sa-ok')?.cleanedUp).toBe(false)
253
+ expect(family.subagents.find((s) => s.sessionId === 'sa-broken-manifest')?.cleanedUp).toBe(true)
254
+ })
255
+ })
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { buildFamilyIndex, resolveFamily } from '../core/family.js'
3
- import type { Entry } from '../core/parser.js'
3
+ import type { Entry } from '@zhushanwen/session-core'
4
4
 
5
5
  // ---- fixture 常量(映射 design §3.3 D-7 Q1 真实场景)----
6
6
  const ROOT = '019fe620' // 家族根(无 parentSession)
@@ -186,6 +186,29 @@ describe('findSessions', () => {
186
186
  expect(matches[0].cwd).toBe('/proj-a')
187
187
  })
188
188
 
189
+ it('缺 cwd 的 header 进候选集(谓词不统一负面锚,design §4 负面行为验证)', async () => {
190
+ // header 行只有 id(无 cwd/timestamp):runtime 导入侧「id+cwd 非空」严谓词会拒收,
191
+ // reader 宽谓词(仅要求 type==='session' ∧ id 为 string)必须照常收录——基座化
192
+ // (readFirstJsonlLine 改指 session-core)不得顺手统一谓词,本用例即机器锚。
193
+ const headerPath = join(slugDir, 'no-cwd.jsonl')
194
+ await mkdir(slugDir, { recursive: true })
195
+ await writeFile(headerPath, JSON.stringify({ type: 'session', id: '019e0001-no-cwd' }) + '\n')
196
+ await makeSession(slugDir, { name: 'with-cwd.jsonl', id: '019e0002-with-cwd', cwd: '/demo' })
197
+
198
+ // find 结果:缺 cwd header 命中,cwd 归一化为空串(buildCandidate 的 header.cwd ?? '')
199
+ const { matches } = await findSessions('no-cwd', agentDir)
200
+ expect(matches).toHaveLength(1)
201
+ expect(matches[0].sessionId).toBe('019e0001-no-cwd')
202
+ expect(matches[0].cwd).toBe('')
203
+
204
+ // 候选枚举(recent 路径枚举全部候选):缺 cwd header 未被候选集排除
205
+ const recent = await findSessions('recent', agentDir)
206
+ expect(recent.matches.map((m) => m.sessionId).sort()).toEqual([
207
+ '019e0001-no-cwd',
208
+ '019e0002-with-cwd',
209
+ ])
210
+ })
211
+
189
212
  it('limit 截断:truncated 标记正确', async () => {
190
213
  // 5 个文件 id 都含 "common",uuid 片段 "common" 全匹配
191
214
  for (let i = 0; i < 5; i++) {
@@ -1,6 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { parseSessionContent, parseSessionFile } from '../core/parser.js'
3
- import type { Entry } from '../core/parser.js'
2
+ import { parseSessionContent, parseSessionFile, type Entry } from '@zhushanwen/session-core'
4
3
  import { REAL_SESSION, HAS_REAL_SESSION } from './real-data.js'
5
4
 
6
5
  /** 构造单行 JSONL entry 字符串 */