@zhushanwen/pi-session-reader 0.3.1 → 0.5.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.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * F1 无匹配域(u9,design 2026-09-10 §5.2 / §6.7 子决策 3)。
3
+ *
4
+ * 从 tool-handler.ts 机械提取(max-lines 拆分轮,零行为变更):F1 自检行 /
5
+ * 编辑距离候选 / formatNoMatch 渲染,消费方 = tool-handler 的 resolveByFragment
6
+ * 与 findNoMatch(find 零匹配)。自足模块——只依赖 discovery/core 的纯函数。
7
+ */
8
+ import { extractSessionIdFromFilename } from './discovery/subagents.js'
9
+ import { basename } from './core/toolcall.js'
10
+ import type { SessionRoot } from './discovery/roots.js'
11
+
12
+ /**
13
+ * Levenshtein 编辑距离(纯函数)。单行 DP 滚动数组,O(len(a)×len(b)) 时间。
14
+ * F1 用它对 uuid query 找最接近的候选 id(§5.2「最接近的候选」),让 agent 一眼自纠
15
+ *(如 01a08zzz → 01a08aac)。候选量级 ≤ 数千、uuid 36 字符,毫秒级。
16
+ */
17
+ export function levenshtein(a: string, b: string): number {
18
+ if (a === b) return 0
19
+ if (a.length === 0) return b.length
20
+ if (b.length === 0) return a.length
21
+ let prev = new Array<number>(b.length + 1)
22
+ let curr = new Array<number>(b.length + 1)
23
+ for (let j = 0; j <= b.length; j++) prev[j] = j
24
+ for (let i = 1; i <= a.length; i++) {
25
+ curr[0] = i
26
+ for (let j = 1; j <= b.length; j++) {
27
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
28
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
29
+ }
30
+ const swap = prev
31
+ prev = curr
32
+ curr = swap
33
+ }
34
+ return prev[b.length]
35
+ }
36
+
37
+ /** 首个差异位(1-based,人类位次);等串/一方为前缀时 undefined(无「X → Y」可标)。 */
38
+ function firstDiffPosition(a: string, b: string): number | undefined {
39
+ const n = Math.min(a.length, b.length)
40
+ for (let i = 0; i < n; i++) {
41
+ if (a[i] !== b[i]) return i + 1
42
+ }
43
+ return undefined
44
+ }
45
+
46
+ /** F1 编辑距离候选行上限(§5.2 top-3)。 */
47
+ const NO_MATCH_SUGGESTIONS = 3
48
+
49
+ /**
50
+ * F1 自检行根标签列宽(2 空格缩进 + 最长 `[subagent]` 10 字符 + 3 对齐间距,
51
+ * 同 doctor 表 DOCTOR_ROOT_LABEL_PAD 的对齐思路)。
52
+ */
53
+ const F1_ROOT_LABEL_PAD = 15
54
+
55
+ /** ① 自检行:逐根去重标注/文件数 + 合计非空与否 + 归一化已做(只陈述事实)。 */
56
+ function buildSelfCheckLines(roots: SessionRoot[]): string[] {
57
+ const lines: string[] = ['自检(发现层,只陈述事实):']
58
+ let totalFiles = 0
59
+ for (const r of roots) {
60
+ if (r.dedupedInto !== undefined) {
61
+ lines.push(` [${r.kind}]`.padEnd(F1_ROOT_LABEL_PAD) + `与 [${r.dedupedInto}] 同路径,已去重`)
62
+ continue
63
+ }
64
+ const count = r.fileCount ?? r.files.length
65
+ totalFiles += count
66
+ lines.push(` [${r.kind}]`.padEnd(F1_ROOT_LABEL_PAD) + `${r.path}:${count} 文件`)
67
+ }
68
+ lines.push(
69
+ totalFiles > 0
70
+ ? ` → 候选集非空(共 ${totalFiles} 文件),根解析正常`
71
+ : ' → 候选集为空(所有候选根均 0 文件)',
72
+ )
73
+ lines.push(' → 查询已做 uuid 归一化匹配(小写 + 去连字符)后仍无命中')
74
+ return lines
75
+ }
76
+
77
+ /** F1 编辑距离候选(sessionId + source + mtime + 距离)。 */
78
+ interface NoMatchSuggestion {
79
+ sessionId: string
80
+ source: string
81
+ mtime: number
82
+ distance: number
83
+ }
84
+
85
+ /** ② 候选集(roots 实扫文件名提取 id,同 id 取 mtime 新者)→ 编辑距离升序 top-3。 */
86
+ function collectSuggestions(query: string, roots: SessionRoot[]): NoMatchSuggestion[] {
87
+ const candidates = new Map<string, { sessionId: string; source: string; mtime: number }>()
88
+ for (const r of roots) {
89
+ if (r.dedupedInto !== undefined) continue
90
+ for (const f of r.files) {
91
+ const sid = extractSessionIdFromFilename(basename(f.path))
92
+ if (sid === '') continue
93
+ const existing = candidates.get(sid)
94
+ if (existing === undefined || f.mtime > existing.mtime) {
95
+ candidates.set(sid, { sessionId: sid, source: r.source, mtime: f.mtime })
96
+ }
97
+ }
98
+ }
99
+ return [...candidates.values()]
100
+ .map((c) => ({ ...c, distance: levenshtein(query, c.sessionId) }))
101
+ .sort((a, b) => a.distance - b.distance || b.mtime - a.mtime)
102
+ .slice(0, NO_MATCH_SUGGESTIONS)
103
+ }
104
+
105
+ /** ② 渲染:候选行(source 标注 + 首个差异位);空候选给明确说明。 */
106
+ function formatSuggestionLines(suggestions: NoMatchSuggestion[], query: string): string[] {
107
+ const lines = ['最接近的候选(编辑距离):']
108
+ if (suggestions.length === 0) {
109
+ lines.push(' (候选集中无可比对的 session id)')
110
+ }
111
+ for (const s of suggestions) {
112
+ const pos = firstDiffPosition(query, s.sessionId)
113
+ const diff =
114
+ pos !== undefined ? `差异在第 ${pos} 位:${query[pos - 1] ?? ''} → ${s.sessionId[pos - 1] ?? ''}` : ''
115
+ lines.push(` ${s.sessionId} ${s.source} ${diff}`.trimEnd())
116
+ }
117
+ return lines
118
+ }
119
+
120
+ /** ③ 正确做法四条 + ④ 封死 shell 绕行(纯静态文案)。 */
121
+ function guidanceLines(): string[] {
122
+ return [
123
+ '',
124
+ '正确做法:',
125
+ ` - 改用标题/keyword:session_read { action:"find", query:"<标题关键词>" }`,
126
+ ` - 已知文件路径时直接传绝对路径:session_read { action:"outline", session:"<绝对路径>.jsonl" }`,
127
+ ` - 截断/过期 id 用更短前缀:session_read { action:"find", query:"<更短前缀>" }`,
128
+ ` - 想先看环境与根目录状态:session_read { action:"doctor" }`,
129
+ '',
130
+ '不要用 shell find/ls/rg 搜 session 目录,不要 cat/read 原始 .jsonl —— session_read 是唯一入口。',
131
+ ]
132
+ }
133
+
134
+ /**
135
+ * F1 无匹配(u9 重写,design §5.2 形态——逐段规格):
136
+ *
137
+ * ① 事实型自检行:main 根 N 文件 / subagent 根 M 文件 /「候选集非空|为空」/「查询已做
138
+ * uuid 归一化匹配(小写+去连字符)后仍无命中」。计数取 roots——调用方在 F1 路径
139
+ * 恒以无 options 的 resolveSessionRoots 实扫(find/doctor 缓存句柄不注入,§7B 要点 8:
140
+ * 缓存计数会把 PS-14「首条 assistant 前 0 文件」误报成「主根为空」),即本次实扫结果。
141
+ * 只陈述事实,**不得**出现「真的没有这个 session」类归因断言(§3.3 教训:大写/去连
142
+ * 字符输入在归一化前会被错误归因为「不存在」)。
143
+ * ② 编辑距离最近候选 top-3(标注 source 与差异位),候选 = roots 实扫文件名提取的
144
+ * sessionId(extractSessionIdFromFilename 与 buildManifestIndex 同源;非 id 形态
145
+ * 文件名提不出 → 不进候选,文件名与 header id 同源由 pi 写入保证)。
146
+ * ③「正确做法」四条:标题/keyword、绝对路径 outline、更短前缀、doctor。
147
+ * ④ 最后一行封死 shell 绕行:find/ls/rg 搜 session 目录、cat/read 原始 .jsonl 均禁止。
148
+ *
149
+ * 无「👉 用 recent 看全量」误导指引(recent 候选对写错的 uuid 无自纠价值,§6.7 被否项)。
150
+ */
151
+ export function formatNoMatch(query: string, roots: SessionRoot[]): string {
152
+ return [
153
+ `无匹配 session:"${query}"`,
154
+ '',
155
+ ...buildSelfCheckLines(roots),
156
+ '',
157
+ ...formatSuggestionLines(collectSuggestions(query, roots), query),
158
+ ...guidanceLines(),
159
+ ].join('\n')
160
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * result action(subagent-sync-collect U6:design subagent-sync-collect.md §3.1.3)。
3
+ *
4
+ * 从 tool-handler.ts 机械提取(max-lines 拆分轮):纯移动零行为变更。tool-handler 的
5
+ * 定位/解析 helper(err/stripHash/requireStr/resolveSessionId/disambiguate/safeParse)
6
+ * 为文件私有且被其余 9 个 action 共用,不便搬移——经 {@link ResultActionDeps} 注入,
7
+ * tool-handler.ts 构造 RESULT_ACTION_DEPS 常量传入(构造期绑定,运行时零查找开销)。
8
+ *
9
+ * 设计来源:docs/design/subagent-sync-collect.md §3.1.3 ——
10
+ * session_read {"action":"result","session":"sa-aaa"} // 单个
11
+ * session_read {"action":"result","session":"sa-aaa,sa-bbb,sa-ccc"} // 批量(≤10)
12
+ * → 每条返回该 subagent session 的最终 assistant 正文(与通知 record.result 同源),
13
+ * 可选 limit 参数(默认 8000 字符/条,超出截断 + 提示读原文件)。
14
+ * 定位复用既有发现机制(sa-xxx manifest 反查 / uuid 片段 / 路径),不新造目录或文件。
15
+ */
16
+ import type { Entry, ParseResult } from './core/parser.js'
17
+ import type { MatchedSession } from './discovery/find.js'
18
+ import { listRecordManifests, type RecordManifest } from './discovery/subagents.js'
19
+ import type { ResolveResult, SessionReadAction, SessionReadParams, ToolResult } from './tool-handler.js'
20
+
21
+ /** result 批量上限(design subagent-sync-collect §3.1.3:一次最多 10 个)。 */
22
+ const RESULT_MAX_BATCH = 10
23
+
24
+ /** result 单条默认字符上限(design §3.1.3:默认 8000 字符/条)。 */
25
+ const RESULT_DEFAULT_LIMIT = 8000
26
+
27
+ /** result 批量条目分隔符(与 notifier 批通知 "\n\n---\n\n" join 同款)。 */
28
+ const RESULT_BATCH_SEPARATOR = '\n\n---\n\n'
29
+
30
+ /** result action 对 tool-handler 私有 helper 的注入面(构造期常量绑定)。 */
31
+ export interface ResultActionDeps {
32
+ err(message: string): Error
33
+ stripHash(s: string): string
34
+ requireStr(val: string | undefined, name: string, action: SessionReadAction): string
35
+ resolveSessionId(
36
+ rawSession: string | undefined,
37
+ action: SessionReadAction,
38
+ agentDir: string,
39
+ source?: 'main' | 'subagent',
40
+ /** S3:批量预取的 manifest 列表(避免逐 id 重复全量扫描);单 id 不传。 */
41
+ prefetchedManifests?: RecordManifest[],
42
+ ): Promise<ResolveResult>
43
+ disambiguate(query: string, candidates: MatchedSession[]): ToolResult
44
+ safeParse(fileName: string): Promise<ParseResult>
45
+ /** 批量头行短 id 截断宽度(tool-handler SESSION_ID_PREFIX_LEN 同源)。 */
46
+ sessionIdPrefixLen: number
47
+ }
48
+
49
+ /**
50
+ * 从 assistant message content 提取纯 text(text 块顺序拼接)。
51
+ *
52
+ * 与 record 侧 text_delta 直累积同构:同一 message 内多个 text 块无分隔拼接
53
+ *(流式 delta 逐段 append),thinking/toolCall 块不入 record.text,此处同样排除。
54
+ *
55
+ * [S7 code-simplify 登记] 本包内第 4 个同構「text 块提取」变体(其余三处均在
56
+ * tool-handler.ts / discovery/find.ts):messageReadableText('' join + 占位符)、
57
+ * extractContentText('\n' join)、extractTextFromContent(' ' join + 空返 undefined)。
58
+ * 本变体的 '' 无分隔拼接是 A4 取回逐字节一致锁定的硬理由(对齐 record.result 的
59
+ * 流式 delta 无分隔累积),不可与带分隔符的变体合并——差异是行为敏感点,勿「顺手统一」。
60
+ */
61
+ function assistantMessageText(content: unknown): string {
62
+ if (typeof content === 'string') return content
63
+ if (!Array.isArray(content)) return ''
64
+ let out = ''
65
+ for (const block of content) {
66
+ if (block !== null && typeof block === 'object') {
67
+ const o = block as Record<string, unknown>
68
+ if (o.type === 'text' && typeof o.text === 'string') out += o.text
69
+ }
70
+ }
71
+ return out
72
+ }
73
+
74
+ /**
75
+ * 从 session entries 提取最终结果正文(与完成通知 record.result 同源,A4 逐字节一致的前置)。
76
+ *
77
+ * 同源语义(packages/subagent-core/src/execution/execution-record.ts getFullText,只读参考):
78
+ * record.result = AgentResult.text = getFullText(record) = 全部 assistant turn 文本
79
+ * filter(非空,空白串按非空) join("\\n\\n");pi 每 turn_end 对应一条 assistant message
80
+ *(agent-session.js turn_end 携带 message),故文件侧重建 = 每条 assistant message 的
81
+ * text 块顺序拼接 → 跨 message 非空过滤 join("\\n\\n")。
82
+ *
83
+ * 取「最后一条 user message 之后」的 assistant 文本:one-shot(单 user prompt,
84
+ * collect:sync 的目标形态)与 join-all 逐字节一致;多轮 chatMode session 对齐
85
+ * 「最终一轮输出」语义。
86
+ *
87
+ * 导出供单测白盒锁定同源重建规则(message 内 '' join / 跨 message '\n\n' join /
88
+ * thinking 与 toolCall 排除 / 空 text 过滤 / 末 user 边界)。
89
+ */
90
+ export function extractFinalAssistantText(entries: Entry[]): string {
91
+ let lastUserIdx = -1
92
+ for (let i = entries.length - 1; i >= 0; i--) {
93
+ if (entries[i]?.message?.role === 'user') {
94
+ lastUserIdx = i
95
+ break
96
+ }
97
+ }
98
+ const texts: string[] = []
99
+ for (let i = lastUserIdx + 1; i < entries.length; i++) {
100
+ const e = entries[i]
101
+ if (e?.message?.role !== 'assistant') continue
102
+ const text = assistantMessageText(e.message.content)
103
+ if (text.length > 0) texts.push(text)
104
+ }
105
+ return texts.join('\n\n')
106
+ }
107
+
108
+ /** result 单条取回结果(单条 details 与批量 details.items 共用形状)。 */
109
+ interface ResultItem {
110
+ /** 调用方原始输入(sa-id / uuid 片段 / 路径) */
111
+ session: string
112
+ /** 反查到的真实 session id(header id,非 sa- 占位) */
113
+ sessionId: string
114
+ /** session .jsonl 绝对路径(截断提示读原文件用) */
115
+ sessionFile: string
116
+ /** 全量正文字符数(截断前口径) */
117
+ totalChars: number
118
+ truncated: boolean
119
+ /** 实际展示正文(截断后含尾提示;未截断时 = 全量正文) */
120
+ text: string
121
+ }
122
+
123
+ /** result 截断尾提示(design §3.1.3:超出截断 + 提示读原文件)。 */
124
+ /** result 条目截断提示行。
125
+ *
126
+ * [S8 code-simplify 口径注] 本函数的 X = **保留**字符数(limit);而 subagent-core
127
+ * notifier.ts buildTruncationPointer 同模板的 X = **丢弃**字符数(total - kept)。
128
+ * 两处措辞同构但口径相反(主 agent 会先后消费两种通知),统一字符串 = 行为变更,
129
+ * 勿顺手改口径——读本行时先确认在消费哪一侧。
130
+ * [C6] 英文去 emoji(对齐 notifier 指针行的纯英文形态,adversarial-review-fixes §3.4)。 */
131
+ function formatResultTruncation(
132
+ raw: string,
133
+ sessionFile: string,
134
+ limit: number,
135
+ totalChars: number,
136
+ ): string {
137
+ return (
138
+ `\n\n[truncated ${limit} of ${totalChars} chars — ` +
139
+ `full text: read ${sessionFile}, or session_read { action:"detail", session:"${raw}" }]`
140
+ )
141
+ }
142
+
143
+ /**
144
+ * result 的 limit 解析:缺省 8000;非有限数/非正数报错(schema 已限 Number,此处防御
145
+ * + 可单测绕过 schema)。向下取整防小数 slice 语义模糊。
146
+ */
147
+ function resolveResultLimit(raw: number | undefined): number {
148
+ if (raw === undefined) return RESULT_DEFAULT_LIMIT
149
+ if (!Number.isFinite(raw) || raw <= 0) {
150
+ throw new Error(
151
+ `limit "${String(raw)}" 无效,需为正数。👉 传正整数重试(缺省 ${RESULT_DEFAULT_LIMIT} 字符/条)。`,
152
+ )
153
+ }
154
+ return Math.floor(raw)
155
+ }
156
+
157
+ /**
158
+ * result 的 session 列表解析:单 id 或逗号批量(≤10)。
159
+ * 空条目(如 "sa-a," 或连续逗号)与超限均明确报错(含上限数字与 👉)。
160
+ */
161
+ function parseResultSessionList(raw: string | undefined, deps: ResultActionDeps): string[] {
162
+ const trimmed = deps.requireStr(raw, 'session', 'result')
163
+ const ids = trimmed.split(',').map((s) => deps.stripHash(s.trim()))
164
+ if (ids.some((id) => id === '')) {
165
+ throw deps.err(
166
+ `session 列表含空条目:"${trimmed}"。👉 检查逗号分隔格式(如 "sa-aaa,sa-bbb")重试。`,
167
+ )
168
+ }
169
+ if (ids.length > RESULT_MAX_BATCH) {
170
+ throw deps.err(
171
+ `批量取回 ${ids.length} 个 session 超上限:一次最多 ${RESULT_MAX_BATCH} 个。` +
172
+ `\n👉 分批取回(每批 ≤${RESULT_MAX_BATCH} 个 id)。`,
173
+ )
174
+ }
175
+ return ids
176
+ }
177
+
178
+ /**
179
+ * result:取回 subagent session 的最终结果正文(design subagent-sync-collect §3.1.3)。
180
+ *
181
+ * 流程:逐 id 走 resolveSessionId 三形态反查(sa-xxx manifest 精确反查 / uuid 片段 /
182
+ * 绝对路径,multi 走既有 disambiguate)→ safeParse → extractFinalAssistantText
183
+ *(与通知 record.result 同源)→ limit 截断。定位零新机制,不新造目录或文件。
184
+ *
185
+ * content 形态:单 id = 纯正文(无包装,未截断时与 record.result 逐字节一致,A4 取回门);
186
+ * 批量 = 每条目 `[i/n] <原始id> (session <短id>)` 头行 + 正文,'\\n\\n---\\n\\n' join。
187
+ *
188
+ * 错误路径(明确报错不返空串):id 无匹配(ES2/F1 风格)、manifest 有但文件不存在
189
+ *(ES1:GC 或 pi session 延迟写入尚未 flush)、无 assistant 输出(运行中/未写正文)、
190
+ * 批量超限 / 空条目 / limit 非法。
191
+ */
192
+ export async function doResult(
193
+ params: SessionReadParams,
194
+ agentDir: string,
195
+ deps: ResultActionDeps,
196
+ ): Promise<ToolResult> {
197
+ const ids = parseResultSessionList(params.session, deps)
198
+ const limit = resolveResultLimit(params.limit)
199
+ // S3(code-simplify):sa- 形态走 manifest 反查,逐 id 调用会重复全量扫 subagents/ 树
200
+ //(N+1)——批量入口预取一次注入。uuid 片段/路径形态不经 manifest,不预取。
201
+ const prefetchedManifests = ids.some((id) => id.startsWith('sa-'))
202
+ ? await listRecordManifests(agentDir)
203
+ : undefined
204
+ const items: ResultItem[] = []
205
+ for (const id of ids) {
206
+ const resolved = await deps.resolveSessionId(id, 'result', agentDir, params.source, prefetchedManifests)
207
+ if (resolved.kind === 'multi') return deps.disambiguate(resolved.query, resolved.candidates)
208
+ const { entries } = await deps.safeParse(resolved.fileName)
209
+ const text = extractFinalAssistantText(entries)
210
+ if (text.length === 0) {
211
+ throw deps.err(
212
+ `session "${id}" 尚无 assistant 输出(运行中或文件尚未 flush 完整)。` +
213
+ `\n👉 稍后重试,或用 session_read { action:"outline", session:"${id}" } 看当前进度。`,
214
+ )
215
+ }
216
+ const totalChars = text.length
217
+ const truncated = totalChars > limit
218
+ items.push({
219
+ session: id,
220
+ sessionId: resolved.sessionId,
221
+ sessionFile: resolved.fileName,
222
+ totalChars,
223
+ truncated,
224
+ text: truncated
225
+ ? text.slice(0, limit) + formatResultTruncation(id, resolved.fileName, limit, totalChars)
226
+ : text,
227
+ })
228
+ }
229
+ if (items.length === 1) {
230
+ const only = items[0]
231
+ return {
232
+ content: [{ type: 'text', text: only.text }],
233
+ details: { ...only },
234
+ }
235
+ }
236
+ const blocks = items.map(
237
+ (it, i) =>
238
+ `[${i + 1}/${items.length}] ${it.session} (session ${it.sessionId.slice(0, deps.sessionIdPrefixLen)}…)\n${it.text}`,
239
+ )
240
+ return {
241
+ content: [{ type: 'text', text: blocks.join(RESULT_BATCH_SEPARATOR) }],
242
+ details: { count: items.length, items },
243
+ }
244
+ }