@zhushanwen/pi-session-reader 0.4.0 → 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
+ }