@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
@@ -0,0 +1,277 @@
1
+ import { readFile, readdir } from 'node:fs/promises'
2
+ import { statSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import type { ZcodeAnchor } from './entry-anchor.js'
6
+ import type { ZcodeFamilyNode } from '../core/family.js'
7
+
8
+ /**
9
+ * zcode manifest 直读与枚举(design session-reader-shared-core §3.1 第①步 / D5 / D5-1)。
10
+ *
11
+ * 背景(F21):pi 路径的 `isRecordManifest` 三必填含 `sessionFile`,而 zcode manifest
12
+ * 落盘时该键被 JSON.stringify 丢弃(subagent-core record-store-rebuild.ts:665/725 投影
13
+ * 照写 `sessionFile: undefined`)→ `listRecordManifests` 对 zcode manifest 恒返回 []。
14
+ * 本单元另开一条**不经过该守卫**的独立路径:
15
+ * - **定点直读**(`readZcodeManifest`):manifest 文件名由 sa-id 完全确定(写侧
16
+ * `path.join(recordsDir, \`${record.id}.json\`)`,manifest-store.ts:141/153),按确定性
17
+ * 文件名 `<sa-id>.json` 在 `<agentDir>/subagents/<cwd>/records/` 下**窄 walk(命中即止,
18
+ * 不读不解析任何其他 manifest)**→ JSON.parse → `engine === 'zcode'` ∧
19
+ * `engineHandle.sessionRef` 双键(sessionId/dbPath 均非空 string)→ 取锚。
20
+ * - **全量枚举**(`listZcodeManifests`,family 用):扫同布局下全部 .json,逐条
21
+ * parse + 谓词过滤,只返回 zcode 形态(pi manifest / 坏 JSON 跳过)。
22
+ *
23
+ * **对 pi 行为零影响(本方案的定义性质)**:`isRecordManifest` / `RecordManifest` /
24
+ * `listRecordManifests`(subagents.ts)一行不改——pi manifest 仍走原扫描路径,本单元
25
+ * 的谓词**不走** isRecordManifest。判别规则与 runtime `transcriptAnchorOf` 同构
26
+ * (F8:`engine === 'zcode'` 第一判据 + sessionRef 双键齐)。用 `engine` 判别而非
27
+ * 「engineHandle 存在性」的理由见 D5 被否项 ②(pi record 提升后 engineHandle 仍持久化,
28
+ * 存在性判别会误路由)。
29
+ */
30
+
31
+ /**
32
+ * zcode manifest 的读取记录(枚举产出,family 消费)。
33
+ *
34
+ * 富字段(slug/task/model/status/agentName)从 manifest 顶层透传(旧 manifest 缺省
35
+ * → undefined);slug 的 `m.slug ?? m.agentName ?? ''` 兜底在枚举组装时完成(与 pi
36
+ * 孤儿链 appendOrphanIdentities 同款,core 层直接透传)。
37
+ */
38
+ export interface ZcodeManifestRecord extends ZcodeFamilyNode {
39
+ /** 写侧盖章的引擎 id(枚举谓词保证 === 'zcode') */
40
+ engine: string
41
+ }
42
+
43
+ /**
44
+ * anchor-missing 的缺失键归因(§3.4:缺失键名进结构化日志——engineHandle 整体缺席 /
45
+ * 缺 sessionRef / 缺 sessionId / 缺 dbPath 归因不同:整体缺席多为旧版本产物,缺
46
+ * sessionId 多为引擎未回调 onHandleReady,缺 dbPath 多为写入面截断)。entry 兜底侧
47
+ * 的同构分类(tool-handler classifyIncompleteEntryAnchor)复用本类型单源。
48
+ */
49
+ export type ZcodeAnchorMissingReason =
50
+ | 'missing-engineHandle'
51
+ | 'missing-sessionRef'
52
+ | 'missing-sessionId'
53
+ | 'missing-dbPath'
54
+
55
+ /**
56
+ * 定点直读的结果三态(discriminated result——错误码映射归路由单元 U9,本单元只给
57
+ * 归因信号,§3.4 检查顺序第 0 段「定位链」):
58
+ * - `zcode`:engine==='zcode' ∧ sessionRef 双键齐 → 携带锚,继续 zcode 链(白名单闸 → 开库);
59
+ * - `not-zcode`:文件不存在 / 坏 JSON / `engine` 缺省或 `'pi'`(或非 string id)——
60
+ * 该 sa-id 不是「zcode 单子」,交回调用方走今天的 pi 现路径(sessionFile + JSONL);
61
+ * - `anchor-missing`:engine==='zcode' 但 `engineHandle` 整体缺席或 `sessionRef` 缺键
62
+ * ——路由映射 `zcode_anchor_missing`(§3.4;缺失键名以 `reason` 逐键归因,由结构化
63
+ * 日志承载)。
64
+ */
65
+ export type ZcodeManifestLookup =
66
+ | { kind: 'zcode'; anchor: ZcodeAnchor }
67
+ | { kind: 'not-zcode' }
68
+ | { kind: 'anchor-missing'; reason: ZcodeAnchorMissingReason }
69
+
70
+ /** manifest 顶层 JSON 的读取视图(守卫输入) */
71
+ type ManifestJson = Record<string, unknown>
72
+
73
+ function asObject(v: unknown): ManifestJson | undefined {
74
+ if (typeof v !== 'object' || v === null) return undefined
75
+ return v as ManifestJson
76
+ }
77
+
78
+ /** 第一判据:`engine === 'zcode'`(缺省 / 'pi' / 其他引擎均非 zcode 形态) */
79
+ function isZcodeEngine(m: ManifestJson): boolean {
80
+ return m.engine === 'zcode'
81
+ }
82
+
83
+ /**
84
+ * 第二判据:`engineHandle.sessionRef` 双键(sessionId/dbPath)均为非空 string → 锚。
85
+ * (F8 判据的锚字段部分;pi record 的 sessionRef 无 dbPath → 天然不齐。)
86
+ */
87
+ function zcodeAnchorOf(m: ManifestJson): ZcodeAnchor | undefined {
88
+ const handle = asObject(m.engineHandle)
89
+ if (!handle) return undefined
90
+ const ref = asObject(handle.sessionRef)
91
+ if (!ref) return undefined
92
+ const { sessionId, dbPath } = ref
93
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined
94
+ if (typeof dbPath !== 'string' || dbPath === '') return undefined
95
+ return { sessionId, dbPath }
96
+ }
97
+
98
+ /**
99
+ * anchor-missing 的逐键归因(判定顺序与 zcodeAnchorOf 的键序一致——同一键序下
100
+ * 「首个不满足的键」即归因,两函数对同一 manifest 的 zcode/anchor-missing 判定互洽)。
101
+ */
102
+ function zcodeAnchorMissingReason(m: ManifestJson): ZcodeAnchorMissingReason {
103
+ const handle = asObject(m.engineHandle)
104
+ if (!handle) return 'missing-engineHandle'
105
+ const ref = asObject(handle.sessionRef)
106
+ if (!ref) return 'missing-sessionRef'
107
+ if (typeof ref.sessionId !== 'string' || ref.sessionId === '') return 'missing-sessionId'
108
+ return 'missing-dbPath'
109
+ }
110
+
111
+ /** manifest 顶层富字段(旧 manifest 缺省 → undefined,不伪造) */
112
+ interface ManifestRichFields {
113
+ agentName?: string
114
+ task?: string
115
+ model?: string
116
+ status?: string
117
+ }
118
+
119
+ function richFieldsOf(m: ManifestJson): ManifestRichFields {
120
+ const out: ManifestRichFields = {}
121
+ if (typeof m.agentName === 'string') out.agentName = m.agentName
122
+ if (typeof m.task === 'string') out.task = m.task
123
+ if (typeof m.model === 'string') out.model = m.model
124
+ if (typeof m.status === 'string') out.status = m.status
125
+ return out
126
+ }
127
+
128
+ /** stat 存在性(任意路径无安全面——D5-1 只查「文件在不在」,不开库) */
129
+ function fileExists(p: string): boolean {
130
+ try {
131
+ statSync(p)
132
+ return true
133
+ } catch {
134
+ return false
135
+ }
136
+ }
137
+
138
+ /**
139
+ * manifest JSON → zcode 记录(枚举产出形状)。非 zcode 形态 → undefined。
140
+ * slug 兜底 m.slug ?? m.agentName ?? ''(与 pi 孤儿链同款)。
141
+ */
142
+ function toZcodeManifestRecord(m: ManifestJson): ZcodeManifestRecord | undefined {
143
+ if (!isZcodeEngine(m)) return undefined
144
+ const anchor = zcodeAnchorOf(m)
145
+ if (!anchor) return undefined
146
+ if (typeof m.id !== 'string' || m.id === '') return undefined
147
+ if (typeof m.rootSessionId !== 'string' || m.rootSessionId === '') return undefined
148
+ const rich = richFieldsOf(m)
149
+ const slug = typeof m.slug === 'string' ? m.slug : (rich.agentName ?? '')
150
+ return {
151
+ id: m.id,
152
+ rootSessionId: m.rootSessionId,
153
+ slug,
154
+ agentName: rich.agentName,
155
+ task: rich.task,
156
+ model: rich.model,
157
+ status: rich.status,
158
+ dbPath: anchor.dbPath,
159
+ dbFileExists: fileExists(anchor.dbPath),
160
+ engine: 'zcode',
161
+ }
162
+ }
163
+
164
+ /** `<agentDir>/subagents` 布局常量(与写侧 path-encoding/manifest-store 布局对齐) */
165
+ const SUBAGENTS_DIR = 'subagents'
166
+ const RECORDS_DIR = 'records'
167
+
168
+ /**
169
+ * 逐条解析一个 manifest 文件为三态结果(直读用)。
170
+ * 坏 JSON / 非 string id → not-zcode(残缺 manifest 交回 pi 现路径——与今天的
171
+ * tryReadManifest 丢弃行为等价,只是换了显式信号)。
172
+ */
173
+ function parseManifestLookup(raw: string): ZcodeManifestLookup {
174
+ let v: unknown
175
+ try {
176
+ v = JSON.parse(raw)
177
+ } catch {
178
+ return { kind: 'not-zcode' } // 坏 JSON
179
+ }
180
+ const m = asObject(v)
181
+ if (!m || !isZcodeEngine(m)) return { kind: 'not-zcode' } // engine 缺省 / 'pi' / 其他
182
+ const anchor = zcodeAnchorOf(m)
183
+ if (!anchor) return { kind: 'anchor-missing', reason: zcodeAnchorMissingReason(m) } // 逐键归因
184
+ return { kind: 'zcode', anchor }
185
+ }
186
+
187
+ /**
188
+ * 定点直读(§3.1 第①步主路径):按确定性文件名 `<sa-id>.json` 在
189
+ * `<agentDir>/subagents/<cwd>/records/` 下窄 walk,**命中即止——不读不解析任何其他
190
+ * manifest**(对比 listRecordManifests 的全量 walk + 逐条 parse)。
191
+ *
192
+ * 无会话边界(§3.1):manifest 文件名由 sa-id 确定性推出,任何会话中都可直读。
193
+ * 根目录不可读 / 无候选目录 → not-zcode(调用方交回 pi 现路径,与今天行为一致)。
194
+ */
195
+ export async function readZcodeManifest(
196
+ agentDir: string,
197
+ saId: string,
198
+ ): Promise<ZcodeManifestLookup> {
199
+ const root = join(agentDir, SUBAGENTS_DIR)
200
+ let slugs: DirentLike[]
201
+ try {
202
+ slugs = await readdir(root, { withFileTypes: true })
203
+ } catch {
204
+ return { kind: 'not-zcode' } // subagents 根不存在/不可读 → 无 manifest
205
+ }
206
+ for (const e of slugs) {
207
+ if (!e.isDirectory()) continue
208
+ const file = join(root, e.name, RECORDS_DIR, `${saId}.json`)
209
+ let raw: string
210
+ try {
211
+ // 命中即止:只读这一个文件(文件名由 sa-id 确定),坏内容同样终止(见 parseManifestLookup)
212
+ raw = await readFile(file, 'utf8')
213
+ } catch {
214
+ continue // 该 cwd 目录下无此 manifest → 下一候选目录
215
+ }
216
+ return parseManifestLookup(raw)
217
+ }
218
+ return { kind: 'not-zcode' }
219
+ }
220
+
221
+ /** readdir(withFileTypes) 条目的最小结构视图(便于类型标注) */
222
+ interface DirentLike {
223
+ isDirectory(): boolean
224
+ isFile(): boolean
225
+ name: string
226
+ }
227
+
228
+ /**
229
+ * zcode manifest 全量枚举(family 用,D5-1 的节点列表来源):扫
230
+ * `<agentDir>/subagents/<cwd>/records/` 全部 .json,逐条 JSON.parse + 谓词过滤
231
+ * (engine==='zcode' ∧ sessionRef 双键齐 ∧ id/rootSessionId 非空 string),
232
+ * 只返回 zcode 形态——**pi manifest 与坏 JSON 直接跳过,不走 isRecordManifest
233
+ * (那条链零改动)**。每个返回记录已携带 dbFileExists(statSync 预计算)。
234
+ *
235
+ * 单条 manifest 不可达(坏 JSON / 残缺 / stat 失败)不中断枚举、不抛异常——
236
+ * family 是列表视图,单节点不可达不炸整屏(D5-1 GC 语义)。
237
+ */
238
+ export async function listZcodeManifests(agentDir: string): Promise<ZcodeManifestRecord[]> {
239
+ const root = join(agentDir, SUBAGENTS_DIR)
240
+ const out: ZcodeManifestRecord[] = []
241
+ let slugs: DirentLike[]
242
+ try {
243
+ slugs = await readdir(root, { withFileTypes: true })
244
+ } catch {
245
+ return out // 根不存在/不可读 → 空(无 zcode 节点,不报错)
246
+ }
247
+ for (const slug of slugs) {
248
+ if (!slug.isDirectory()) continue
249
+ const recordsDir = join(root, slug.name, RECORDS_DIR)
250
+ let files: DirentLike[]
251
+ try {
252
+ files = await readdir(recordsDir, { withFileTypes: true })
253
+ } catch {
254
+ continue // 该 cwd 目录无 records/ → 跳过
255
+ }
256
+ for (const f of files) {
257
+ if (!f.isFile() || !f.name.endsWith('.json')) continue
258
+ let raw: string
259
+ try {
260
+ raw = await readFile(join(recordsDir, f.name), 'utf8')
261
+ } catch {
262
+ continue // 单文件读失败 → 跳过
263
+ }
264
+ let v: unknown
265
+ try {
266
+ v = JSON.parse(raw)
267
+ } catch {
268
+ continue // 坏 JSON → 跳过
269
+ }
270
+ const m = asObject(v)
271
+ if (!m) continue
272
+ const rec = toZcodeManifestRecord(m)
273
+ if (rec) out.push(rec)
274
+ }
275
+ }
276
+ return out
277
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-a
2
2
  import { getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent'
3
3
  import { StringEnum } from '@earendil-works/pi-ai'
4
4
  import { Type } from 'typebox'
5
+ import { setPiHandle } from '@zhushanwen/pi-extension-logger'
5
6
  import type { SessionMetadataProvider } from './discovery/find.js'
6
7
  import { handleSessionRead, type SessionReadParams, type SessionReadSignals } from './tool-handler.js'
7
8
  import { createHashAutocompleteProvider } from './tui/hash-provider.js'
@@ -153,6 +154,9 @@ const guidelines = [
153
154
  'find first to locate a session by uuid fragment or name. TUI #references are full uuids.',
154
155
  'outline before detail. Never read raw .jsonl files—use this tool.',
155
156
  'family traces fork parents/children, subagent sessions, and workflow runs.',
157
+ // zcode 路由(U9)对 LLM 的唯一可见面:sa-id 输入自动路由,无需任何参数;首遇
158
+ // zcode_* 错误码前先有背景预告(schema 面按设计 D4 零变化,不在此重复)
159
+ 'sa- ids route transparently to pi or zcode engine sessions; sess_ prefixed ids are not accepted—use the sa- id from the completion notice.',
156
160
  'extract what=<type> to pull user messages / commands / files / commits / tool results across turns (optional tool= filter for commands/tool-results).',
157
161
  "workflow action to see workflow run overviews (status/budget/steps). Each step's call sessionId can jump to outline/detail for deep reading.",
158
162
  "result action to fetch a subagent's final result text (same content as its completion notice): session takes a single sa-id/uuid/path or a comma-separated batch of at most 10; optional limit caps chars per item (default 8000, truncated items carry a pointer to the full file).",
@@ -181,6 +185,9 @@ const registeredPis = new WeakSet<ExtensionAPI>()
181
185
  let currentCwdSessionDir: string | null = null
182
186
 
183
187
  export default function sessionReaderExtension(pi: ExtensionAPI): void {
188
+ // zcode 读链的结构化日志走「事后排查」通道(appendEntry,不进 LLM 上下文不显 TUI):
189
+ // 最早期注入 pi handle——tool-handler 的 getLogger('session-reader') 由此生效(U9)。
190
+ setPiHandle(pi)
184
191
  // u11(design 2026-09-10 §6.6):标题元数据走 pi 的 SessionManager.listAll(dir)——
185
192
  // session_info name 提取、首消息采集与并发解析由 pi 维护。注入范式同 §6.2 信号包:
186
193
  // pi 类型只在本层出现(SessionInfo 对发现层 SessionMetadataEntry 结构兼容,直接透传),
@@ -15,7 +15,7 @@
15
15
  * 可选 limit 参数(默认 8000 字符/条,超出截断 + 提示读原文件)。
16
16
  * 定位复用既有发现机制(sa-xxx manifest 反查 / uuid 片段 / 路径),不新造目录或文件。
17
17
  */
18
- import type { Entry, ParseResult } from './core/parser.js'
18
+ import type { Entry, ParseResult } from '@zhushanwen/session-core'
19
19
  import { textBlockParts } from './core/render.js'
20
20
  import type { MatchedSession } from './discovery/find.js'
21
21
  import { listRecordManifests, type RecordManifest } from './discovery/subagents.js'
@@ -107,7 +107,8 @@ interface ResultItem {
107
107
  session: string
108
108
  /** 反查到的真实 session id(header id,非 sa- 占位) */
109
109
  sessionId: string
110
- /** session .jsonl 绝对路径(截断提示读原文件用) */
110
+ /** 内容源路径:pi = session .jsonl 绝对路径(截断提示 read 用);zcode 路由 = 会话库
111
+ * .sqlite 绝对路径(二进制不可 read,截断提示只留 session_read 指针,见 formatResultTruncation) */
111
112
  sessionFile: string
112
113
  /** 全量正文字符数(截断前口径) */
113
114
  totalChars: number
@@ -122,16 +123,23 @@ interface ResultItem {
122
123
  * notifier.ts buildTruncationPointer 同模板的 X = **丢弃**字符数(total - kept)。
123
124
  * 两处措辞同构但口径相反(主 agent 会先后消费两种通知),统一字符串 = 行为变更,
124
125
  * 勿顺手改口径——读本行时先确认在消费哪一侧。
125
- * [C6] 英文去 emoji(对齐 notifier 指针行的纯英文形态,adversarial-review-fixes §3.4)。 */
126
+ * [C6] 英文去 emoji(对齐 notifier 指针行的纯英文形态,adversarial-review-fixes §3.4)。
127
+ *
128
+ * zcode 路由(zcodeRoute=true)下 sessionFile 是 sqlite 库路径,`read` 半边指针不可执行
129
+ * ——抑制之,只保留 `session_read {action:"detail"}` 可执行半边(错误 → 权威源 → 重试
130
+ * 闭环不破:权威源动作必须是 agent 真能执行的动作)。sessionFile 参数此形态下不进文案,
131
+ * 保留是为 pi/zcode 两路由签名同形。 */
126
132
  function formatResultTruncation(
127
133
  raw: string,
128
134
  sessionFile: string,
129
135
  limit: number,
130
136
  totalChars: number,
137
+ zcodeRoute: boolean,
131
138
  ): string {
139
+ const readHalf = zcodeRoute ? '' : `read ${sessionFile}, or `
132
140
  return (
133
141
  `\n\n[truncated ${limit} of ${totalChars} chars — ` +
134
- `full text: read ${sessionFile}, or session_read { action:"detail", session:"${raw}" }]`
142
+ `full text: ${readHalf}session_read { action:"detail", session:"${raw}" }]`
135
143
  )
136
144
  }
137
145
 
@@ -217,7 +225,8 @@ export async function doResult(
217
225
  totalChars,
218
226
  truncated,
219
227
  text: truncated
220
- ? text.slice(0, limit) + formatResultTruncation(id, resolved.fileName, limit, totalChars)
228
+ ? text.slice(0, limit) +
229
+ formatResultTruncation(id, resolved.fileName, limit, totalChars, resolved.zcodeAnchor !== undefined)
221
230
  : text,
222
231
  })
223
232
  }
@@ -13,7 +13,7 @@ import {
13
13
  type SessionMetadataEntry,
14
14
  type SessionMetadataProvider,
15
15
  } from './discovery/find.js'
16
- import { parseSessionFile } from './core/parser.js'
16
+ import { parseSessionFile } from '@zhushanwen/session-core'
17
17
  import { segmentTurns, type Turn } from './core/turns.js'
18
18
  import { buildTreeView } from './core/tree.js'
19
19
  import { BYTES_PER_KB, BYTES_PER_MB } from './core/units.js'