@zhushanwen/pi-session-reader 0.12.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-session-reader",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "taiji": {
@@ -16,7 +16,7 @@
16
16
  "pi-package"
17
17
  ],
18
18
  "dependencies": {
19
- "@zhushanwen/extension-protocol": "0.22.0",
19
+ "@zhushanwen/extension-protocol": "0.23.0",
20
20
  "@zhushanwen/pi-ext-guards": "0.5.0",
21
21
  "@zhushanwen/pi-extension-logger": "0.6.2",
22
22
  "@zhushanwen/session-core": "0.12.0",
@@ -25,7 +25,7 @@
25
25
  "devDependencies": {
26
26
  "@vitest/coverage-v8": "^4.1.9",
27
27
  "vitest": "^4.1.8",
28
- "@zhushanwen/subagent-core": "1.6.0"
28
+ "@zhushanwen/subagent-core": "1.6.1"
29
29
  },
30
30
  "files": [
31
31
  "index.ts",
@@ -0,0 +1,447 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { tmpdir } from 'node:os'
3
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
4
+ import { join, dirname } from 'node:path'
5
+ import {
6
+ parseArchiveLedgerText,
7
+ scanArchiveLedgers,
8
+ formatArchivedText,
9
+ formatArchivedDisambiguation,
10
+ resolveArchiveDir,
11
+ type ArchiveLedgerEntry,
12
+ } from '../discovery/archive-ledger.js'
13
+ import { resolveSessionRoots } from '../discovery/roots.js'
14
+ import { handleSessionRead } from '../tool-handler.js'
15
+ import { FIX_E6 } from './fixtures.js'
16
+
17
+ /**
18
+ * [session-archive D7] session-reader 归档根感知测试:簿记收集器(收 .archive.json
19
+ * 不收 .jsonl / 字段解析)+ 第五根「存在才纳入」+ 归档态返回形态(头部标注 /
20
+ * 摘要 turns / 尾注 / staging 占位)+ 无匹配路径回归。
21
+ *
22
+ * 全部用例数据 = mkdtemp 自建自删的最小归档目录(不触真实数据目录,测试防线纪律)。
23
+ */
24
+
25
+ /** 真实形态的 cwd 编码目录名(encodeCwd:`--` 开头 `--` 结尾) */
26
+ const SLUG = '--Users-foo--'
27
+
28
+ /** 第二归档条目 id(多命中消歧用,合法 uuid 形态) */
29
+ const SID2 = '0aaaaaaa-bbbb-7ccc-dddd-0000000000a2'
30
+
31
+ /** 完整 archived 簿记样本(D3 schema 全字段;overrides 覆盖单测变化点)。 */
32
+ function archivedLedger(overrides: Record<string, unknown> = {}): Record<string, unknown> {
33
+ return {
34
+ sessionId: FIX_E6,
35
+ name: 'v0.10 renderer 重构',
36
+ originalFile: '2026-10-19T10-00-00_abc.jsonl',
37
+ cwd: '/Users/foo/proj',
38
+ archivedAt: '2026-10-19T12:00:00.000Z',
39
+ rawBytes: 1000,
40
+ compressedBytes: 200,
41
+ codec: 'zstd-3',
42
+ status: 'archived',
43
+ sha256: 'deadbeef',
44
+ summary: {
45
+ turns: [
46
+ { user: 'v0.10 的 renderer 重构开始,先出分层方案。', agent: '六层分层:Shell / Workspace。' },
47
+ { user: '', agent: null },
48
+ ],
49
+ },
50
+ ...overrides,
51
+ }
52
+ }
53
+
54
+ /** 写一份归档簿记(archive/<slug>/<base>.archive.json),返回其绝对路径。 */
55
+ async function writeLedger(
56
+ agentDir: string,
57
+ base: string,
58
+ ledger: Record<string, unknown>,
59
+ slug = SLUG,
60
+ ): Promise<string> {
61
+ const dir = join(agentDir, 'archive', slug)
62
+ await mkdir(dir, { recursive: true })
63
+ const path = join(dir, `${base}.archive.json`)
64
+ await writeFile(path, JSON.stringify(ledger), 'utf8')
65
+ return path
66
+ }
67
+
68
+ /** 写 .jsonl fixture(父目录不存在自建),默认合法 session header。 */
69
+ async function writeJsonl(path: string, content = '{"type":"session","id":"x"}\n'): Promise<void> {
70
+ await mkdir(dirname(path), { recursive: true })
71
+ await writeFile(path, content)
72
+ }
73
+
74
+ /** 归档条目(渲染/消歧单测的直造形态,绕过 fs)。 */
75
+ function entry(overrides: Partial<ArchiveLedgerEntry> = {}): ArchiveLedgerEntry {
76
+ return {
77
+ ledgerPath: '/tmp/agent/archive/--Users-foo--/a.archive.json',
78
+ sessionId: FIX_E6,
79
+ name: 'v0.10 renderer 重构',
80
+ status: 'archived',
81
+ summary: [
82
+ { user: '先出分层方案。', agent: '六层分层。' },
83
+ { user: '', agent: null },
84
+ ],
85
+ archivedAt: '2026-10-19T12:00:00.000Z',
86
+ mtime: 1_000,
87
+ ...overrides,
88
+ }
89
+ }
90
+
91
+ // ============================================================
92
+ // 簿记解析(parseArchiveLedgerText:字段解析 / 非法整条拒收)
93
+ // ============================================================
94
+
95
+ describe('parseArchiveLedgerText(D3 消费子集)', () => {
96
+ it('合法 archived 簿记解析全部消费字段', () => {
97
+ const parsed = parseArchiveLedgerText(JSON.stringify(archivedLedger()))
98
+ expect(parsed).toBeDefined()
99
+ expect(parsed!.sessionId).toBe(FIX_E6)
100
+ expect(parsed!.name).toBe('v0.10 renderer 重构')
101
+ expect(parsed!.status).toBe('archived')
102
+ expect(parsed!.summary).toEqual([
103
+ { user: 'v0.10 的 renderer 重构开始,先出分层方案。', agent: '六层分层:Shell / Workspace。' },
104
+ { user: '', agent: null },
105
+ ])
106
+ expect(parsed!.cwd).toBe('/Users/foo/proj')
107
+ expect(parsed!.archivedAt).toBe('2026-10-19T12:00:00.000Z')
108
+ })
109
+
110
+ it('staging 簿记:summary null + name 缺省可选', () => {
111
+ const parsed = parseArchiveLedgerText(
112
+ JSON.stringify(archivedLedger({ status: 'staging', summary: null, name: undefined })),
113
+ )
114
+ expect(parsed).toBeDefined()
115
+ expect(parsed!.status).toBe('staging')
116
+ expect(parsed!.summary).toBeNull()
117
+ expect(parsed!.name).toBeUndefined()
118
+ })
119
+
120
+ it('非法形态整条拒收(undefined):坏 JSON / 缺 sessionId / id 形态非法 / status 词表外 / summary 结构非法', () => {
121
+ expect(parseArchiveLedgerText('{not json')).toBeUndefined()
122
+ expect(parseArchiveLedgerText(JSON.stringify({ status: 'archived', summary: null }))).toBeUndefined()
123
+ // btw 虚拟 id 冒号形态(禁 `:`)拒收
124
+ expect(
125
+ parseArchiveLedgerText(JSON.stringify(archivedLedger({ sessionId: 'btw:0aaa' }))),
126
+ ).toBeUndefined()
127
+ expect(
128
+ parseArchiveLedgerText(JSON.stringify(archivedLedger({ status: 'restoring' }))),
129
+ ).toBeUndefined()
130
+ // summary 非 null 非 { turns } / turns 元素 agent 类型非法
131
+ expect(
132
+ parseArchiveLedgerText(JSON.stringify(archivedLedger({ summary: { turns: 'x' } }))),
133
+ ).toBeUndefined()
134
+ expect(
135
+ parseArchiveLedgerText(
136
+ JSON.stringify(
137
+ archivedLedger({ summary: { turns: [{ user: 'u', agent: 42 }] } }),
138
+ ),
139
+ ),
140
+ ).toBeUndefined()
141
+ })
142
+ })
143
+
144
+ // ============================================================
145
+ // 簿记收集器(scanArchiveLedgers:收 .archive.json 不收 .jsonl)
146
+ // ============================================================
147
+
148
+ describe('scanArchiveLedgers(D7 独立收集契约)', () => {
149
+ let agentDir: string
150
+
151
+ beforeEach(async () => {
152
+ agentDir = join(await mkdtemp(join(tmpdir(), 'archive-scan-')), 'agent')
153
+ })
154
+ afterEach(async () => {
155
+ await rm(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
156
+ })
157
+
158
+ it('收 archive/<encodeCwd>/ 下 .archive.json 并解析字段(含 mtime)', async () => {
159
+ await writeLedger(agentDir, 'a', archivedLedger())
160
+ const entries = await scanArchiveLedgers(resolveArchiveDir(agentDir))
161
+ expect(entries).toHaveLength(1)
162
+ expect(entries[0]!.sessionId).toBe(FIX_E6)
163
+ expect(entries[0]!.status).toBe('archived')
164
+ expect(entries[0]!.ledgerPath.endsWith(join('archive', SLUG, 'a.archive.json'))).toBe(true)
165
+ expect(entries[0]!.mtime).toBeGreaterThan(0)
166
+ })
167
+
168
+ it('不收 .jsonl:staging 明文主文件不进收集(硬契约——收了会被当在场 session)', async () => {
169
+ await writeLedger(agentDir, 'a', archivedLedger({ status: 'staging', summary: null }))
170
+ // staging 期归档目录内的明文主文件 + 压缩体
171
+ await writeJsonl(join(agentDir, 'archive', SLUG, '2026-10-19T10-00-00_abc.jsonl'))
172
+ await writeJsonl(join(agentDir, 'archive', SLUG, '2026-10-19T10-00-00_abc.jsonl.zst'), 'x')
173
+ const entries = await scanArchiveLedgers(resolveArchiveDir(agentDir))
174
+ expect(entries).toHaveLength(1)
175
+ expect(entries[0]!.ledgerPath.endsWith('.archive.json')).toBe(true)
176
+ })
177
+
178
+ it('.restoring 后缀簿记不收(恢复中,D9 不在列表)', async () => {
179
+ const path = await writeLedger(agentDir, 'a', archivedLedger())
180
+ const restoringPath = path.replace(/\.archive\.json$/, '.restoring')
181
+ await writeFile(restoringPath, JSON.stringify(archivedLedger()), 'utf8')
182
+ const entries = await scanArchiveLedgers(resolveArchiveDir(agentDir))
183
+ expect(entries).toHaveLength(1)
184
+ expect(entries[0]!.ledgerPath).toBe(path)
185
+ })
186
+
187
+ it('assets 子目录不递归(附件区无簿记,只耗 IO);深层簿记照收', async () => {
188
+ await writeLedger(agentDir, 'a', archivedLedger())
189
+ await mkdir(join(agentDir, 'archive', SLUG, 'assets', FIX_E6, 'attachments'), { recursive: true })
190
+ await writeFile(
191
+ join(agentDir, 'archive', SLUG, 'assets', FIX_E6, 'attachments', 'img.png'),
192
+ 'x',
193
+ )
194
+ const entries = await scanArchiveLedgers(resolveArchiveDir(agentDir))
195
+ expect(entries).toHaveLength(1)
196
+ })
197
+
198
+ it('坏簿记跳过不中断(坏 JSON / 词表外 status 的条目丢弃,其余照收)', async () => {
199
+ await writeLedger(agentDir, 'a', archivedLedger())
200
+ await writeLedger(agentDir, 'b', archivedLedger({ sessionId: SID2, status: 'bogus' }))
201
+ const dir = join(agentDir, 'archive', SLUG)
202
+ await writeFile(join(dir, 'c.archive.json'), '{broken', 'utf8')
203
+ const entries = await scanArchiveLedgers(resolveArchiveDir(agentDir))
204
+ expect(entries).toHaveLength(1)
205
+ expect(entries[0]!.sessionId).toBe(FIX_E6)
206
+ })
207
+
208
+ it('归档根不存在 → 空数组不抛错', async () => {
209
+ await expect(scanArchiveLedgers(resolveArchiveDir(agentDir))).resolves.toEqual([])
210
+ })
211
+ })
212
+
213
+ // ============================================================
214
+ // roots 第五根(存在才纳入 + 收集契约分流)
215
+ // ============================================================
216
+
217
+ describe('resolveSessionRoots 第五根 archive(存在才纳入)', () => {
218
+ let agentDir: string
219
+
220
+ beforeEach(async () => {
221
+ agentDir = join(await mkdtemp(join(tmpdir(), 'archive-roots-')), 'agent')
222
+ })
223
+ afterEach(async () => {
224
+ await rm(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
225
+ })
226
+
227
+ it('archive 目录不存在 → 根不进返回(现有四根行为零变化,无归档目录环境回归锚)', async () => {
228
+ const roots = await resolveSessionRoots({ agentDir })
229
+ expect(roots.map((r) => r.kind)).toEqual(['default', 'legacy', 'subagent'])
230
+ expect(roots.some((r) => r.kind === 'archive')).toBe(false)
231
+ })
232
+
233
+ it('archive 目录存在 → 第五根纳入:fileCount = 簿记数、files 恒空(不收 .jsonl)、archiveEntries 在场', async () => {
234
+ await writeLedger(agentDir, 'a', archivedLedger())
235
+ await writeLedger(agentDir, 'b', archivedLedger({ sessionId: SID2, name: '另一个' }))
236
+ // staging 明文 .jsonl 不影响 fileCount(不收)
237
+ await writeJsonl(join(agentDir, 'archive', SLUG, 'plain.jsonl'))
238
+
239
+ const roots = await resolveSessionRoots({ agentDir })
240
+ expect(roots.map((r) => r.kind)).toEqual(['default', 'legacy', 'subagent', 'archive'])
241
+ const archive = roots.find((r) => r.kind === 'archive')!
242
+ expect(archive.path).toBe(resolveArchiveDir(agentDir))
243
+ expect(archive.exists).toBe(true)
244
+ expect(archive.source).toBe('main')
245
+ expect(archive.files).toEqual([])
246
+ expect(archive.fileCount).toBe(2)
247
+ expect(archive.archiveEntries).toHaveLength(2)
248
+ expect(typeof archive.scanMs).toBe('number')
249
+ })
250
+
251
+ it('doctor 渲染:archive 根行计数措辞为「N 归档单元」', async () => {
252
+ await writeLedger(agentDir, 'a', archivedLedger())
253
+ const r = await handleSessionRead({ action: 'doctor' }, { agentDir })
254
+ const text = r.content[0]!.text
255
+ expect(text).toContain('[archive]')
256
+ expect(text).toContain('1 归档单元')
257
+ })
258
+
259
+ it('同 realpath 去重:更高优先级根与归档目录同路径时 archive 根 dedupedInto,不重复成根', async () => {
260
+ // live 信号直接指向归档目录(basename 非 encodeCwd 形态 → 取自身,字面同路径
261
+ // realpath 必同);优先级 live > archive,归档根不实扫簿记、标注归属——与普通根
262
+ // realpath 去重同构(§7B 要点 4)。回归锚:archive 分支漏 kept 检查时本用例红
263
+ //(out 出现两条同路径实扫根,且 byRealPath 被 archive 覆盖致 dedupedInto 失效)
264
+ await writeLedger(agentDir, 'a', archivedLedger())
265
+ const archiveDir = resolveArchiveDir(agentDir)
266
+
267
+ const roots = await resolveSessionRoots({ agentDir, liveSessionDir: archiveDir })
268
+ expect(roots.map((r) => r.kind)).toEqual(['live', 'default', 'legacy', 'subagent', 'archive'])
269
+ const archive = roots.find((r) => r.kind === 'archive')!
270
+ expect(archive.path).toBe(archiveDir)
271
+ expect(archive.dedupedInto).toBe('live')
272
+ // 被去重根不产生扫描统计,簿记收集未执行(与普通根去重同构)
273
+ expect(archive.fileCount).toBeUndefined()
274
+ expect(archive.scanMs).toBeUndefined()
275
+ expect(archive.archiveEntries).toBeUndefined()
276
+ // 同路径全列表只保留一个实扫根(= 更高优先级的 live),簿记不重复计数
277
+ const scannedAtSamePath = roots.filter(
278
+ (r) => r.path === archiveDir && r.dedupedInto === undefined,
279
+ )
280
+ expect(scannedAtSamePath).toHaveLength(1)
281
+ expect(scannedAtSamePath[0]!.kind).toBe('live')
282
+ })
283
+ })
284
+
285
+ // ============================================================
286
+ // 归档态返回形态(handleSessionRead 集成:D7 硬契约)
287
+ // ============================================================
288
+
289
+ describe('归档态返回形态(片段命中归档回退)', () => {
290
+ let agentDir: string
291
+
292
+ beforeEach(async () => {
293
+ agentDir = join(await mkdtemp(join(tmpdir(), 'archive-read-')), 'agent')
294
+ })
295
+ afterEach(async () => {
296
+ await rm(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
297
+ })
298
+
299
+ it('名称片段命中归档:头部标注 + 摘要 turns + 尾注恢复指引(§5.1 agent 引用样例形态)', async () => {
300
+ await writeLedger(agentDir, 'a', archivedLedger())
301
+ const r = await handleSessionRead(
302
+ { action: 'outline', session: 'renderer 重构' },
303
+ { agentDir },
304
+ )
305
+ const text = r.content[0]!.text
306
+ expect(text).toContain('[session-reader] session: v0.10 renderer 重构')
307
+ expect(text).toContain('状态: 已归档 (archived) — 完整内容已压缩离线,以下仅为摘要')
308
+ expect(text).toContain('── turn 1 ──')
309
+ expect(text).toContain('user: v0.10 的 renderer 重构开始,先出分层方案。')
310
+ expect(text).toContain('assistant (摘要): 六层分层:Shell / Workspace。')
311
+ expect(text).toContain('注: 如需完整历史,请让用户在归档列表中恢复该会话。')
312
+ // details 程序化标记:archived: true + 簿记条目
313
+ const d = r.details as { archived: boolean; entry: { sessionId: string } }
314
+ expect(d.archived).toBe(true)
315
+ expect(d.entry.sessionId).toBe(FIX_E6)
316
+ })
317
+
318
+ it('摘要占位规则:agent null → 无文本占位;user 空串 → 图片/附件占位', async () => {
319
+ await writeLedger(agentDir, 'a', archivedLedger())
320
+ const r = await handleSessionRead({ action: 'outline', session: 'renderer' }, { agentDir })
321
+ const text = r.content[0]!.text
322
+ expect(text).toContain('── turn 2 ──')
323
+ expect(text).toContain('user: [图片/附件消息]')
324
+ expect(text).toContain('assistant (摘要): (此 turn 以工具调用结束,无文本回复)')
325
+ })
326
+
327
+ it('staging 态(summary null):头部 staging 标注 + 「摘要生成中」+ 名称/id 元数据', async () => {
328
+ await writeLedger(agentDir, 'a', archivedLedger({ status: 'staging', summary: null }))
329
+ const r = await handleSessionRead({ action: 'outline', session: 'renderer' }, { agentDir })
330
+ const text = r.content[0]!.text
331
+ expect(text).toContain('状态: 已归档 (staging) — 摘要生成中,请稍后再查')
332
+ expect(text).toContain(`(id: ${FIX_E6})`)
333
+ expect(text).toContain('注: 如需完整历史,请让用户在归档列表中恢复该会话。')
334
+ expect(text).not.toContain('── turn')
335
+ })
336
+
337
+ it('sessionId 片段与归一化形态命中(大写/去连字符)', async () => {
338
+ await writeLedger(agentDir, 'a', archivedLedger())
339
+ const byFragment = await handleSessionRead({ action: 'detail', session: 'e6c96', turns: 'T000-T001' }, { agentDir })
340
+ expect(byFragment.content[0]!.text).toContain('已归档 (archived)')
341
+ const byNorm = await handleSessionRead(
342
+ { action: 'outline', session: FIX_E6.replace(/-/g, '').toUpperCase() },
343
+ { agentDir },
344
+ )
345
+ expect(byNorm.content[0]!.text).toContain('已归档 (archived)')
346
+ })
347
+
348
+ it('staging 明文 .jsonl 不被直读:同 id 明文在归档目录内仍返回归档形态(不读压缩体/明文)', async () => {
349
+ await writeLedger(agentDir, 'a', archivedLedger())
350
+ await writeJsonl(
351
+ join(agentDir, 'archive', SLUG, `${FIX_E6}.jsonl`),
352
+ `{"type":"session","id":"${FIX_E6}"}\n`,
353
+ )
354
+ const r = await handleSessionRead({ action: 'outline', session: 'e6c96' }, { agentDir })
355
+ expect(r.content[0]!.text).toContain('已归档 (archived)')
356
+ })
357
+
358
+ it('多归档命中 → 归档消歧列表(列候选 + 👉,不抛错)', async () => {
359
+ await writeLedger(agentDir, 'a', archivedLedger())
360
+ await writeLedger(
361
+ agentDir,
362
+ 'b',
363
+ archivedLedger({ sessionId: SID2, name: 'v0.11 renderer 重构' }),
364
+ )
365
+ const r = await handleSessionRead({ action: 'outline', session: 'renderer' }, { agentDir })
366
+ const text = r.content[0]!.text
367
+ expect(text).toContain('多个归档 session 匹配 "renderer"')
368
+ expect(text).toContain(`v0.10 renderer 重构(id: ${FIX_E6}`)
369
+ expect(text).toContain(`v0.11 renderer 重构(id: ${SID2}`)
370
+ expect(text).toContain('👉')
371
+ })
372
+
373
+ it('在场优先:同片段在场 session 命中时返回在场内容,不走归档回退', async () => {
374
+ await writeJsonl(
375
+ join(agentDir, 'sessions', SLUG, `${FIX_E6}.jsonl`),
376
+ `{"type":"session","id":"${FIX_E6}"}\n`,
377
+ )
378
+ await writeLedger(agentDir, 'a', archivedLedger({ name: 'e6c96 主题' }))
379
+ const r = await handleSessionRead({ action: 'outline', session: 'e6c96' }, { agentDir })
380
+ expect(r.details).not.toHaveProperty('archived')
381
+ expect(r.content[0]!.text).not.toContain('已归档')
382
+ })
383
+
384
+ it('result action 命中归档:统一归档态摘要(批量链路的单 id 形态)', async () => {
385
+ await writeLedger(agentDir, 'a', archivedLedger())
386
+ const r = await handleSessionRead({ action: 'result', session: 'renderer' }, { agentDir })
387
+ expect(r.content[0]!.text).toContain('已归档 (archived)')
388
+ expect((r.details as { archived: boolean }).archived).toBe(true)
389
+ })
390
+
391
+ it('无匹配路径回归:无 archive 目录 + 在场零命中 → F1 照旧(无归档提示行、无 [archive] 根行)', async () => {
392
+ await expect(
393
+ handleSessionRead({ action: 'outline', session: 'zzz-nope' }, { agentDir }),
394
+ ).rejects.toThrow('无匹配 session')
395
+ await expect(
396
+ handleSessionRead({ action: 'outline', session: 'zzz-nope' }, { agentDir }),
397
+ ).rejects.toThrow(/自检(发现层,只陈述事实):/)
398
+ })
399
+
400
+ it('F1 归档指引(条件行):归档根存在且簿记非空 → find 零匹配文案含「目标可能已归档」', async () => {
401
+ await writeLedger(agentDir, 'a', archivedLedger())
402
+ const r = await handleSessionRead({ action: 'find', query: 'zzz-nope' }, { agentDir })
403
+ const text = r.content[0]!.text
404
+ expect(text).toContain('无匹配 session')
405
+ expect(text).toContain('目标可能已归档')
406
+ expect(text).toContain('[archive]')
407
+ })
408
+
409
+ it('F1 归档指引不出现:归档根存在但簿记为空(空目录)', async () => {
410
+ await mkdir(join(agentDir, 'archive', SLUG), { recursive: true })
411
+ const r = await handleSessionRead({ action: 'find', query: 'zzz-nope' }, { agentDir })
412
+ expect(r.content[0]!.text).not.toContain('目标可能已归档')
413
+ })
414
+ })
415
+
416
+ // ============================================================
417
+ // 归档态渲染纯函数(formatArchivedText / formatArchivedDisambiguation 直测)
418
+ // ============================================================
419
+
420
+ describe('formatArchivedText(两态渲染纯函数)', () => {
421
+ it('无 name 时头行回退 sessionId', () => {
422
+ const text = formatArchivedText(entry({ name: undefined }))
423
+ expect(text).toContain(`[session-reader] session: ${FIX_E6}`)
424
+ })
425
+
426
+ it('archived 态无摘要 turns(summary 空数组)只出头部与尾注', () => {
427
+ const text = formatArchivedText(entry({ summary: [] }))
428
+ expect(text).toContain('已归档 (archived)')
429
+ expect(text).not.toContain('── turn')
430
+ expect(text).toContain('注: 如需完整历史')
431
+ })
432
+ })
433
+
434
+ describe('formatArchivedDisambiguation(多命中消歧)', () => {
435
+ it('按 mtime 倒序列候选并携带归档时间', () => {
436
+ const text = formatArchivedDisambiguation('q', [
437
+ entry({ mtime: 1, name: '旧条目' }),
438
+ entry({ mtime: 2, sessionId: SID2, name: '新条目' }),
439
+ ])
440
+ const newIdx = text.indexOf('新条目')
441
+ const oldIdx = text.indexOf('旧条目')
442
+ expect(newIdx).toBeGreaterThan(-1)
443
+ expect(oldIdx).toBeGreaterThan(newIdx)
444
+ expect(text).toContain(`id: ${SID2}`)
445
+ expect(text).toContain('归档于 2026-10-19T12:00:00.000Z')
446
+ })
447
+ })
@@ -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
+ }
@@ -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
+ }
@@ -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
 
@@ -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)。
@@ -140,7 +151,8 @@ export function normalizeLiveSessionDir(liveSessionDir: string): string {
140
151
 
141
152
  /**
142
153
  * 从信号包推导候选根(未扫描骨架),数组序 = 优先级序(§6.1)。
143
- * `[subagent]` 恒在(常量推导);`[live]` 仅在信号存在时出现。
154
+ * `[subagent]` 恒在(常量推导);`[live]` 仅在信号存在时出现;`[archive]` 恒在
155
+ * 骨架中(D7 第五根,纳入与否由 resolveSessionRoots 按「存在才扫」判定)。
144
156
  */
145
157
  function sessionRootSpecs(signals: SessionRootSignals): SessionRoot[] {
146
158
  // 空 agentDir 防御:派生式会退化为 cwd 相对路径('sessions'/'./sessions'),可能扫到
@@ -156,6 +168,10 @@ function sessionRootSpecs(signals: SessionRootSignals): SessionRoot[] {
156
168
  // 扫描在 sid 分区内定向探 subagents/sessions/(scanStoreSubagentJsonl);旧布局
157
169
  // `<agentDir>/subagents` 不再读(设计裁决 D6:存量原位保留、不可见)。
158
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)))
159
175
  return specs
160
176
  }
161
177
 
@@ -202,11 +218,13 @@ export interface SessionRootScanOptions {
202
218
  /**
203
219
  * 解析候选根并逐根扫描(design 2026-09-10 §6.1 候选根探测 / §7B 要点 3/4/6)。
204
220
  *
205
- * 优先级 live > default > legacy > subagent;按 realpath 去重:同路径多信号只扫一次,
206
- * 保留最高优先级 kind 标签,被去重的根以 `dedupedInto` 标注(不产生 fileCount/scanMs)。
207
- * 逐根扫描:main 源整树递归(跳 workflow-state);subagent 源 = 存储树定向扫描
208
- *([session-store-layout-inversion] 单新根,见 scanStoreSubagentJsonl)。单根失败
209
- * (不存在/无权限)→ 空结果继续,不抛错(沿用 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 契约)。
210
228
  *
211
229
  * options(u8 追加,全部缺省安全):`subagents:'stat'` 让 subagent 根只做存在性检查。
212
230
  * 无缓存——每次调用实扫(原 u8 cache 注入点已删除,ext-simplify-04 U3;find/F1
@@ -220,6 +238,35 @@ export async function resolveSessionRoots(
220
238
  const out: SessionRoot[] = []
221
239
  const byRealPath = new Map<string, SessionRoot>()
222
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
+ }
223
270
  const key = await safeRealpath(candidate.path)
224
271
  const kept = byRealPath.get(key)
225
272
  if (kept) {
package/src/doctor.ts CHANGED
@@ -93,6 +93,9 @@ function renderDoctor(
93
93
  // 仅 subagents:'stat' 的 subagent 根(doctor 默认形态)——其余根恒有计数(实扫);
94
94
  // 被去重根已在上方提前返回
95
95
  facts.push('未扫描(subagent 根默认不扫,includeSubagents:true 开启)')
96
+ } else if (r.kind === 'archive') {
97
+ // [session-archive D7] 归档根计数 = 簿记数(非 .jsonl 文件数,措辞按实名)
98
+ facts.push(`${r.fileCount} 归档单元`, `扫 ${Math.round(r.scanMs ?? 0)}ms`)
96
99
  } else {
97
100
  facts.push(`${r.fileCount} 文件`, `扫 ${Math.round(r.scanMs ?? 0)}ms`)
98
101
  }
package/src/index.ts CHANGED
@@ -159,6 +159,9 @@ const guidelines = [
159
159
  // zcode 路由(U9)对 LLM 的唯一可见部分:sa-id 输入自动路由,无需任何参数;首遇
160
160
  // zcode_* 错误码前先有背景预告。
161
161
  'sa- ids route transparently to pi or zcode engine sessions; sess_ prefixed ids are not accepted—use the completion-notice sa- id.',
162
+ // [session-archive D7] 归档态硬契约的 LLM 侧预告:引用已归档 session 只得摘要
163
+ //(返回头部显式标注「已归档」),恢复完整历史是用户操作。
164
+ 'Archived sessions return summary-only views (marked 已归档/archived)—for full history, ask the user to restore the session.',
162
165
  'Errors carry a 👉 recovery hint—follow it to retry.',
163
166
  'Unsure about host environment or session roots? Run action:"doctor".',
164
167
  ]
package/src/no-match.ts CHANGED
@@ -62,6 +62,13 @@ function buildSelfCheckLines(roots: SessionRoot[]): string[] {
62
62
  continue
63
63
  }
64
64
  const count = r.fileCount ?? r.files.length
65
+ // [session-archive D2 U2] 归档根不计入 find 候选集合计(find 只迭代在场 .jsonl,
66
+ // 归档根 files 恒空、fileCount = 簿记数——计入则「候选集非空」陈述失真);
67
+ // 措辞对齐 doctor 的「N 归档单元」实名(区别于 .jsonl 文件计数)。
68
+ if (r.kind === 'archive') {
69
+ lines.push(` [${r.kind}]`.padEnd(F1_ROOT_LABEL_PAD) + `${r.path}:${count} 归档单元`)
70
+ continue
71
+ }
65
72
  totalFiles += count
66
73
  lines.push(` [${r.kind}]`.padEnd(F1_ROOT_LABEL_PAD) + `${r.path}:${count} 文件`)
67
74
  }
@@ -117,8 +124,15 @@ function formatSuggestionLines(suggestions: NoMatchSuggestion[], query: string):
117
124
  return lines
118
125
  }
119
126
 
120
- /** ③ 正确做法四条 + ④ 封死 shell 绕行(纯静态文案)。 */
121
- function guidanceLines(): string[] {
127
+ /**
128
+ * ③ 正确做法四条 + ④ 封死 shell 绕行(纯静态文案)。
129
+ *
130
+ * [session-archive D7] archiveHint:归档根存在且有簿记时由 formatNoMatch 追加第
131
+ * 五条(目标可能已归档的引用指引)——find 结果不含归档条目,归档引用通道是深读
132
+ * action 的 session 参数(resolveByFragment 的归档回退层)。无归档目录 → 空数组,
133
+ * 文案零变化。
134
+ */
135
+ function guidanceLines(archiveHint: string[] = []): string[] {
122
136
  return [
123
137
  '',
124
138
  '正确做法:',
@@ -126,6 +140,7 @@ function guidanceLines(): string[] {
126
140
  ` - 已知文件路径时直接传绝对路径:session_read { action:"outline", session:"<绝对路径>.jsonl" }`,
127
141
  ` - 截断/过期 id 用更短前缀:session_read { action:"find", query:"<更短前缀>" }`,
128
142
  ` - 想先看环境与根目录状态:session_read { action:"doctor" }`,
143
+ ...archiveHint,
129
144
  '',
130
145
  '不要用 shell find/ls/rg 搜 session 目录,不要 cat/read 原始 .jsonl —— session_read 是唯一入口。',
131
146
  ]
@@ -150,12 +165,22 @@ function guidanceLines(): string[] {
150
165
  * 无「👉 用 recent 看全量」误导指引(recent 候选对写错的 uuid 无自纠价值,§6.7 不采用项)。
151
166
  */
152
167
  export function formatNoMatch(query: string, roots: SessionRoot[]): string {
168
+ // [session-archive D7] 归档指引(条件行):归档根存在且簿记非空才出现——自检行
169
+ // 已列 [archive] 根计数,此处补「怎么引用」的动作指引,find→深读链路对归档
170
+ // session 不断链。无归档目录的 roots 不含 archive 根,guidanceLines 零变化。
171
+ const archiveRoot = roots.find((r) => r.kind === 'archive' && r.dedupedInto === undefined)
172
+ const archiveHint =
173
+ archiveRoot !== undefined && (archiveRoot.fileCount ?? 0) > 0
174
+ ? [
175
+ ` - 目标可能已归档:直接用 session 参数引用(返回归档摘要),如 session_read { action:"outline", session:"<名称片段或id>" }`,
176
+ ]
177
+ : []
153
178
  return [
154
179
  `无匹配 session:"${query}"`,
155
180
  '',
156
181
  ...buildSelfCheckLines(roots),
157
182
  '',
158
183
  ...formatSuggestionLines(collectSuggestions(query, roots), query),
159
- ...guidanceLines(),
184
+ ...guidanceLines(archiveHint),
160
185
  ].join('\n')
161
186
  }
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import type { Entry, ParseResult } from '@zhushanwen/session-core'
19
19
  import { textBlockParts } from './core/render.js'
20
+ import { archivedResult } from './discovery/archive-match.js'
20
21
  import type { MatchedSession } from './discovery/find.js'
21
22
  import { listRecordManifests, type RecordManifest } from './discovery/subagents.js'
22
23
  import { err, requireStr, SESSION_ID_PREFIX_LEN, stripHash } from './handler-utils.js'
@@ -208,6 +209,11 @@ export async function doResult(
208
209
  for (const id of ids) {
209
210
  const resolved = await deps.resolveSessionId(id, 'result', agentDir, params.source, prefetchedManifests)
210
211
  if (resolved.kind === 'multi') return deps.disambiguate(resolved.query, resolved.candidates)
212
+ // [session-archive D7] 片段命中归档 session:完整正文不可读,统一返回归档态摘要
213
+ //(构造与渲染收敛在 archive-match 的 archivedResult,与 tool-handler 同一出口)
214
+ if (resolved.kind === 'archived' || resolved.kind === 'archived-multi') {
215
+ return archivedResult(resolved)
216
+ }
211
217
  const { entries } = await deps.safeParse(resolved.fileName)
212
218
  const text = extractFinalAssistantText(entries)
213
219
  if (text.length === 0) {
@@ -33,7 +33,8 @@ import {
33
33
  import type { Family } from './core/family.js'
34
34
  import { SESSION_ID_PREFIX_LEN, rangeLabel } from './handler-utils.js'
35
35
  import { formatNoMatch } from './no-match.js'
36
- import type { ToolResult } from './tool-handler.js'
36
+ import { archivedResult } from './discovery/archive-match.js'
37
+ import type { ResolveResult, ToolResult } from './tool-handler.js'
37
38
 
38
39
  // 结构化日志通道(zcodeReadErrorMessage 的降级/TOCTOU warn):getLogger 按名缓存
39
40
  // 单例,与 tool-handler 侧调用取同一实例,appendEntry/文件日志行为不变。
@@ -229,6 +230,16 @@ export function disambiguate(query: string, candidates: MatchedSession[]): ToolR
229
230
  return { content: [{ type: 'text', text }], details: { ambiguous: true, candidates } }
230
231
  }
231
232
 
233
+ /**
234
+ * ResolveResult 非 ok 支的统一分发([session-archive D7] 归并点):multi → 既有
235
+ * F2 消歧;archived / archived-multi → 归档态返回(archive-match 两支渲染,不读
236
+ * 压缩体)。8 个 do* 的「multi 消歧 + 归档短路」重复分支收敛到本函数单一出口。
237
+ */
238
+ export function resolveNonOkResult(resolved: Exclude<ResolveResult, { kind: 'ok' }>): ToolResult {
239
+ if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
240
+ return archivedResult(resolved)
241
+ }
242
+
232
243
  // ---------------------------------------------------------------------------
233
244
  // 文本渲染(content)
234
245
  // ---------------------------------------------------------------------------
@@ -20,8 +20,10 @@
20
20
  * 本模块 re-export 供 index.ts 生产消费)。
21
21
  *
22
22
  * 错误规格 F1-F6:handler 抛 Error(message 含 👉 恢复指引),index.ts 的 execute 闭包
23
- * 原样传播给 pi——pi-agent-core 只对 execute throw 置 isError:true(返回值里的 isError
24
- * 字段被丢弃,agent-loop.js:453-483)。handler 可抛(纯逻辑可测)。
23
+ * 原样传播给 pi——execute throw → agent-loop catch 置 isError:true(pi-agent-core dist/
24
+ * agent-loop.js executePreparedToolCall,1.0.0 实读 catch :581-588;1.0.0 起正常 return 的
25
+ * isError 也被尊重——:579 isError: result.isError === true,0.84.4 才硬编码 false——本包
26
+ * 失败恒走 throw,行为不变)。handler 可抛(纯逻辑可测)。
25
27
  * 例外:F2 多匹配与 F1 find 零匹配「不视为错误」,返回消歧/提示结果而非抛错。
26
28
  */
27
29
  import { existsSync } from 'node:fs'
@@ -38,6 +40,10 @@ import {
38
40
  type SessionMetadataEntry,
39
41
  type SessionMetadataProvider,
40
42
  } from './discovery/find.js'
43
+ import {
44
+ resolveArchiveFragment,
45
+ type ArchiveResolve,
46
+ } from './discovery/archive-match.js'
41
47
  import { resolveSessionRoots } from './discovery/roots.js'
42
48
  import { readSessionHeaderIdSync } from './discovery/session-header.js'
43
49
  import { findZcodeEntryAnchor, type ZcodeAnchor } from './discovery/entry-anchor.js'
@@ -122,6 +128,7 @@ import {
122
128
  formatZcodeRecordNotFound,
123
129
  formatZcodeSessionNotFound,
124
130
  isToolResultSummary,
131
+ resolveNonOkResult,
125
132
  zcodeReadErrorMessage,
126
133
  } from './tool-format.js'
127
134
  import { doDoctor, type SessionReadSignals } from './doctor.js'
@@ -215,6 +222,13 @@ export type ResolveResult =
215
222
  zcodeAnchor?: ZcodeAnchor
216
223
  }
217
224
  | { kind: 'multi'; query: string; candidates: MatchedSession[] }
225
+ /**
226
+ * [session-archive D7] 片段命中归档 session(在场零命中后的归档回退层,两支
227
+ * 形态见 ArchiveResolve):消费方(各 do* 的 non-ok 分发 resolveNonOkResult /
228
+ * result-action)统一返回归档态摘要——不读压缩体,头部显式标注归档态
229
+ * (agent 区分摘要与完整历史的硬契约)。
230
+ */
231
+ | ArchiveResolve
218
232
 
219
233
  // readSessionHeaderIdSync(同步读首行 header 取 id,resolveSessionId 形态①/②消费)
220
234
  // 在 discovery/session-header.ts(基座 readFirstJsonlLineSync 的谓词/降级薄包装,G2 单源)。
@@ -467,6 +481,11 @@ async function resolveByRecordId(session: string, agentDir: string, prefetchedMa
467
481
  * 形态③:其余片段 → findSessions 透传 source/liveSessionDir 沿用 F1(零匹配)/ F2(多匹配消歧)。
468
482
  * liveSessionDir 透传保证片段匹配与 find 同一 roots;F1 自检行同理须含 [live] 根行
469
483
  *(否则 [live]≠[default] 环境下自检行看不到最高优先级根,计数失真)。
484
+ *
485
+ * [session-archive D7] 归档回退层:在场匹配零命中 → 查归档簿记(archive-match)。
486
+ * 在场优先保证既有行为零变化(归档 session 不在 sessions/ 扫描范围,「在场零命中」
487
+ * 与「已归档」对同一 session 互斥成立——D9 不变量 1);命中归档不走 F1(历史引用
488
+ * 不断链,D7 方案否决项「agent 完全不可见」)。
470
489
  */
471
490
  async function resolveByFragment(
472
491
  session: string,
@@ -481,6 +500,10 @@ async function resolveByFragment(
481
500
  }
482
501
  const { matches } = await findSessions(session, agentDir, opts)
483
502
  if (matches.length === 0) {
503
+ // [session-archive D7] 归档回退层:单命中返回归档态、多命中返回归档消歧
504
+ //(实现与匹配口径在 discovery/archive-match.ts);无簿记命中落 F1。
505
+ const archive = await resolveArchiveFragment(session, agentDir)
506
+ if (archive !== undefined) return archive
484
507
  // F1 自检行需要发现层实况:无 options 的 resolveSessionRoots 恒实扫(根扫描无
485
508
  // 缓存——doctor 缓存机已删除,ext-simplify-04 U3),与 find 刚完成的扫描同一数据
486
509
  // 源(roots.ts 薄包装语义);信号包同源(liveSessionDir 透传)——findSessions
@@ -719,7 +742,8 @@ async function doFamily(
719
742
  undefined,
720
743
  liveSessionDir,
721
744
  )
722
- if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
745
+ // non-ok 支统一分发(multi 消歧 / 归档态两支,[session-archive D7] 归并点)
746
+ if (resolved.kind !== 'ok') return resolveNonOkResult(resolved)
723
747
 
724
748
  // recursive=true:嵌套执行树(U7/U8)
725
749
  if (params.recursive) {
@@ -766,7 +790,8 @@ async function doOutline(
766
790
  undefined,
767
791
  liveSessionDir,
768
792
  )
769
- if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
793
+ // non-ok 支统一分发(multi 消歧 / 归档态两支,[session-archive D7] 归并点)
794
+ if (resolved.kind !== 'ok') return resolveNonOkResult(resolved)
770
795
  const { entries, totalBytes, skippedLines } = await loadParsed(resolved, agentDir)
771
796
  const tree = buildTreeView(entries)
772
797
  const turns = segmentTurns(entries, new Set(tree.leafPath))
@@ -802,7 +827,8 @@ async function doExpand(
802
827
  undefined,
803
828
  liveSessionDir,
804
829
  )
805
- if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
830
+ // non-ok 支统一分发(multi 消歧 / 归档态两支,[session-archive D7] 归并点)
831
+ if (resolved.kind !== 'ok') return resolveNonOkResult(resolved)
806
832
  const turnIdx = parseTurnIndex(requireStr(params.turn, 'turn', 'expand'))
807
833
  const { entries } = await loadParsed(resolved, agentDir)
808
834
  const tree = buildTreeView(entries)
@@ -835,7 +861,8 @@ async function doDetail(
835
861
  undefined,
836
862
  liveSessionDir,
837
863
  )
838
- if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
864
+ // non-ok 支统一分发(multi 消歧 / 归档态两支,[session-archive D7] 归并点)
865
+ if (resolved.kind !== 'ok') return resolveNonOkResult(resolved)
839
866
  const range = parseTurnsRange(requireStr(params.turns, 'turns', 'detail'))
840
867
  const { entries } = await loadParsed(resolved, agentDir)
841
868
  const tree = buildTreeView(entries)
@@ -893,7 +920,8 @@ async function doSearch(
893
920
  undefined,
894
921
  signals.liveSessionDir,
895
922
  )
896
- if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
923
+ // non-ok 支统一分发(multi 消歧 / 归档态两支,[session-archive D7] 归并点)
924
+ if (resolved.kind !== 'ok') return resolveNonOkResult(resolved)
897
925
  const scope = params.scope ?? 'all'
898
926
  const limit = params.limit ?? SEARCH_DEFAULT_LIMIT
899
927
  const { entries } = await loadParsed(resolved, agentDir)
@@ -927,7 +955,8 @@ async function doExport(
927
955
  undefined,
928
956
  liveSessionDir,
929
957
  )
930
- if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
958
+ // non-ok 支统一分发(multi 消歧 / 归档态两支,[session-archive D7] 归并点)
959
+ if (resolved.kind !== 'ok') return resolveNonOkResult(resolved)
931
960
 
932
961
  let text: string
933
962
  let label: string
@@ -1034,7 +1063,8 @@ async function doExtract(
1034
1063
  undefined,
1035
1064
  liveSessionDir,
1036
1065
  )
1037
- if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
1066
+ // non-ok 支统一分发(multi 消歧 / 归档态两支,[session-archive D7] 归并点)
1067
+ if (resolved.kind !== 'ok') return resolveNonOkResult(resolved)
1038
1068
  const { entries } = await loadParsed(resolved, agentDir)
1039
1069
  // extract 遍历全量 entry(含旁支/压缩历史),与 outline/expand/detail 的 leaf 视图不同:
1040
1070
  // 素材提取要全量(design §2.3 实测全量 519 toolCall / 26 user / 515 toolResult),
@@ -1213,7 +1243,8 @@ async function doWorkflow(
1213
1243
  undefined,
1214
1244
  liveSessionDir,
1215
1245
  )
1216
- if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
1246
+ // non-ok 支统一分发(multi 消歧 / 归档态两支,[session-archive D7] 归并点)
1247
+ if (resolved.kind !== 'ok') return resolveNonOkResult(resolved)
1217
1248
 
1218
1249
  let workflows: WorkflowRef[]
1219
1250
  try {