@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.
- package/package.json +3 -2
- package/src/__tests__/cross-package-subagent-core.test.ts +321 -0
- package/src/__tests__/env.test.ts +258 -0
- package/src/__tests__/execution-tree.test.ts +9 -8
- package/src/__tests__/find.test.ts +214 -9
- package/src/__tests__/hash-provider.test.ts +2 -2
- package/src/__tests__/index.test.ts +157 -5
- package/src/__tests__/real-data.ts +10 -0
- package/src/__tests__/result.test.ts +341 -0
- package/src/__tests__/roots.test.ts +362 -9
- package/src/__tests__/session-command.test.ts +2 -2
- package/src/__tests__/subagents.test.ts +7 -6
- package/src/__tests__/tool-handler.test.ts +1216 -7
- package/src/discovery/env.ts +238 -0
- package/src/discovery/find.ts +363 -56
- package/src/discovery/roots.ts +236 -8
- package/src/discovery/subagents.ts +31 -11
- package/src/doctor.ts +262 -0
- package/src/extract.ts +422 -0
- package/src/handler-utils.ts +63 -0
- package/src/index.ts +63 -9
- package/src/no-match.ts +160 -0
- package/src/result-action.ts +244 -0
- package/src/search-across.ts +539 -0
- package/src/tool-handler.ts +554 -723
- package/vitest.config.ts +15 -0
package/src/tool-handler.ts
CHANGED
|
@@ -4,8 +4,16 @@
|
|
|
4
4
|
* 分层约定(同 scheduler/cw-tool):本文件零 pi 依赖——agentDir 作参数注入,
|
|
5
5
|
* 不调用 getAgentDir(),可完全单测;pi 注册与 getAgentDir() 调用在 index.ts。
|
|
6
6
|
*
|
|
7
|
-
* 按 action 分发到
|
|
8
|
-
*(find/subagents)。content 给 LLM
|
|
7
|
+
* 按 action 分发到 11 条路径,串联 M1 core(parser/tree/turns/render)+ M2 discovery
|
|
8
|
+
*(find/subagents)+ doctor 的环境判定与根表渲染(u8,discovery/env)。content 给 LLM
|
|
9
|
+
* 读(人类可读摘要),details 供程序化消费/测试断言。
|
|
10
|
+
*
|
|
11
|
+
* 域模块拆分(max-lines 拆分轮机械提取,零行为变更,result-action.ts 先例同型):
|
|
12
|
+
* result-action.ts(result)/ doctor.ts(doctor + SessionReadSignals)/
|
|
13
|
+
* search-across.ts(search 管线 + u12 跨会话)/ extract.ts(extract 预设)/
|
|
14
|
+
* no-match.ts(F1 自检行)/ handler-utils.ts(pad/err/turn 索引解析低层小工具)。
|
|
15
|
+
* 本模块保留公共类型、定位解析(resolveSessionId)、各 action 编排与共享渲染;
|
|
16
|
+
* 拆出域的公开导出经此 re-export(index.ts / 单测白盒 import 路径不变)。
|
|
9
17
|
*
|
|
10
18
|
* 错误规格 F1-F6:handler 抛 Error(message 含 👉 恢复指引),index.ts 的 execute 闭包
|
|
11
19
|
* 原样传播给 pi——pi-agent-core 只对 execute throw 置 isError:true(返回值里的 isError
|
|
@@ -16,14 +24,23 @@ import { existsSync, openSync, readSync, closeSync } from 'node:fs'
|
|
|
16
24
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
17
25
|
import { join, isAbsolute } from 'node:path'
|
|
18
26
|
import { homedir } from 'node:os'
|
|
19
|
-
import {
|
|
20
|
-
|
|
27
|
+
import {
|
|
28
|
+
findSessions,
|
|
29
|
+
type MatchedSession,
|
|
30
|
+
type SessionMetadataEntry,
|
|
31
|
+
type SessionMetadataProvider,
|
|
32
|
+
} from './discovery/find.js'
|
|
33
|
+
import { resolveSessionRoots } from './discovery/roots.js'
|
|
34
|
+
import {
|
|
35
|
+
buildFamilyFromFs,
|
|
36
|
+
listRecordManifests,
|
|
37
|
+
type RecordManifest,
|
|
38
|
+
} from './discovery/subagents.js'
|
|
21
39
|
import { readRunSnapshot, resolveWorkflows } from './discovery/workflows.js'
|
|
22
40
|
import { parseSessionFile, type Entry, type ParseResult } from './core/parser.js'
|
|
23
41
|
import { parseRunSnapshot, renderWorkflowOverview, type WorkflowOverview } from './core/workflow.js'
|
|
24
42
|
import { buildTreeView } from './core/tree.js'
|
|
25
|
-
import { segmentTurns
|
|
26
|
-
import { extractToolCalls, formatToolCallSummary, basename } from './core/toolcall.js'
|
|
43
|
+
import { segmentTurns } from './core/turns.js'
|
|
27
44
|
import {
|
|
28
45
|
renderOutline,
|
|
29
46
|
renderExpand,
|
|
@@ -35,11 +52,43 @@ import {
|
|
|
35
52
|
type ToolResultSummaryEntry,
|
|
36
53
|
} from './core/render.js'
|
|
37
54
|
import type { Family, SessionRef, WorkflowRef } from './core/family.js'
|
|
55
|
+
import { doResult, extractFinalAssistantText, type ResultActionDeps } from './result-action.js'
|
|
56
|
+
|
|
57
|
+
// result action 主体在 result-action.ts(max-lines 拆分轮机械提取,零行为变更);
|
|
58
|
+
// 导出面保持不变:extractFinalAssistantText 仍从本模块导出(包内测试白盒导入路径不变)。
|
|
38
59
|
import {
|
|
39
60
|
buildExecutionTree,
|
|
40
61
|
formatExecutionTreeText,
|
|
41
62
|
type ExecutionTree,
|
|
42
63
|
} from './core/execution-tree.js'
|
|
64
|
+
// 同轮拆分的域模块(依赖方向:本模块 → 域模块 → handler-utils,无循环;
|
|
65
|
+
// 域模块对本模块仅 type import——编译期擦除,同 result-action.ts 先例)。
|
|
66
|
+
import { err, pad, parseTurnIndex, parseTurnsRange, rangeLabel } from './handler-utils.js'
|
|
67
|
+
import { formatNoMatch } from './no-match.js'
|
|
68
|
+
import {
|
|
69
|
+
collectSearchHits,
|
|
70
|
+
compilePattern,
|
|
71
|
+
formatSearchText,
|
|
72
|
+
isCatastrophicPattern,
|
|
73
|
+
searchAcrossSessions,
|
|
74
|
+
SEARCH_DEFAULT_LIMIT,
|
|
75
|
+
} from './search-across.js'
|
|
76
|
+
import {
|
|
77
|
+
extractCommits,
|
|
78
|
+
extractCommands,
|
|
79
|
+
extractFiles,
|
|
80
|
+
extractToolResults,
|
|
81
|
+
extractUserMessages,
|
|
82
|
+
type ExtractWhat,
|
|
83
|
+
} from './extract.js'
|
|
84
|
+
import { doDoctor, DOCTOR_CACHE_TTL_MS, statDirMtimeOrNull, type SessionReadSignals } from './doctor.js'
|
|
85
|
+
|
|
86
|
+
// 拆出域的公开导出面保持从本模块可见(index.ts / 单测白盒 import 路径不变)。
|
|
87
|
+
export type { SessionReadSignals }
|
|
88
|
+
export { DOCTOR_CACHE_TTL_MS }
|
|
89
|
+
export { levenshtein } from './no-match.js'
|
|
90
|
+
export { MULTI_SEARCH_MAX_SESSIONS, SEARCH_SCAN_BYTE_BUDGET, searchAcrossSessions } from './search-across.js'
|
|
91
|
+
export { renderExtractItems } from './extract.js'
|
|
43
92
|
|
|
44
93
|
// ---------------------------------------------------------------------------
|
|
45
94
|
// 公共类型(与 index.ts 的 TypeBox schema 对齐)
|
|
@@ -55,6 +104,8 @@ export type SessionReadAction =
|
|
|
55
104
|
| 'export'
|
|
56
105
|
| 'extract'
|
|
57
106
|
| 'workflow'
|
|
107
|
+
| 'result'
|
|
108
|
+
| 'doctor'
|
|
58
109
|
|
|
59
110
|
export interface SessionReadParams {
|
|
60
111
|
action: SessionReadAction
|
|
@@ -81,6 +132,12 @@ export interface SessionReadParams {
|
|
|
81
132
|
tool?: string
|
|
82
133
|
/** family action: 返回嵌套执行树(任意深度 subagent↔workflow-call 相互嵌套)。默认 false(flat family)。 */
|
|
83
134
|
recursive?: boolean
|
|
135
|
+
/**
|
|
136
|
+
* doctor action: 是否同时扫描 subagent 根(产文件数)。默认 false——subagent 根在纯 pi
|
|
137
|
+
* 下可达数千文件,doctor 可能被反复询问(design 2026-09-10 §6.3 成本控制),默认只列
|
|
138
|
+
* 路径与可扫性(exists)。
|
|
139
|
+
*/
|
|
140
|
+
includeSubagents?: boolean
|
|
84
141
|
}
|
|
85
142
|
|
|
86
143
|
export interface ToolResult {
|
|
@@ -92,16 +149,6 @@ export interface ToolResult {
|
|
|
92
149
|
// 小工具
|
|
93
150
|
// ---------------------------------------------------------------------------
|
|
94
151
|
|
|
95
|
-
/** turn 索引显示宽度(T013 三位补零)。 */
|
|
96
|
-
const TURN_INDEX_WIDTH = 3
|
|
97
|
-
|
|
98
|
-
const pad = (n: number): string => String(n).padStart(TURN_INDEX_WIDTH, '0')
|
|
99
|
-
|
|
100
|
-
/** 构造带 👉 恢复指引的 Error(handler 抛出,由 execute 闭包 catch)。 */
|
|
101
|
-
function err(message: string): Error {
|
|
102
|
-
return new Error(message)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
152
|
/** 剥 # 前缀(TUI `#e6c96` 引用 → 纯片段,design §3.3 D-3/D-4)。 */
|
|
106
153
|
function stripHash(s: string): string {
|
|
107
154
|
return s.replace(/^#+/, '')
|
|
@@ -139,53 +186,11 @@ function requireStr(
|
|
|
139
186
|
return val.trim()
|
|
140
187
|
}
|
|
141
188
|
|
|
142
|
-
// ---------------------------------------------------------------------------
|
|
143
|
-
// turn / turns 索引解析
|
|
144
|
-
// ---------------------------------------------------------------------------
|
|
145
|
-
|
|
146
|
-
const TURN_RE = /^T?(\d+)$/i
|
|
147
|
-
|
|
148
|
-
function parseTurnIndex(raw: string): number {
|
|
149
|
-
const m = raw.trim().match(TURN_RE)
|
|
150
|
-
if (!m) {
|
|
151
|
-
throw err(
|
|
152
|
-
`turn "${raw}" 格式无效(应为 T013 或 013)。👉 用合法 turn 索引重试,或 outline 重看有效范围。`,
|
|
153
|
-
)
|
|
154
|
-
}
|
|
155
|
-
return parseInt(m[1], 10)
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** turns 范围 "T013-T015" 的段数。 */
|
|
159
|
-
const TURNS_RANGE_PARTS = 2
|
|
160
|
-
|
|
161
|
-
function parseTurnsRange(raw: string): { start: number; end: number } {
|
|
162
|
-
const parts = raw.split('-').map((s) => s.trim())
|
|
163
|
-
if (parts.length === 1) {
|
|
164
|
-
const i = parseTurnIndex(parts[0])
|
|
165
|
-
return { start: i, end: i }
|
|
166
|
-
}
|
|
167
|
-
if (parts.length === TURNS_RANGE_PARTS) {
|
|
168
|
-
const start = parseTurnIndex(parts[0])
|
|
169
|
-
const end = parseTurnIndex(parts[1])
|
|
170
|
-
if (end < start) {
|
|
171
|
-
throw err(
|
|
172
|
-
`turns 范围 "${raw}" 起始大于结束。👉 检查范围格式(如 T013-T015)重试。`,
|
|
173
|
-
)
|
|
174
|
-
}
|
|
175
|
-
return { start, end }
|
|
176
|
-
}
|
|
177
|
-
throw err(`turns "${raw}" 格式无效(应为 T013 或 T013-T015)。👉 用合法范围重试。`)
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function rangeLabel(r: { start: number; end: number }): string {
|
|
181
|
-
return r.start === r.end ? `T${pad(r.start)}` : `T${pad(r.start)}-T${pad(r.end)}`
|
|
182
|
-
}
|
|
183
|
-
|
|
184
189
|
// ---------------------------------------------------------------------------
|
|
185
190
|
// resolveSessionId:片段 → 完整 id(design §3.4 resolveSessionId 辅助)
|
|
186
191
|
// ---------------------------------------------------------------------------
|
|
187
192
|
|
|
188
|
-
type ResolveResult =
|
|
193
|
+
export type ResolveResult =
|
|
189
194
|
| { kind: 'ok'; sessionId: string; fileName: string }
|
|
190
195
|
| { kind: 'multi'; query: string; candidates: MatchedSession[] }
|
|
191
196
|
|
|
@@ -246,78 +251,115 @@ function expandHome(p: string): string {
|
|
|
246
251
|
}
|
|
247
252
|
|
|
248
253
|
/**
|
|
249
|
-
* 把 session 参数解析到唯一完整 id(design §6.1 M0 + U2/U3
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* -
|
|
253
|
-
|
|
254
|
-
* (禁止降级 record.id——sa- 形态不可当 sessionId,CQ3 决策)
|
|
255
|
-
* - ③ 其余 → findSessions 透传 source 沿用 F1/F2
|
|
254
|
+
* 把 session 参数解析到唯一完整 id(design §6.1 M0 + U2/U3)。三形态各拆独立解析器:
|
|
255
|
+
* resolveBySessionPath(① 绝对路径/~)→ resolveByRecordId(② sa- 前缀)→ resolveByFragment(③ 片段)。
|
|
256
|
+
* 错误契约(U3):① 文件不存在/非 .jsonl/header 读不出 → F6 风格;② sessionFile GC → ES1(manifest 元数据 + 👉);
|
|
257
|
+
* sa-id 0/>1 命中 → ES2(👉 family)。仅用于 family/outline/expand/detail/search/export/extract/workflow/result
|
|
258
|
+
*(find 自行调 findSessions,零匹配时返回空 + 提示,不抛错;doctor 为环境自检,无 session 解析)。
|
|
256
259
|
*
|
|
257
|
-
*
|
|
258
|
-
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
* 零匹配时返回空 + 提示,不抛错)。
|
|
260
|
+
* liveSessionDir(§6.1 信号 1)只作用于形态③:片段匹配与 find 消费同一 roots
|
|
261
|
+
*([live] 根对片段解析可见——否则 find 能列出的 session 在 [live]≠[default] 环境
|
|
262
|
+
* 下 outline 等解析不到,违反「同一 roots」契约);①按路径直读、②按 agentDir 下
|
|
263
|
+
* manifest 反查,均不依赖根列表,不消费该信号。
|
|
262
264
|
*/
|
|
263
265
|
async function resolveSessionId(
|
|
264
266
|
rawSession: string | undefined,
|
|
265
267
|
action: SessionReadAction,
|
|
266
268
|
agentDir: string,
|
|
267
269
|
source?: 'main' | 'subagent',
|
|
270
|
+
/** S3(code-simplify):批量调用方(doResult)预取的 manifest 列表——省去逐 id
|
|
271
|
+
* 重复全量扫 subagents/ 树(N+1)。单 id 调用点不传,行为零变化。 */
|
|
272
|
+
prefetchedManifests?: RecordManifest[],
|
|
273
|
+
/** 信号包中的 liveSessionDir,透传形态③(resolveByFragment);缺省 = 三根降级。 */
|
|
274
|
+
liveSessionDir?: string,
|
|
268
275
|
): Promise<ResolveResult> {
|
|
269
276
|
const session = stripHash(requireStr(rawSession, 'session', action))
|
|
270
277
|
|
|
271
278
|
// ① 绝对路径或 ~ 前缀(Windows 盘符由 isAbsolute 处理)
|
|
272
279
|
if (isAbsolute(session) || session === '~' || session.startsWith('~/')) {
|
|
273
|
-
|
|
274
|
-
if (!expanded.endsWith('.jsonl')) {
|
|
275
|
-
throw err(
|
|
276
|
-
`读取失败:${session}(非 .jsonl session 文件)。👉 检查文件或换 session。`,
|
|
277
|
-
)
|
|
278
|
-
}
|
|
279
|
-
if (!existsSync(expanded)) {
|
|
280
|
-
throw err(`读取失败:${session}(文件不存在)。👉 检查文件或换 session。`)
|
|
281
|
-
}
|
|
282
|
-
const headerId = readSessionHeaderId(expanded)
|
|
283
|
-
if (headerId === undefined) {
|
|
284
|
-
throw err(
|
|
285
|
-
`读取失败:${session}(首行非合法 session header)。👉 检查文件或换 session。`,
|
|
286
|
-
)
|
|
287
|
-
}
|
|
288
|
-
return { kind: 'ok', sessionId: headerId, fileName: expanded }
|
|
280
|
+
return resolveBySessionPath(session)
|
|
289
281
|
}
|
|
290
282
|
|
|
291
283
|
// ② sa-id 前缀 → record manifest 精确反查
|
|
292
284
|
if (session.startsWith('sa-')) {
|
|
293
|
-
|
|
294
|
-
const hits = manifests.filter((m) => m.id === session)
|
|
295
|
-
if (hits.length === 0) {
|
|
296
|
-
throw err(formatSaIdNotFound(session))
|
|
297
|
-
}
|
|
298
|
-
if (hits.length > 1) {
|
|
299
|
-
throw err(formatSaIdAmbiguous(session, hits))
|
|
300
|
-
}
|
|
301
|
-
const record = hits[0]
|
|
302
|
-
if (!existsSync(record.sessionFile)) {
|
|
303
|
-
throw err(formatSessionGc(record))
|
|
304
|
-
}
|
|
305
|
-
const headerId = readSessionHeaderId(record.sessionFile)
|
|
306
|
-
if (headerId === undefined) {
|
|
307
|
-
// header 读不出不降级 record.id(sa- 形态不可当 sessionId,CQ3)
|
|
308
|
-
throw err(
|
|
309
|
-
`读取失败:${record.sessionFile}(首行非合法 session header)。👉 检查文件或换 session。`,
|
|
310
|
-
)
|
|
311
|
-
}
|
|
312
|
-
return { kind: 'ok', sessionId: headerId, fileName: record.sessionFile }
|
|
285
|
+
return resolveByRecordId(session, agentDir, prefetchedManifests)
|
|
313
286
|
}
|
|
314
287
|
|
|
315
|
-
// ③ 其余:findSessions 透传 source 沿用 F1/F2
|
|
316
|
-
|
|
288
|
+
// ③ 其余:findSessions 透传 source/liveSessionDir 沿用 F1/F2
|
|
289
|
+
return resolveByFragment(session, agentDir, source, liveSessionDir)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 形态①:绝对路径 / ~ 前缀 → 展开后读首行 header,sessionId=header 真实 id(文件名仅定位)。 */
|
|
293
|
+
function resolveBySessionPath(session: string): ResolveResult {
|
|
294
|
+
const expanded = expandHome(session)
|
|
295
|
+
if (!expanded.endsWith('.jsonl')) {
|
|
296
|
+
throw err(
|
|
297
|
+
`读取失败:${session}(非 .jsonl session 文件)。👉 检查文件或换 session。`,
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
if (!existsSync(expanded)) {
|
|
301
|
+
throw err(`读取失败:${session}(文件不存在)。👉 检查文件或换 session。`)
|
|
302
|
+
}
|
|
303
|
+
const headerId = readSessionHeaderId(expanded)
|
|
304
|
+
if (headerId === undefined) {
|
|
305
|
+
throw err(
|
|
306
|
+
`读取失败:${session}(首行非合法 session header)。👉 检查文件或换 session。`,
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
return { kind: 'ok', sessionId: headerId, fileName: expanded }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 形态②:sa-id 前缀 → record manifest 精确反查,sessionId=sessionFile header id
|
|
314
|
+
*(禁止降级 record.id——sa- 形态不可当 sessionId,CQ3 决策)。批量调用方传预取列表
|
|
315
|
+
*(S3:避免逐 id 全量重扫);单 id 调用点现场扫一次。
|
|
316
|
+
*/
|
|
317
|
+
async function resolveByRecordId(session: string, agentDir: string, prefetchedManifests: RecordManifest[] | undefined): Promise<ResolveResult> {
|
|
318
|
+
const manifests = prefetchedManifests ?? (await listRecordManifests(agentDir))
|
|
319
|
+
const hits = manifests.filter((m) => m.id === session)
|
|
320
|
+
if (hits.length === 0) {
|
|
321
|
+
throw err(formatSaIdNotFound(session))
|
|
322
|
+
}
|
|
323
|
+
if (hits.length > 1) {
|
|
324
|
+
throw err(formatSaIdAmbiguous(session, hits))
|
|
325
|
+
}
|
|
326
|
+
const record = hits[0]
|
|
327
|
+
if (!existsSync(record.sessionFile)) {
|
|
328
|
+
throw err(formatSessionGc(record))
|
|
329
|
+
}
|
|
330
|
+
const headerId = readSessionHeaderId(record.sessionFile)
|
|
331
|
+
if (headerId === undefined) {
|
|
332
|
+
// header 读不出不降级 record.id(sa- 形态不可当 sessionId,CQ3)
|
|
333
|
+
throw err(
|
|
334
|
+
`读取失败:${record.sessionFile}(首行非合法 session header)。👉 检查文件或换 session。`,
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
return { kind: 'ok', sessionId: headerId, fileName: record.sessionFile }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* 形态③:其余片段 → findSessions 透传 source/liveSessionDir 沿用 F1(零匹配)/ F2(多匹配消歧)。
|
|
342
|
+
* liveSessionDir 透传保证片段匹配与 find 同一 roots;F1 自检行同理须含 [live] 根行
|
|
343
|
+
*(否则 [live]≠[default] 环境下自检行看不到最高优先级根,计数失真)。
|
|
344
|
+
*/
|
|
345
|
+
async function resolveByFragment(
|
|
346
|
+
session: string,
|
|
347
|
+
agentDir: string,
|
|
348
|
+
source?: 'main' | 'subagent',
|
|
349
|
+
liveSessionDir?: string,
|
|
350
|
+
): Promise<ResolveResult> {
|
|
351
|
+
const opts = {
|
|
352
|
+
limit: 10,
|
|
353
|
+
...(source ? { source } : {}),
|
|
354
|
+
...(liveSessionDir ? { liveSessionDir } : {}),
|
|
355
|
+
}
|
|
317
356
|
const { matches } = await findSessions(session, agentDir, opts)
|
|
318
357
|
if (matches.length === 0) {
|
|
319
|
-
|
|
320
|
-
|
|
358
|
+
// F1 自检行需要发现层实况:无 options 的 resolveSessionRoots 恒实扫(不读 doctor
|
|
359
|
+
// 缓存,§7B 要点 8),与 find 刚完成的扫描同一数据源(roots.ts 薄包装语义);
|
|
360
|
+
// 信号包同源(liveSessionDir 透传)——findSessions 内部对空串/undefined 已有降级 guard。
|
|
361
|
+
const roots = await resolveSessionRoots({ agentDir, liveSessionDir })
|
|
362
|
+
throw err(formatNoMatch(session, roots))
|
|
321
363
|
}
|
|
322
364
|
if (matches.length === 1) {
|
|
323
365
|
return { kind: 'ok', sessionId: matches[0].sessionId, fileName: matches[0].fileName }
|
|
@@ -361,17 +403,6 @@ const SESSION_ID_PREFIX_LEN = 8
|
|
|
361
403
|
/** 消歧提示的 uuid 片段长度(比短显略长,引导输入更长片段消歧)。 */
|
|
362
404
|
const HINT_ID_PREFIX_LEN = 12
|
|
363
405
|
|
|
364
|
-
/** F1 无匹配 message(含最近 10 + 👉)。 */
|
|
365
|
-
function formatNoMatch(query: string, recent: MatchedSession[]): string {
|
|
366
|
-
const lines: string[] = recent.length
|
|
367
|
-
? recent.map((m, i) => ` ${i + 1}. ${m.sessionId.slice(0, SESSION_ID_PREFIX_LEN)}… ${m.firstMessagePreview ?? ''}`.trimEnd())
|
|
368
|
-
: [' (无历史 session)']
|
|
369
|
-
return (
|
|
370
|
-
`无匹配 session:"${query}"。最近 ${recent.length} 个 session:\n${lines.join('\n')}\n` +
|
|
371
|
-
`👉 用 session_read { action:"find", query:"recent" } 看全量,或换片段重试。`
|
|
372
|
-
)
|
|
373
|
-
}
|
|
374
|
-
|
|
375
406
|
/** F2 多匹配消歧结果(不抛错,返回候选 + 👉)。 */
|
|
376
407
|
function disambiguate(query: string, candidates: MatchedSession[]): ToolResult {
|
|
377
408
|
const lines = candidates.map(
|
|
@@ -406,20 +437,66 @@ async function safeParse(fileName: string): Promise<ParseResult> {
|
|
|
406
437
|
// 文本渲染(content)
|
|
407
438
|
// ---------------------------------------------------------------------------
|
|
408
439
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
440
|
+
/**
|
|
441
|
+
* find 分组渲染的单组数据(u10,design 2026-09-10 §6.7 子决策 2)。
|
|
442
|
+
* main 组恒在 groups 首位(置顶),编号跨组连续。
|
|
443
|
+
*/
|
|
444
|
+
interface FindGroup {
|
|
445
|
+
source: 'main' | 'subagent'
|
|
446
|
+
/** 配额切片后实际展示的候选 */
|
|
447
|
+
shown: MatchedSession[]
|
|
448
|
+
/** 溢出:该组命中数 > shown.length(+1 探测;溢出时精确总数未知,只知更多) */
|
|
449
|
+
overflow: boolean
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* find 输出渲染(u10 分组版,design §5.1 形态 + §6.7 子决策 2/3 精确规格):
|
|
454
|
+
*
|
|
455
|
+
* - 按 source 分组、main 段置顶(subagent 噪声不淹没目标),组头标注各组命中数,
|
|
456
|
+
* 组内编号跨组连续(§5.1 示例:subagent 段从 main 段末尾续号)。
|
|
457
|
+
* - 候选行打印完整 sessionId(废除 8 字符截断——agent 拿到截断 id 无法粘回做精确调用,
|
|
458
|
+
* §3.2 失败模式 D)。SESSION_ID_PREFIX_LEN 常量本体与 result 通路不动(§6.7 范围声明)。
|
|
459
|
+
* - 每条 main 候选附一行可直接复制执行的 outline 调用串(↳,§6.7 子决策 3);
|
|
460
|
+
* subagent 候选不附(噪声不配指针)。
|
|
461
|
+
* - 候选行末段文本:标题优先(u11,name 来自 SessionManager.listAll,§5.1 形态
|
|
462
|
+
* 「… · 福耀玻璃深度研究」),无标题回退首消息预览(现状行为)。
|
|
463
|
+
* - truncated 按**合并总量**(命中总数 vs 实际输出数)计算,由调用方传入,此处只负责标注。
|
|
464
|
+
* - subagent 段超配额折叠为一行展开提示(加 source:"subagent" 查看);main 段溢出
|
|
465
|
+
* 仅在组头标注(规格的折叠提示只针对 subagent)。
|
|
466
|
+
*/
|
|
467
|
+
function formatFindContent(query: string, groups: FindGroup[], truncated: boolean): string {
|
|
468
|
+
const shown = groups.flatMap((g) => g.shown)
|
|
469
|
+
const head = `${shown.length} session(s) matched "${query}"${
|
|
470
|
+
truncated ? ` (truncated, showing first ${shown.length})` : ''
|
|
422
471
|
}`
|
|
472
|
+
const lines: string[] = []
|
|
473
|
+
let index = 0
|
|
474
|
+
for (const g of groups) {
|
|
475
|
+
lines.push('')
|
|
476
|
+
if (g.overflow && g.shown.length === 0) {
|
|
477
|
+
// main 占满配额、subagent 有命中但 0 条展示(§6.7:「subagent 段为 0 条仅显示计数」;
|
|
478
|
+
// 命中总数须全量深读首条 user 才能精确计数,recent 形态下 IO 不可接受,只报有命中)
|
|
479
|
+
lines.push(`${g.source}(有命中未显示——展示配额已被 main 占满):`)
|
|
480
|
+
} else if (g.overflow) {
|
|
481
|
+
lines.push(`${g.source}(>${g.shown.length} 条命中,显示前 ${g.shown.length} 条):`)
|
|
482
|
+
} else {
|
|
483
|
+
lines.push(`${g.source}(${g.shown.length} 条命中):`)
|
|
484
|
+
}
|
|
485
|
+
for (const m of g.shown) {
|
|
486
|
+
index += 1
|
|
487
|
+
const parts = [`${index}. ${m.sessionId}`, formatDate(m.mtime)]
|
|
488
|
+
if (m.cwd) parts.push(shortCwd(m.cwd))
|
|
489
|
+
if (m.name) parts.push(m.name)
|
|
490
|
+
else if (m.firstMessagePreview) parts.push(m.firstMessagePreview)
|
|
491
|
+
lines.push(` ${parts.join(' · ')}`)
|
|
492
|
+
if (g.source === 'main') {
|
|
493
|
+
lines.push(` ↳ session_read { action:"outline", session:"${m.sessionId}" }`)
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (g.overflow && g.source === 'subagent') {
|
|
497
|
+
lines.push(' … 另有 subagent 命中未显示。👉 加 source:"subagent" 查看')
|
|
498
|
+
}
|
|
499
|
+
}
|
|
423
500
|
return `${head}\n${lines.join('\n')}`
|
|
424
501
|
}
|
|
425
502
|
|
|
@@ -552,79 +629,6 @@ function formatFamilyText(f: Family): string {
|
|
|
552
629
|
return lines.join('\n')
|
|
553
630
|
}
|
|
554
631
|
|
|
555
|
-
// ---------------------------------------------------------------------------
|
|
556
|
-
// search 辅助
|
|
557
|
-
// ---------------------------------------------------------------------------
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* 灾难性正则形态探测(MF-5):组内含量词/`|` 且组本身又被量词修饰的 pattern
|
|
561
|
-
*(`(a+)+`、`(a*)*`、`(a|aa)+`、`(a{1,3})*` 等)对长文本指数级回溯,可挂死整个 turn(5.4MB session
|
|
562
|
-
* 全文逐 entry 匹配)。内层字符类含 `{` 以捕获 `{m,n}` 范围量词(MF-1);`(a{1,3})` 单独使用
|
|
563
|
-
*(组后无尾随量词)不命中,仍按正则执行。命中则降级为字面子串匹配(与非法正则同一兜底路径)。
|
|
564
|
-
* 保守拒绝(把合法但形似的 pattern 降级为子串)比挂死可接受。
|
|
565
|
-
*/
|
|
566
|
-
function isCatastrophicPattern(pattern: string): boolean {
|
|
567
|
-
return (
|
|
568
|
-
/\((?:[^()\\]|\\.)*[+*?{](?:[^()\\]|\\.)*\)[+*?{]/.test(pattern) ||
|
|
569
|
-
/\((?:[^()\\]|\\.)*\|(?:[^()\\]|\\.)*\)[+*?{]/.test(pattern)
|
|
570
|
-
)
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
/** 编译检索 pattern:先当正则,非法/灾难性则转义为字面子串(design §3.4 pattern 子串或正则)。 */
|
|
574
|
-
function compilePattern(pattern: string): RegExp {
|
|
575
|
-
if (isCatastrophicPattern(pattern)) {
|
|
576
|
-
return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
|
|
577
|
-
}
|
|
578
|
-
try {
|
|
579
|
-
return new RegExp(pattern, 'i')
|
|
580
|
-
} catch {
|
|
581
|
-
return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
/** 安全序列化:循环引用等异常时返回空串(catch 非空——记默认值)。 */
|
|
586
|
-
function safeStringify(v: unknown): string {
|
|
587
|
-
try {
|
|
588
|
-
return JSON.stringify(v)
|
|
589
|
-
} catch {
|
|
590
|
-
return '' // 循环引用等致 stringify 失败,跳过该块
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
/** search 的可检索文本:含 text/thinking/toolResult 全量(按 scope 过滤由调用方做)。 */
|
|
595
|
-
function searchableText(content: unknown): string {
|
|
596
|
-
if (typeof content === 'string') return content
|
|
597
|
-
if (Array.isArray(content)) {
|
|
598
|
-
const parts: string[] = []
|
|
599
|
-
for (const b of content) {
|
|
600
|
-
if (b && typeof b === 'object') {
|
|
601
|
-
const o = b as Record<string, unknown>
|
|
602
|
-
if (typeof o.text === 'string') parts.push(o.text)
|
|
603
|
-
else if (typeof o.thinking === 'string') parts.push(o.thinking)
|
|
604
|
-
else {
|
|
605
|
-
const serialized = safeStringify(o)
|
|
606
|
-
if (serialized) parts.push(serialized)
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
return parts.join('\n')
|
|
611
|
-
}
|
|
612
|
-
return safeStringify(content)
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/** search 命中片段的前后上下文字符数。 */
|
|
616
|
-
const SNIPPET_CONTEXT_CHARS = 20
|
|
617
|
-
|
|
618
|
-
function snippet(text: string, idx: number, len: number): string {
|
|
619
|
-
const start = Math.max(0, idx - SNIPPET_CONTEXT_CHARS)
|
|
620
|
-
const end = Math.min(text.length, idx + len + SNIPPET_CONTEXT_CHARS)
|
|
621
|
-
return (
|
|
622
|
-
(start > 0 ? '…' : '') +
|
|
623
|
-
text.slice(start, end).replace(/\s+/g, ' ').trim() +
|
|
624
|
-
(end < text.length ? '…' : '')
|
|
625
|
-
)
|
|
626
|
-
}
|
|
627
|
-
|
|
628
632
|
// ===========================================================================
|
|
629
633
|
// 各 action 实现
|
|
630
634
|
// ===========================================================================
|
|
@@ -632,23 +636,114 @@ function snippet(text: string, idx: number, len: number): string {
|
|
|
632
636
|
/** find action 的默认匹配数上限。 */
|
|
633
637
|
const FIND_DEFAULT_LIMIT = 20
|
|
634
638
|
|
|
635
|
-
/**
|
|
636
|
-
|
|
639
|
+
/**
|
|
640
|
+
* find 零匹配:F1 自检行(u9)。计数取本次实扫(无 options 恒实扫,不读 doctor
|
|
641
|
+
* 缓存——§7B 要点 8 PS-14),完整信号包保证 [live] 根(最高优先级)计数可见。
|
|
642
|
+
*/
|
|
643
|
+
function findNoMatch(query: string, signals: SessionReadSignals): Promise<ToolResult> {
|
|
644
|
+
// F1 自检行需要发现层实况:resolveSessionRoots 与 find 刚完成的扫描同一数据源。
|
|
645
|
+
return resolveSessionRoots(signals).then((roots) => ({
|
|
646
|
+
content: [{ type: 'text', text: formatNoMatch(query, roots) }],
|
|
647
|
+
details: { matches: [], truncated: false },
|
|
648
|
+
}))
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* find:按片段/名称/recent 定位 session(design §3.4 find)。零匹配不抛,返回提示。
|
|
653
|
+
*
|
|
654
|
+
* u10 分组(design 2026-09-10 §6.7 子决策 2 精确规格,纯展示层规则):
|
|
655
|
+
* - `limit` 作用于**分组后的合并列表**:main 段优先占满(上限 limit),剩余配额给
|
|
656
|
+
* subagent 段;main 命中 > limit 时 subagent 段为 0 条仅显示计数 + 展开提示。
|
|
657
|
+
* - `truncated` 按**合并总量**(命中总数 vs 实际输出数)计算:各 source 独立查询以
|
|
658
|
+
* 「配额 +1」探测溢出,`hasMore ⟺ 该组命中数 > 该组展示数`,合取即精确等价于
|
|
659
|
+
* 「命中总数 > 实际输出数」,与单次合并查询的 truncated 语义逐值一致。
|
|
660
|
+
* - 匹配层 findSessions 不动:分组只是展示规则,各 source 独立查询的组内语义仍是
|
|
661
|
+
* mtime 排序 + limit 截断。subagent 溢出时不做精确计数(需对全部命中深读首条 user,
|
|
662
|
+
* recent 形态下命中可达全库量级,IO 不可接受),折叠行只报「有更多 + 展开方式」。
|
|
663
|
+
* - 显式 source 过滤走单组查询(现状语义):显式 source 本身就是折叠提示所指的展开
|
|
664
|
+
* 动作,不再折叠。
|
|
665
|
+
*
|
|
666
|
+
* u11(design 2026-09-10 §6.6):metadataProvider / liveSessionDir 透传匹配层——标题
|
|
667
|
+
* 检索的惰性/窄化/TTL 缓存策略都在发现层与注入包装侧,本函数只负责透传(缺省
|
|
668
|
+
* undefined = 现状行为)。多次 findSessions 调用(分组探测)经注入侧 TTL 缓存去重,
|
|
669
|
+
* 标题 listAll 每 TTL 窗口至多一次/目录。
|
|
670
|
+
*/
|
|
671
|
+
async function doFind(
|
|
672
|
+
params: SessionReadParams,
|
|
673
|
+
signals: SessionReadSignals,
|
|
674
|
+
metadataProvider?: SessionMetadataProvider,
|
|
675
|
+
): Promise<ToolResult> {
|
|
637
676
|
const query = requireStr(params.query, 'query', 'find')
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
677
|
+
const limit = params.limit ?? FIND_DEFAULT_LIMIT
|
|
678
|
+
const cwd = params.cwd
|
|
679
|
+
|
|
680
|
+
// 显式 source:单组,匹配层原语义(mtime 排序 + limit 截断),无分组展示
|
|
681
|
+
if (params.source !== undefined) {
|
|
682
|
+
const { matches, truncated } = await findSessions(query, signals.agentDir, {
|
|
683
|
+
cwd,
|
|
684
|
+
limit,
|
|
685
|
+
source: params.source,
|
|
686
|
+
liveSessionDir: signals.liveSessionDir,
|
|
687
|
+
metadataProvider,
|
|
688
|
+
})
|
|
689
|
+
if (matches.length === 0) return findNoMatch(query, signals)
|
|
645
690
|
return {
|
|
646
|
-
content: [
|
|
647
|
-
|
|
691
|
+
content: [
|
|
692
|
+
{
|
|
693
|
+
type: 'text',
|
|
694
|
+
text: formatFindContent(
|
|
695
|
+
query,
|
|
696
|
+
[{ source: params.source, shown: matches, overflow: false }],
|
|
697
|
+
truncated,
|
|
698
|
+
),
|
|
699
|
+
},
|
|
700
|
+
],
|
|
701
|
+
details: { matches, truncated },
|
|
648
702
|
}
|
|
649
703
|
}
|
|
704
|
+
|
|
705
|
+
// 分组查询:main 段以 limit+1 探测溢出,优先占满配额
|
|
706
|
+
const mainRes = await findSessions(query, signals.agentDir, {
|
|
707
|
+
cwd,
|
|
708
|
+
limit: limit + 1,
|
|
709
|
+
source: 'main',
|
|
710
|
+
liveSessionDir: signals.liveSessionDir,
|
|
711
|
+
metadataProvider,
|
|
712
|
+
})
|
|
713
|
+
const mainHasMore = mainRes.matches.length > limit
|
|
714
|
+
const mainShown = mainHasMore ? mainRes.matches.slice(0, limit) : mainRes.matches
|
|
715
|
+
|
|
716
|
+
// subagent 段:remaining>0 → 配额 remaining+1 探测溢出;remaining=0(main 占满/溢出)
|
|
717
|
+
// → limit:1 仅探测有无命中(折叠计数判据,不做全量深读)
|
|
718
|
+
const remaining = limit - mainShown.length
|
|
719
|
+
const subLimit = remaining > 0 ? remaining + 1 : 1
|
|
720
|
+
const subRes = await findSessions(query, signals.agentDir, {
|
|
721
|
+
cwd,
|
|
722
|
+
limit: subLimit,
|
|
723
|
+
source: 'subagent',
|
|
724
|
+
liveSessionDir: signals.liveSessionDir,
|
|
725
|
+
metadataProvider,
|
|
726
|
+
})
|
|
727
|
+
const subOverflow = remaining > 0 ? subRes.matches.length > remaining : subRes.matches.length > 0
|
|
728
|
+
const subShown = subRes.matches.slice(0, Math.max(remaining, 0))
|
|
729
|
+
|
|
730
|
+
const matches = [...mainShown, ...subShown]
|
|
731
|
+
if (matches.length === 0) return findNoMatch(query, signals)
|
|
732
|
+
const truncated = mainHasMore || subOverflow
|
|
650
733
|
return {
|
|
651
|
-
content: [
|
|
734
|
+
content: [
|
|
735
|
+
{
|
|
736
|
+
type: 'text',
|
|
737
|
+
text: formatFindContent(
|
|
738
|
+
query,
|
|
739
|
+
[
|
|
740
|
+
{ source: 'main', shown: mainShown, overflow: mainHasMore },
|
|
741
|
+
{ source: 'subagent', shown: subShown, overflow: subOverflow },
|
|
742
|
+
],
|
|
743
|
+
truncated,
|
|
744
|
+
),
|
|
745
|
+
},
|
|
746
|
+
],
|
|
652
747
|
details: { matches, truncated },
|
|
653
748
|
}
|
|
654
749
|
}
|
|
@@ -660,8 +755,19 @@ async function doFind(params: SessionReadParams, agentDir: string): Promise<Tool
|
|
|
660
755
|
* recursive=true → 嵌套执行树(buildExecutionTree + formatExecutionTreeText,任意深度
|
|
661
756
|
* subagent↔workflow-call 相互嵌套,IF4)。错误契约同构:multi→disambiguate;构建抛错→catch 转 👉。
|
|
662
757
|
*/
|
|
663
|
-
async function doFamily(
|
|
664
|
-
|
|
758
|
+
async function doFamily(
|
|
759
|
+
params: SessionReadParams,
|
|
760
|
+
agentDir: string,
|
|
761
|
+
liveSessionDir?: string,
|
|
762
|
+
): Promise<ToolResult> {
|
|
763
|
+
const resolved = await resolveSessionId(
|
|
764
|
+
params.session,
|
|
765
|
+
'family',
|
|
766
|
+
agentDir,
|
|
767
|
+
params.source,
|
|
768
|
+
undefined,
|
|
769
|
+
liveSessionDir,
|
|
770
|
+
)
|
|
665
771
|
if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
|
|
666
772
|
|
|
667
773
|
// recursive=true:嵌套执行树(U7/U8)
|
|
@@ -695,8 +801,19 @@ async function doFamily(params: SessionReadParams, agentDir: string): Promise<To
|
|
|
695
801
|
}
|
|
696
802
|
|
|
697
803
|
/** outline:turn 级全貌 TOC(design §3.4 outline,~1500 token;render budget 硬编码 2000)。 */
|
|
698
|
-
async function doOutline(
|
|
699
|
-
|
|
804
|
+
async function doOutline(
|
|
805
|
+
params: SessionReadParams,
|
|
806
|
+
agentDir: string,
|
|
807
|
+
liveSessionDir?: string,
|
|
808
|
+
): Promise<ToolResult> {
|
|
809
|
+
const resolved = await resolveSessionId(
|
|
810
|
+
params.session,
|
|
811
|
+
'outline',
|
|
812
|
+
agentDir,
|
|
813
|
+
params.source,
|
|
814
|
+
undefined,
|
|
815
|
+
liveSessionDir,
|
|
816
|
+
)
|
|
700
817
|
if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
|
|
701
818
|
const { entries, totalBytes, skippedLines } = await safeParse(resolved.fileName)
|
|
702
819
|
const tree = buildTreeView(entries)
|
|
@@ -717,8 +834,19 @@ async function doOutline(params: SessionReadParams, agentDir: string): Promise<T
|
|
|
717
834
|
}
|
|
718
835
|
|
|
719
836
|
/** expand:单 turn 的 entry 列表(design §3.4 expand)。turn 越界抛 F4。 */
|
|
720
|
-
async function doExpand(
|
|
721
|
-
|
|
837
|
+
async function doExpand(
|
|
838
|
+
params: SessionReadParams,
|
|
839
|
+
agentDir: string,
|
|
840
|
+
liveSessionDir?: string,
|
|
841
|
+
): Promise<ToolResult> {
|
|
842
|
+
const resolved = await resolveSessionId(
|
|
843
|
+
params.session,
|
|
844
|
+
'expand',
|
|
845
|
+
agentDir,
|
|
846
|
+
params.source,
|
|
847
|
+
undefined,
|
|
848
|
+
liveSessionDir,
|
|
849
|
+
)
|
|
722
850
|
if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
|
|
723
851
|
const turnIdx = parseTurnIndex(requireStr(params.turn, 'turn', 'expand'))
|
|
724
852
|
const { entries } = await safeParse(resolved.fileName)
|
|
@@ -739,8 +867,19 @@ async function doExpand(params: SessionReadParams, agentDir: string): Promise<To
|
|
|
739
867
|
}
|
|
740
868
|
|
|
741
869
|
/** detail:turns 范围的完整文本(design §3.4 detail)。默认省略 toolResult/thinking。 */
|
|
742
|
-
async function doDetail(
|
|
743
|
-
|
|
870
|
+
async function doDetail(
|
|
871
|
+
params: SessionReadParams,
|
|
872
|
+
agentDir: string,
|
|
873
|
+
liveSessionDir?: string,
|
|
874
|
+
): Promise<ToolResult> {
|
|
875
|
+
const resolved = await resolveSessionId(
|
|
876
|
+
params.session,
|
|
877
|
+
'detail',
|
|
878
|
+
agentDir,
|
|
879
|
+
params.source,
|
|
880
|
+
undefined,
|
|
881
|
+
liveSessionDir,
|
|
882
|
+
)
|
|
744
883
|
if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
|
|
745
884
|
const range = parseTurnsRange(requireStr(params.turns, 'turns', 'detail'))
|
|
746
885
|
const { entries } = await safeParse(resolved.fileName)
|
|
@@ -763,74 +902,43 @@ async function doDetail(params: SessionReadParams, agentDir: string): Promise<To
|
|
|
763
902
|
}
|
|
764
903
|
}
|
|
765
904
|
|
|
766
|
-
/** search
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
role: string
|
|
774
|
-
matchSnippet: string
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
/** search 扫描阶段:遍历 turns 收集命中;每 turn 前检查 abort(MF-5 尽早退出)。 */
|
|
778
|
-
function collectSearchHits(
|
|
779
|
-
turns: Turn[],
|
|
780
|
-
regex: RegExp,
|
|
781
|
-
scope: NonNullable<SessionReadParams['scope']>,
|
|
782
|
-
signal: AbortSignal | undefined,
|
|
783
|
-
): SearchHit[] {
|
|
784
|
-
const hits: SearchHit[] = []
|
|
785
|
-
for (const t of turns) {
|
|
786
|
-
// MF-5:Esc/abort 后 pi 已丢弃本 turn 结果,尽早退出避免继续扫描长 session
|
|
787
|
-
if (signal?.aborted) {
|
|
788
|
-
throw err('搜索已中断(信号 aborted)。👉 重试或换更精确的 pattern。')
|
|
789
|
-
}
|
|
790
|
-
for (let i = 0; i < t.entries.length; i++) {
|
|
791
|
-
const msg = t.entries[i].message
|
|
792
|
-
if (msg === undefined) continue
|
|
793
|
-
if (scope !== 'all' && msg.role !== scope) continue
|
|
794
|
-
const text = searchableText(msg.content)
|
|
795
|
-
const m = regex.exec(text)
|
|
796
|
-
if (m !== null) {
|
|
797
|
-
hits.push({
|
|
798
|
-
turnIndex: t.index,
|
|
799
|
-
entryIndex: i,
|
|
800
|
-
role: msg.role,
|
|
801
|
-
matchSnippet: snippet(text, m.index, m[0].length),
|
|
802
|
-
})
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
return hits
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
/** search 收口阶段:结果文本组装(header 标注降级/scope/截断 + 逐行命中列表)。 */
|
|
810
|
-
function formatSearchText(
|
|
811
|
-
pattern: string,
|
|
812
|
-
degraded: boolean,
|
|
813
|
-
scope: NonNullable<SessionReadParams['scope']>,
|
|
814
|
-
sliced: SearchHit[],
|
|
815
|
-
truncated: boolean,
|
|
816
|
-
): string {
|
|
817
|
-
const lines = sliced.map(
|
|
818
|
-
(h) => ` T${pad(h.turnIndex)} #${h.entryIndex} ${h.role}: ${h.matchSnippet}`,
|
|
819
|
-
)
|
|
820
|
-
return `${sliced.length} hit(s) for /${pattern}/${degraded ? '(已降级为字面子串匹配)' : ''}${
|
|
821
|
-
scope !== 'all' ? ' scope=' + scope : ''
|
|
822
|
-
}${truncated ? ` (truncated, showing first ${sliced.length})` : ''}\n${lines.join('\n')}`
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
/** search:session 内全文检索(design §3.4 search,M3 新实现)。 */
|
|
905
|
+
/** search:全文检索(design §3.4 search,M3 新实现)。
|
|
906
|
+
*
|
|
907
|
+
* u12 两种形态(design 2026-09-10 §2 目标 5 / §8.2 V8):
|
|
908
|
+
* - session 含逗号 → 跨会话模式(searchAcrossSessions):候选集 = find 输出的完整 id
|
|
909
|
+
* 列表,窄化前置 + 字节上限 + 分 session 渲染;
|
|
910
|
+
* - 否则单会话模式(现状零变化):session 经 resolveSessionId 解析后对单个 session 检索。
|
|
911
|
+
*/
|
|
826
912
|
async function doSearch(
|
|
827
913
|
params: SessionReadParams,
|
|
828
|
-
|
|
914
|
+
signals: SessionReadSignals,
|
|
829
915
|
signal?: AbortSignal,
|
|
916
|
+
metadataProvider?: SessionMetadataProvider,
|
|
830
917
|
): Promise<ToolResult> {
|
|
831
|
-
const resolved = await resolveSessionId(params.session, 'search', agentDir, params.source)
|
|
832
|
-
if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
|
|
833
918
|
const pattern = requireStr(params.pattern, 'pattern', 'search')
|
|
919
|
+
const rawSession = params.session
|
|
920
|
+
if (rawSession !== undefined && rawSession.includes(',')) {
|
|
921
|
+
const ids = rawSession
|
|
922
|
+
.split(',')
|
|
923
|
+
.map((s) => stripHash(s.trim()))
|
|
924
|
+
.filter((s) => s.length > 0)
|
|
925
|
+
return searchAcrossSessions(ids, pattern, signals, {
|
|
926
|
+
scope: params.scope,
|
|
927
|
+
limit: params.limit,
|
|
928
|
+
signal,
|
|
929
|
+
metadataProvider,
|
|
930
|
+
})
|
|
931
|
+
}
|
|
932
|
+
const agentDir = signals.agentDir
|
|
933
|
+
const resolved = await resolveSessionId(
|
|
934
|
+
rawSession,
|
|
935
|
+
'search',
|
|
936
|
+
agentDir,
|
|
937
|
+
params.source,
|
|
938
|
+
undefined,
|
|
939
|
+
signals.liveSessionDir,
|
|
940
|
+
)
|
|
941
|
+
if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
|
|
834
942
|
const scope = params.scope ?? 'all'
|
|
835
943
|
const limit = params.limit ?? SEARCH_DEFAULT_LIMIT
|
|
836
944
|
const { entries } = await safeParse(resolved.fileName)
|
|
@@ -850,9 +958,20 @@ async function doSearch(
|
|
|
850
958
|
const EXPORT_SEPARATOR_LEN = 40
|
|
851
959
|
|
|
852
960
|
/** export:物化摘要到 <agentDir>/tmp/session-view-<id>.md(design §3.4 export,D-8)。 */
|
|
853
|
-
async function doExport(
|
|
961
|
+
async function doExport(
|
|
962
|
+
params: SessionReadParams,
|
|
963
|
+
agentDir: string,
|
|
964
|
+
liveSessionDir?: string,
|
|
965
|
+
): Promise<ToolResult> {
|
|
854
966
|
const format = params.format ?? 'outline'
|
|
855
|
-
const resolved = await resolveSessionId(
|
|
967
|
+
const resolved = await resolveSessionId(
|
|
968
|
+
params.session,
|
|
969
|
+
'export',
|
|
970
|
+
agentDir,
|
|
971
|
+
params.source,
|
|
972
|
+
undefined,
|
|
973
|
+
liveSessionDir,
|
|
974
|
+
)
|
|
856
975
|
if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
|
|
857
976
|
|
|
858
977
|
let text: string
|
|
@@ -918,25 +1037,9 @@ async function doExport(params: SessionReadParams, agentDir: string): Promise<To
|
|
|
918
1037
|
// extract action(v2 O4:跨 turn 按类型提取素材)
|
|
919
1038
|
// ===========================================================================
|
|
920
1039
|
//
|
|
921
|
-
// design §3.3 D3 的 5 个预设 + F7/F8/F9
|
|
922
|
-
|
|
923
|
-
// segmentTurns
|
|
924
|
-
|
|
925
|
-
/** extract 的 5 个合法 what(design §3.3 D3)。 */
|
|
926
|
-
type ExtractWhat = 'user-messages' | 'commands' | 'files' | 'commits' | 'tool-results'
|
|
927
|
-
|
|
928
|
-
/** extract 结果预算(design §3.3 F9):8000 字节 ≈ 2000 token。 */
|
|
929
|
-
const EXTRACT_BUDGET_BYTES = 8000
|
|
930
|
-
|
|
931
|
-
/** 含 path 参数的文件类工具(design §3.3 D3 files scope)。 */
|
|
932
|
-
const FILE_TOOLS = new Set(['read', 'edit', 'write', 'head'])
|
|
933
|
-
|
|
934
|
-
/** git 命令关键词(commits 预设判定 bash 结果是否来自 git 命令)。 */
|
|
935
|
-
const GIT_CMD_RE = /\bgit\s+(log|show|commit|push|merge|cherry-pick|revert|reset|rebase|diff)\b/
|
|
936
|
-
/** git short hash(commits 预设,保守限定 7-8 位避免 uuid 全量误报)。 */
|
|
937
|
-
const SHORT_HASH_RE = /\b[0-9a-f]{7,8}\b/g
|
|
938
|
-
/** commit 上下文消歧关键词(commits 次路径:hash 附近出现才纳入)。 */
|
|
939
|
-
const COMMIT_CTX_RE = /feat:|fix:|refactor:|chore:|docs:|\b(commit|commits|merged|pushed|merge)\b/i
|
|
1040
|
+
// design §3.3 D3 的 5 个预设 + F7/F8/F9 错误规格。预设管线与预算渲染在 extract.ts
|
|
1041
|
+
//(max-lines 拆分轮机械提取);本段保留 F7 what 校验与 doExtract 编排(定位依赖
|
|
1042
|
+
// tool-handler 私有的 resolveSessionId/safeParse/segmentTurns)。纯提取,不调 LLM。
|
|
940
1043
|
|
|
941
1044
|
/** what 类型守卫(直接比较,避开不安全断言;schema 已校验,此处防御 + 可单测绕过)。 */
|
|
942
1045
|
function isExtractWhat(v: unknown): v is ExtractWhat {
|
|
@@ -949,407 +1052,25 @@ function isExtractWhat(v: unknown): v is ExtractWhat {
|
|
|
949
1052
|
)
|
|
950
1053
|
}
|
|
951
1054
|
|
|
952
|
-
/**
|
|
953
|
-
* 从 message.content 提取纯 text(string 直取;数组拼接 text 块)。
|
|
954
|
-
*
|
|
955
|
-
* 与 render.ts 内部 extractText 同语义,但那未导出;extract 仅需纯 text
|
|
956
|
-
*(user-messages / tool-results 的正文),不要 thinking/toolCall 占位,本地实现。
|
|
957
|
-
* content 是 unknown 做类型守卫。
|
|
958
|
-
*/
|
|
959
|
-
function extractContentText(content: unknown): string {
|
|
960
|
-
if (typeof content === 'string') return content
|
|
961
|
-
if (Array.isArray(content)) {
|
|
962
|
-
const parts: string[] = []
|
|
963
|
-
for (const b of content) {
|
|
964
|
-
if (b !== null && typeof b === 'object') {
|
|
965
|
-
const o = b as Record<string, unknown>
|
|
966
|
-
if (o.type === 'text' && typeof o.text === 'string') parts.push(o.text)
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
return parts.join('\n')
|
|
970
|
-
}
|
|
971
|
-
return ''
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
/** 截断到 max 字符,超出加省略号(防爆;tool-results 正文用)。 */
|
|
975
|
-
function truncateText(s: string, max: number): string {
|
|
976
|
-
return s.length <= max ? s : s.slice(0, max) + '…'
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
/** turnsLabel 展示的 turn 数上限(超出折叠为 +N)。 */
|
|
980
|
-
const TURNS_LABEL_HEAD_COUNT = 5
|
|
981
|
-
|
|
982
|
-
/** turns 数组紧凑标签(前 5 个 + +N,避免一行过长撑爆预算)。 */
|
|
983
|
-
function turnsLabel(turns: number[]): string {
|
|
984
|
-
const head = turns.slice(0, TURNS_LABEL_HEAD_COUNT).map((n) => `T${pad(n)}`)
|
|
985
|
-
const suffix = turns.length > TURNS_LABEL_HEAD_COUNT ? `+${turns.length - TURNS_LABEL_HEAD_COUNT}` : ''
|
|
986
|
-
return head.join(',') + suffix
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
/**
|
|
990
|
-
* 计算工具分布(按出现次数降序),用于 F8 提示 + details.toolDistribution。
|
|
991
|
-
* 遍历 assistant entry 的 toolCall,复用 extractToolCalls。
|
|
992
|
-
*/
|
|
993
|
-
function computeToolDistribution(
|
|
994
|
-
turns: Turn[],
|
|
995
|
-
): Array<{ name: string; count: number }> {
|
|
996
|
-
const counts = new Map<string, number>()
|
|
997
|
-
for (const t of turns) {
|
|
998
|
-
for (const e of t.entries) {
|
|
999
|
-
if (e.message?.role !== 'assistant') continue
|
|
1000
|
-
for (const tc of extractToolCalls(e)) {
|
|
1001
|
-
counts.set(tc.name, (counts.get(tc.name) ?? 0) + 1)
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
return Array.from(counts.entries())
|
|
1006
|
-
.map(([name, count]) => ({ name, count }))
|
|
1007
|
-
.sort((a, b) => b.count - a.count)
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
/** F8 提示展示的工具分布条数上限。 */
|
|
1011
|
-
const TOOL_DISTRIBUTION_LIMIT = 10
|
|
1012
|
-
|
|
1013
|
-
/** F8:commands/tool-results 的 tool 过滤零匹配 → 返回工具分布 + 👉(不抛错,design §3.3 F8)。 */
|
|
1014
|
-
function f8ToolNoMatch(what: ExtractWhat, tool: string, turns: Turn[]): ToolResult {
|
|
1015
|
-
const dist = computeToolDistribution(turns).slice(0, TOOL_DISTRIBUTION_LIMIT)
|
|
1016
|
-
const distStr = dist.map((d) => `${d.name}×${d.count}`).join(', ')
|
|
1017
|
-
const text = `what=${what} tool="${tool}" 无匹配。该 session 工具:${distStr}。👉 用存在的工具名重试。`
|
|
1018
|
-
return { content: [{ type: 'text', text }], details: { what, tool, toolDistribution: dist } }
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
/**
|
|
1022
|
-
* 通用预算渲染:逐项累加字节,超 EXTRACT_BUDGET_BYTES 截断(design §3.3 F9)。
|
|
1023
|
-
*
|
|
1024
|
-
* details.items 放实际展示的子集(截断后),count 放全集长度,测试可断言 shown/count/truncated。
|
|
1025
|
-
* emptyHint 仅 items 为空时用(files/commits 无匹配不报错,返空 + 提示)。
|
|
1026
|
-
*
|
|
1027
|
-
* 预算控制:按 item 累计字节达预算即截断。**首项超大也内部截断**(对单行 slice 到剩余字节预算,
|
|
1028
|
-
* 字节→字符 ×3 近似防 UTF8 多字节被切半),保证 body 不超预算——而非放行首项致 body 远超预算。
|
|
1029
|
-
* 导出供 tool-handler.test 单测 F9 截断逻辑(首项截断 + 文案含 turn 范围 + 实际 token)。
|
|
1030
|
-
*
|
|
1031
|
-
* getTurns:从 item 提取 turn 列表(files 是 turns 数组,其余单值包数组),供 F9 文案报 turn 范围。
|
|
1032
|
-
*/
|
|
1033
|
-
|
|
1034
|
-
/** UTF8 单字符最大字节数(预算字节→字符的保守换算基数,防多字节字符被切半)。 */
|
|
1035
|
-
const UTF8_MAX_BYTES_PER_CHAR = 3
|
|
1036
|
-
/** token 估算换算基数(bytes/4 口径,与 render 层 chars/4 同近似)。 */
|
|
1037
|
-
const CHARS_PER_TOKEN = 4
|
|
1038
|
-
|
|
1039
|
-
export function renderExtractItems<I>(
|
|
1040
|
-
what: ExtractWhat,
|
|
1041
|
-
items: I[],
|
|
1042
|
-
renderLine: (item: I) => string,
|
|
1043
|
-
getTurns: (item: I) => number[],
|
|
1044
|
-
emptyHint?: string,
|
|
1045
|
-
): ToolResult {
|
|
1046
|
-
if (items.length === 0) {
|
|
1047
|
-
const text = emptyHint ?? `what=${what} 无匹配。`
|
|
1048
|
-
return {
|
|
1049
|
-
content: [{ type: 'text', text }],
|
|
1050
|
-
details: { what, count: 0, shown: 0, truncated: false, items: [] },
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
const shown: I[] = []
|
|
1054
|
-
const shownLines: string[] = []
|
|
1055
|
-
let bytes = 0
|
|
1056
|
-
let cut = false
|
|
1057
|
-
for (const item of items) {
|
|
1058
|
-
const line = renderLine(item)
|
|
1059
|
-
const lineBytes = Buffer.byteLength(line, 'utf8') + 1 // +\n
|
|
1060
|
-
if (bytes + lineBytes > EXTRACT_BUDGET_BYTES) {
|
|
1061
|
-
// 超预算:对当前 line 内部截断到剩余预算(首项超大也截断,但保留截断后的内容)
|
|
1062
|
-
const remainingBytes = EXTRACT_BUDGET_BYTES - bytes
|
|
1063
|
-
const charBudget = Math.floor(remainingBytes / UTF8_MAX_BYTES_PER_CHAR) // 字节→字符 ×3 近似防 UTF8 切半
|
|
1064
|
-
if (charBudget > 0) {
|
|
1065
|
-
const sliced = line.slice(0, charBudget) + '…'
|
|
1066
|
-
shown.push(item)
|
|
1067
|
-
shownLines.push(sliced)
|
|
1068
|
-
}
|
|
1069
|
-
cut = true
|
|
1070
|
-
break
|
|
1071
|
-
}
|
|
1072
|
-
shown.push(item)
|
|
1073
|
-
shownLines.push(line)
|
|
1074
|
-
bytes += lineBytes
|
|
1075
|
-
}
|
|
1076
|
-
const body = shownLines.join('\n')
|
|
1077
|
-
if (!cut) {
|
|
1078
|
-
return {
|
|
1079
|
-
content: [{ type: 'text', text: body }],
|
|
1080
|
-
details: {
|
|
1081
|
-
what,
|
|
1082
|
-
count: items.length,
|
|
1083
|
-
shown: shown.length,
|
|
1084
|
-
truncated: false,
|
|
1085
|
-
items: shown,
|
|
1086
|
-
},
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
// F9:超预算截断。tokens 反映 body 实际体积(非固定 2000);文案报 shown/count + turn 范围 + 实际 token
|
|
1090
|
-
const shownTurns = shown.flatMap(getTurns)
|
|
1091
|
-
const turnRange =
|
|
1092
|
-
shownTurns.length > 0
|
|
1093
|
-
? `(T${pad(Math.min(...shownTurns))}-T${pad(Math.max(...shownTurns))})`
|
|
1094
|
-
: ''
|
|
1095
|
-
const actualTokens = Math.round(Buffer.byteLength(body, 'utf8') / CHARS_PER_TOKEN)
|
|
1096
|
-
const text =
|
|
1097
|
-
body +
|
|
1098
|
-
`\n[what=${what} 已显示 ${shown.length}/${items.length} 项${turnRange},约 ${actualTokens} token 达预算上限。👉 用较小 turns 范围(如 T000-T005)缩小,或换 what 重试。]`
|
|
1099
|
-
return {
|
|
1100
|
-
content: [{ type: 'text', text }],
|
|
1101
|
-
details: {
|
|
1102
|
-
what,
|
|
1103
|
-
count: items.length,
|
|
1104
|
-
shown: shown.length,
|
|
1105
|
-
truncated: true,
|
|
1106
|
-
items: shown,
|
|
1107
|
-
},
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
/** 预设 1:user-messages——收集 role==='user' 的全文(按 turn 排列,design §3.3 D3)。 */
|
|
1112
|
-
function extractUserMessages(turns: Turn[]): ToolResult {
|
|
1113
|
-
const items: Array<{ turn: number; text: string }> = []
|
|
1114
|
-
for (const t of turns) {
|
|
1115
|
-
for (const e of t.entries) {
|
|
1116
|
-
if (e.message?.role !== 'user') continue
|
|
1117
|
-
items.push({ turn: t.index, text: extractContentText(e.message.content) })
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
return renderExtractItems(
|
|
1121
|
-
'user-messages',
|
|
1122
|
-
items,
|
|
1123
|
-
(it) => `T${pad(it.turn)}: ${it.text}`,
|
|
1124
|
-
(it) => [it.turn],
|
|
1125
|
-
)
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
/**
|
|
1129
|
-
* 预设 2:commands——assistant 的 toolCall,带 name + D1 摘要(design §3.3 D3)。
|
|
1130
|
-
* 可选 tool 过滤;过滤后零匹配 → F8(工具分布 + 👉,不抛错)。
|
|
1131
|
-
* index = entry 在 turn.entries 内的位置,与 expand 的 [N] 对齐便于定位。
|
|
1132
|
-
*/
|
|
1133
|
-
function extractCommands(turns: Turn[], tool: string | undefined): ToolResult {
|
|
1134
|
-
const items: Array<{ turn: number; index: number; name: string; summary: string }> = []
|
|
1135
|
-
for (const t of turns) {
|
|
1136
|
-
for (let ei = 0; ei < t.entries.length; ei++) {
|
|
1137
|
-
const e = t.entries[ei]
|
|
1138
|
-
if (e.message?.role !== 'assistant') continue
|
|
1139
|
-
for (const tc of extractToolCalls(e)) {
|
|
1140
|
-
if (tool !== undefined && tc.name !== tool) continue
|
|
1141
|
-
items.push({
|
|
1142
|
-
turn: t.index,
|
|
1143
|
-
index: ei,
|
|
1144
|
-
name: tc.name,
|
|
1145
|
-
summary: formatToolCallSummary(tc),
|
|
1146
|
-
})
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
if (tool !== undefined && items.length === 0) return f8ToolNoMatch('commands', tool, turns)
|
|
1151
|
-
return renderExtractItems(
|
|
1152
|
-
'commands',
|
|
1153
|
-
items,
|
|
1154
|
-
(it) => `T${pad(it.turn)} #${it.index} ${it.summary}`,
|
|
1155
|
-
(it) => [it.turn],
|
|
1156
|
-
)
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
/**
|
|
1160
|
-
* 预设 3:files——read/edit/write/head 的 path 去重(design §3.3 D3)。
|
|
1161
|
-
* 同 path 多次操作合并,op 聚合成 `read+edit` 形式,turns 记录出现过的轮次。
|
|
1162
|
-
* todo/subagent/cw 无 path 不纳入。无匹配不报错(返空 + 提示)。
|
|
1163
|
-
*/
|
|
1164
|
-
function extractFiles(turns: Turn[]): ToolResult {
|
|
1165
|
-
const map = new Map<string, { ops: Set<string>; turns: Set<number> }>()
|
|
1166
|
-
for (const t of turns) {
|
|
1167
|
-
for (const e of t.entries) {
|
|
1168
|
-
if (e.message?.role !== 'assistant') continue
|
|
1169
|
-
for (const tc of extractToolCalls(e)) {
|
|
1170
|
-
if (!FILE_TOOLS.has(tc.name)) continue
|
|
1171
|
-
const p = tc.arguments.path
|
|
1172
|
-
if (typeof p !== 'string') continue
|
|
1173
|
-
let rec = map.get(p)
|
|
1174
|
-
if (rec === undefined) {
|
|
1175
|
-
rec = { ops: new Set(), turns: new Set() }
|
|
1176
|
-
map.set(p, rec)
|
|
1177
|
-
}
|
|
1178
|
-
rec.ops.add(tc.name)
|
|
1179
|
-
rec.turns.add(t.index)
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
const items = Array.from(map.entries()).map(([path, rec]) => ({
|
|
1184
|
-
path,
|
|
1185
|
-
basename: basename(path),
|
|
1186
|
-
op: Array.from(rec.ops).sort().join('+'),
|
|
1187
|
-
turns: Array.from(rec.turns).sort((a, b) => a - b),
|
|
1188
|
-
}))
|
|
1189
|
-
return renderExtractItems(
|
|
1190
|
-
'files',
|
|
1191
|
-
items,
|
|
1192
|
-
(it) => `${it.op}: ${it.path} (${turnsLabel(it.turns)})`,
|
|
1193
|
-
(it) => it.turns,
|
|
1194
|
-
`what=files 无匹配(该 session 无 read/edit/write/head 文件操作)。`,
|
|
1195
|
-
)
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
/**
|
|
1199
|
-
* 预设 4:commits——git 命令 toolResult 的 hash(design §3.3 D3 + D6 误匹配处理)。
|
|
1200
|
-
*
|
|
1201
|
-
* 保守策略(宁可少召回不要乱报 uuid):
|
|
1202
|
-
* ① 主路径(高置信):只从 bash 且关联 command 含 git (log|show|commit|push|merge|...) 的
|
|
1203
|
-
* toolResult 文本提取 7-8 位 hex;
|
|
1204
|
-
* ② 次路径(中置信):扫所有 toolResult 文本,hash 前后各 30 字符内含
|
|
1205
|
-
* feat:/fix:/commit/merge 等关键词的才纳入;
|
|
1206
|
-
* ③ 去重,git-cmd 置信度优先;不扫 user/assistant 自由文本(uuid/session-id 误报太多)。
|
|
1207
|
-
*
|
|
1208
|
-
* 已知局限:7-8 位 hex 与 uuid v7 片段形似,靠 git 命令上下文过滤;仍可能漏报
|
|
1209
|
-
*(git 操作未被 toolResult 捕获)或误报(git log 输出里的其他 hex)。每条标注来源 turn
|
|
1210
|
-
* + source + context,agent 可快速辨认。完全语义判断需 LLM,本工具零 LLM 依赖。
|
|
1211
|
-
*/
|
|
1212
|
-
/** commits 提取的 hash 前后上下文字符数(次路径关键词判定窗口)。 */
|
|
1213
|
-
const COMMIT_CONTEXT_CHARS = 30
|
|
1214
|
-
|
|
1215
|
-
/** commits 预设的单条提取结果(hash + 来源 turn + 置信来源 + 上下文片段)。 */
|
|
1216
|
-
interface CommitItem {
|
|
1217
|
-
hash: string
|
|
1218
|
-
turn: number
|
|
1219
|
-
source: 'git-cmd' | 'commit-context'
|
|
1220
|
-
context: string
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
/** 建 toolCallId → bash command 映射(用于判定 toolResult 是否来自 git 命令)。 */
|
|
1224
|
-
function collectBashCommands(turns: Turn[]): Map<string, string> {
|
|
1225
|
-
const bashCmds = new Map<string, string>()
|
|
1226
|
-
for (const t of turns) {
|
|
1227
|
-
for (const e of t.entries) {
|
|
1228
|
-
if (e.message?.role !== 'assistant') continue
|
|
1229
|
-
for (const tc of extractToolCalls(e)) {
|
|
1230
|
-
if (tc.name === 'bash') {
|
|
1231
|
-
const cmd = tc.arguments.command
|
|
1232
|
-
if (typeof cmd === 'string') bashCmds.set(tc.id, cmd)
|
|
1233
|
-
}
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
return bashCmds
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
/** 单条 toolResult 文本的 hash 提取:git-cmd 全收(高置信),否则按上下文关键词过滤(次路径)。 */
|
|
1241
|
-
function matchCommitsInResult(
|
|
1242
|
-
text: string,
|
|
1243
|
-
turnIndex: number,
|
|
1244
|
-
isGitBash: boolean,
|
|
1245
|
-
high: CommitItem[],
|
|
1246
|
-
low: CommitItem[],
|
|
1247
|
-
): void {
|
|
1248
|
-
for (const m of text.matchAll(SHORT_HASH_RE)) {
|
|
1249
|
-
const hash = m[0]
|
|
1250
|
-
const idx = m.index ?? 0
|
|
1251
|
-
const ctx = text
|
|
1252
|
-
.slice(Math.max(0, idx - COMMIT_CONTEXT_CHARS), idx + hash.length + COMMIT_CONTEXT_CHARS)
|
|
1253
|
-
.replace(/\s+/g, ' ')
|
|
1254
|
-
.trim()
|
|
1255
|
-
if (isGitBash) {
|
|
1256
|
-
high.push({ hash, turn: turnIndex, source: 'git-cmd', context: ctx })
|
|
1257
|
-
} else if (COMMIT_CTX_RE.test(ctx)) {
|
|
1258
|
-
low.push({ hash, turn: turnIndex, source: 'commit-context', context: ctx })
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
/** 主扫描阶段:遍历 toolResult entry,分类收集高/低置信 commit 候选。 */
|
|
1264
|
-
function collectCommitCandidates(
|
|
1265
|
-
turns: Turn[],
|
|
1266
|
-
bashCmds: Map<string, string>,
|
|
1267
|
-
): { high: CommitItem[]; low: CommitItem[] } {
|
|
1268
|
-
const high: CommitItem[] = []
|
|
1269
|
-
const low: CommitItem[] = []
|
|
1270
|
-
for (const t of turns) {
|
|
1271
|
-
for (const e of t.entries) {
|
|
1272
|
-
if (e.message?.role !== 'toolResult') continue
|
|
1273
|
-
const msg = e.message
|
|
1274
|
-
const text = extractContentText(msg.content)
|
|
1275
|
-
if (text === '') continue
|
|
1276
|
-
const cmd = msg.toolCallId !== undefined ? bashCmds.get(msg.toolCallId) : undefined
|
|
1277
|
-
const isGitBash = msg.toolName === 'bash' && cmd !== undefined && GIT_CMD_RE.test(cmd)
|
|
1278
|
-
matchCommitsInResult(text, t.index, isGitBash, high, low)
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
return { high, low }
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
/** 收口阶段:去重,高置信优先,同 hash 保留首次。 */
|
|
1285
|
-
function dedupeCommits(high: CommitItem[], low: CommitItem[]): CommitItem[] {
|
|
1286
|
-
const seen = new Set<string>()
|
|
1287
|
-
const items: CommitItem[] = []
|
|
1288
|
-
for (const c of high) {
|
|
1289
|
-
if (seen.has(c.hash)) continue
|
|
1290
|
-
seen.add(c.hash)
|
|
1291
|
-
items.push(c)
|
|
1292
|
-
}
|
|
1293
|
-
for (const c of low) {
|
|
1294
|
-
if (seen.has(c.hash)) continue
|
|
1295
|
-
seen.add(c.hash)
|
|
1296
|
-
items.push(c)
|
|
1297
|
-
}
|
|
1298
|
-
return items
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
function extractCommits(turns: Turn[]): ToolResult {
|
|
1302
|
-
// 建 toolCallId → bash command 映射(用于判定 toolResult 是否来自 git 命令)
|
|
1303
|
-
const bashCmds = collectBashCommands(turns)
|
|
1304
|
-
const { high, low } = collectCommitCandidates(turns, bashCmds)
|
|
1305
|
-
const items = dedupeCommits(high, low)
|
|
1306
|
-
|
|
1307
|
-
return renderExtractItems(
|
|
1308
|
-
'commits',
|
|
1309
|
-
items,
|
|
1310
|
-
(it) => `T${pad(it.turn)} ${it.hash} [${it.source}] ${it.context}`,
|
|
1311
|
-
(it) => [it.turn],
|
|
1312
|
-
`what=commits 无匹配(该 session 无 git commit hash,或未在 toolResult 中出现)。`,
|
|
1313
|
-
)
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
/** tool-results 正文截断上限(防爆)。 */
|
|
1317
|
-
const TOOL_RESULT_TEXT_MAX_CHARS = 500
|
|
1318
|
-
|
|
1319
|
-
/**
|
|
1320
|
-
* 预设 5:tool-results——role==='toolResult' 文本(design §3.3 D3)。
|
|
1321
|
-
* text 截断到 500 字防爆;可选 tool 过滤(msg.toolName);过滤零匹配 → F8。
|
|
1322
|
-
*/
|
|
1323
|
-
function extractToolResults(turns: Turn[], tool: string | undefined): ToolResult {
|
|
1324
|
-
const items: Array<{ turn: number; index: number; toolName: string; text: string }> = []
|
|
1325
|
-
for (const t of turns) {
|
|
1326
|
-
for (let ei = 0; ei < t.entries.length; ei++) {
|
|
1327
|
-
const e = t.entries[ei]
|
|
1328
|
-
if (e.message?.role !== 'toolResult') continue
|
|
1329
|
-
const tn = e.message.toolName ?? '?'
|
|
1330
|
-
if (tool !== undefined && tn !== tool) continue
|
|
1331
|
-
const text = truncateText(extractContentText(e.message.content), TOOL_RESULT_TEXT_MAX_CHARS)
|
|
1332
|
-
items.push({ turn: t.index, index: ei, toolName: tn, text })
|
|
1333
|
-
}
|
|
1334
|
-
}
|
|
1335
|
-
if (tool !== undefined && items.length === 0)
|
|
1336
|
-
return f8ToolNoMatch('tool-results', tool, turns)
|
|
1337
|
-
return renderExtractItems(
|
|
1338
|
-
'tool-results',
|
|
1339
|
-
items,
|
|
1340
|
-
(it) => `T${pad(it.turn)} #${it.index} ${it.toolName}: ${it.text}`,
|
|
1341
|
-
(it) => [it.turn],
|
|
1342
|
-
)
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
1055
|
/**
|
|
1346
1056
|
* extract:跨 turn 按类型提取素材(design §3.3 D3 五预设 + F7/F8/F9)。
|
|
1347
1057
|
*
|
|
1348
1058
|
* 流程:resolveSessionId(multi 走 disambiguate)→ safeParse → buildTreeView +
|
|
1349
1059
|
* segmentTurns → 可选 turns 范围限定(复用 parseTurnsRange)→ F7 校验 what → 分发 5 预设。
|
|
1350
1060
|
*/
|
|
1351
|
-
async function doExtract(
|
|
1352
|
-
|
|
1061
|
+
async function doExtract(
|
|
1062
|
+
params: SessionReadParams,
|
|
1063
|
+
agentDir: string,
|
|
1064
|
+
liveSessionDir?: string,
|
|
1065
|
+
): Promise<ToolResult> {
|
|
1066
|
+
const resolved = await resolveSessionId(
|
|
1067
|
+
params.session,
|
|
1068
|
+
'extract',
|
|
1069
|
+
agentDir,
|
|
1070
|
+
params.source,
|
|
1071
|
+
undefined,
|
|
1072
|
+
liveSessionDir,
|
|
1073
|
+
)
|
|
1353
1074
|
if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
|
|
1354
1075
|
const { entries } = await safeParse(resolved.fileName)
|
|
1355
1076
|
// extract 遍历全量 entry(含旁支/压缩历史),与 outline/expand/detail 的 leaf 视图不同:
|
|
@@ -1440,8 +1161,19 @@ interface SkippedRun {
|
|
|
1440
1161
|
* step 的 call sessionId/sessionFile 是 LLM 跳 outline/detail 的入口(m0 resolveSessionId
|
|
1441
1162
|
* 三形态复用:sessionId/绝对路径/sa-id 均可深读,TC-wf-step-sessionfile-link)。
|
|
1442
1163
|
*/
|
|
1443
|
-
async function doWorkflow(
|
|
1444
|
-
|
|
1164
|
+
async function doWorkflow(
|
|
1165
|
+
params: SessionReadParams,
|
|
1166
|
+
agentDir: string,
|
|
1167
|
+
liveSessionDir?: string,
|
|
1168
|
+
): Promise<ToolResult> {
|
|
1169
|
+
const resolved = await resolveSessionId(
|
|
1170
|
+
params.session,
|
|
1171
|
+
'workflow',
|
|
1172
|
+
agentDir,
|
|
1173
|
+
params.source,
|
|
1174
|
+
undefined,
|
|
1175
|
+
liveSessionDir,
|
|
1176
|
+
)
|
|
1445
1177
|
if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
|
|
1446
1178
|
|
|
1447
1179
|
let workflows: WorkflowRef[]
|
|
@@ -1529,48 +1261,147 @@ async function doWorkflow(params: SessionReadParams, agentDir: string): Promise<
|
|
|
1529
1261
|
return { content: [{ type: 'text', text }], details }
|
|
1530
1262
|
}
|
|
1531
1263
|
|
|
1264
|
+
// ---------------------------------------------------------------------------
|
|
1265
|
+
// u11 标题元数据 TTL 缓存(design 2026-09-10 §6.6 调用策略 ③)
|
|
1266
|
+
// ---------------------------------------------------------------------------
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* 标题缓存条目(SessionMetadataEntry[] keyed by 目录字面路径)。与 doctor 的
|
|
1270
|
+
* doctorScanCache **独立实例**——两者语义不同:doctor 缓存根扫描统计(文件数/耗时),
|
|
1271
|
+
* 本缓存标题元数据(session_info name / firstMessage,低频变更)。
|
|
1272
|
+
*/
|
|
1273
|
+
interface MetadataCacheEntry {
|
|
1274
|
+
entries: SessionMetadataEntry[]
|
|
1275
|
+
/** 写入时刻(Date.now()),TTL 判定用 */
|
|
1276
|
+
cachedAt: number
|
|
1277
|
+
/** 目录 mtime(ms);不存在为 null(存在性翻转即失效) */
|
|
1278
|
+
dirMtimeMs: number | null
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const metadataCache = new Map<string, MetadataCacheEntry>()
|
|
1282
|
+
// §7.5 豁免(development-guide.md「纯性能缓存豁免」):TTL 纯性能缓存,jiti 双路径加载
|
|
1283
|
+
// 分裂成两份仅多一次 miss 重扫,无正确性影响,不升级 globalThis 单例。
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* 标题缓存 TTL(秒级,§6.6 策略 ③)。量级与 doctor 根扫描缓存同档(DOCTOR_CACHE_TTL_MS),
|
|
1287
|
+
* 待 §11.3a 实测校准;mtime 是主失效通道,TTL 兜「目录内文件追加不改目录 mtime」的陈旧面
|
|
1288
|
+
*(标题恰好随首条消息落盘,同窗口内新增标题最多延迟一个 TTL 可见,可接受)。
|
|
1289
|
+
*/
|
|
1290
|
+
export const METADATA_CACHE_TTL_MS = DOCTOR_CACHE_TTL_MS
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* 把注入的 metadataProvider 包上 TTL 缓存(get 失效判定 + set 快照)。
|
|
1294
|
+
*
|
|
1295
|
+
* 只缓存成功结果——provider 抛错原样上抛,由发现层单目录 try/catch 记空继续(guard),
|
|
1296
|
+
* 且瞬态失败不污染缓存(下个查询即重试)。find 的多次 findSessions 调用(u10 分组探测
|
|
1297
|
+
* main/subagent 两路)与连续 keyword 查询都经此处去重,listAll 每 TTL 窗口至多一次/目录。
|
|
1298
|
+
*/
|
|
1299
|
+
export function withMetadataCache(provider: SessionMetadataProvider): SessionMetadataProvider {
|
|
1300
|
+
return async (dir) => {
|
|
1301
|
+
const hit = metadataCache.get(dir)
|
|
1302
|
+
if (hit !== undefined) {
|
|
1303
|
+
const expired = Date.now() - hit.cachedAt >= METADATA_CACHE_TTL_MS
|
|
1304
|
+
const mtime = await statDirMtimeOrNull(dir)
|
|
1305
|
+
if (!expired && mtime === hit.dirMtimeMs) return hit.entries
|
|
1306
|
+
metadataCache.delete(dir)
|
|
1307
|
+
}
|
|
1308
|
+
const entries = await provider(dir)
|
|
1309
|
+
metadataCache.set(dir, {
|
|
1310
|
+
entries,
|
|
1311
|
+
cachedAt: Date.now(),
|
|
1312
|
+
dirMtimeMs: await statDirMtimeOrNull(dir),
|
|
1313
|
+
})
|
|
1314
|
+
return entries
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* result action 的注入依赖(构造期绑定本文件私有 helper,运行时零查找开销)。
|
|
1320
|
+
* resolveSessionId 仅作类型/缺省绑定——入口分发时被 per-call 包装覆盖(闭包捕获
|
|
1321
|
+
* 信号包 liveSessionDir,见 handleSessionRead result case)。
|
|
1322
|
+
*/
|
|
1323
|
+
const RESULT_ACTION_DEPS: ResultActionDeps = {
|
|
1324
|
+
err,
|
|
1325
|
+
stripHash,
|
|
1326
|
+
requireStr,
|
|
1327
|
+
resolveSessionId,
|
|
1328
|
+
disambiguate,
|
|
1329
|
+
safeParse,
|
|
1330
|
+
sessionIdPrefixLen: SESSION_ID_PREFIX_LEN,
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
export { extractFinalAssistantText }
|
|
1334
|
+
|
|
1532
1335
|
// ===========================================================================
|
|
1533
1336
|
// 入口:按 action 分发
|
|
1534
1337
|
// ===========================================================================
|
|
1535
1338
|
|
|
1536
1339
|
/**
|
|
1537
|
-
* session_read 工具的纯逻辑 handler
|
|
1340
|
+
* session_read 工具的纯逻辑 handler(信号包注入,零 pi 依赖,可单测)。
|
|
1538
1341
|
*
|
|
1539
|
-
* 按 params.action 分发到
|
|
1342
|
+
* 按 params.action 分发到 do* 家族(11 个 action,与本文件各 action 实现一一对应)。
|
|
1540
1343
|
* F1(resolve)/F4/F5/F6 抛 Error(含 👉);F2 多匹配与 find 零匹配返回结果不抛。
|
|
1541
1344
|
*
|
|
1345
|
+
* @param signals 发现层信号包(design §7B:index.ts 采集 { agentDir, liveSessionDir? },
|
|
1346
|
+
* 采集端全可选链可降级)。兼容接受裸 agentDir string(存量单测与外部深 import 的旧签名
|
|
1347
|
+
* 形态,入口归一化为只含 agentDir 的信号包,行为与旧签名逐字节一致;工具运行路径恒传
|
|
1348
|
+
* 完整信号包)。u9 起 find/F1 路径消费根列表(F1 自检行恒走无 options 实扫,
|
|
1349
|
+
* 不读 doctor 缓存,§7B 要点 8)。
|
|
1542
1350
|
* @param signal 可选 AbortSignal(MF-5):仅 search 消费(长扫描可中断);其余 action 有界,不接。
|
|
1351
|
+
* @param metadataProvider 可选标题元数据注入(u11,design 2026-09-10 §6.6):index.ts 构造
|
|
1352
|
+
* `(dir) => SessionManager.listAll(dir)`,此处包 TTL 缓存后透传 find。缺省 = undefined =
|
|
1353
|
+
* 现状行为(标题检索不可用,首条 user 匹配不受影响);provider 抛错由发现层单目录
|
|
1354
|
+
* try/catch 降级,不外抛。
|
|
1543
1355
|
*/
|
|
1544
1356
|
export async function handleSessionRead(
|
|
1545
1357
|
params: SessionReadParams,
|
|
1546
|
-
|
|
1358
|
+
signals: SessionReadSignals | string,
|
|
1547
1359
|
signal?: AbortSignal,
|
|
1360
|
+
metadataProvider?: SessionMetadataProvider,
|
|
1548
1361
|
): Promise<ToolResult> {
|
|
1362
|
+
// 裸 string(存量单测/外部深 import 旧签名,D-8)归一化为信号包;doctor 需要完整
|
|
1363
|
+
// 信号包(liveSessionDir/env/bundleUrl),缺省字段按各自降级语义处理。
|
|
1364
|
+
const norm: SessionReadSignals = typeof signals === 'string' ? { agentDir: signals } : signals
|
|
1365
|
+
const agentDir = norm.agentDir
|
|
1366
|
+
// u11:TTL 缓存包装在注入边界(策略 ③,独立于 doctorScanCache 的实例);仅 find 消费。
|
|
1367
|
+
const cachedProvider =
|
|
1368
|
+
metadataProvider === undefined ? undefined : withMetadataCache(metadataProvider)
|
|
1549
1369
|
switch (params.action) {
|
|
1550
1370
|
case 'find':
|
|
1551
|
-
return doFind(params,
|
|
1371
|
+
return doFind(params, norm, cachedProvider)
|
|
1552
1372
|
case 'family':
|
|
1553
|
-
return doFamily(params, agentDir)
|
|
1373
|
+
return doFamily(params, agentDir, norm.liveSessionDir)
|
|
1554
1374
|
case 'outline':
|
|
1555
|
-
return doOutline(params, agentDir)
|
|
1375
|
+
return doOutline(params, agentDir, norm.liveSessionDir)
|
|
1556
1376
|
case 'expand':
|
|
1557
|
-
return doExpand(params, agentDir)
|
|
1377
|
+
return doExpand(params, agentDir, norm.liveSessionDir)
|
|
1558
1378
|
case 'detail':
|
|
1559
|
-
return doDetail(params, agentDir)
|
|
1379
|
+
return doDetail(params, agentDir, norm.liveSessionDir)
|
|
1560
1380
|
case 'search':
|
|
1561
|
-
return doSearch(params,
|
|
1381
|
+
return doSearch(params, norm, signal, cachedProvider)
|
|
1562
1382
|
case 'export':
|
|
1563
|
-
return doExport(params, agentDir)
|
|
1383
|
+
return doExport(params, agentDir, norm.liveSessionDir)
|
|
1564
1384
|
case 'extract':
|
|
1565
|
-
return doExtract(params, agentDir)
|
|
1385
|
+
return doExtract(params, agentDir, norm.liveSessionDir)
|
|
1566
1386
|
case 'workflow':
|
|
1567
|
-
return doWorkflow(params, agentDir)
|
|
1387
|
+
return doWorkflow(params, agentDir, norm.liveSessionDir)
|
|
1388
|
+
case 'result':
|
|
1389
|
+
// per-call 覆盖 deps.resolveSessionId:把信号包中的 liveSessionDir 闭包进解析调用
|
|
1390
|
+
//(ResultActionDeps 接口签名固定 5 参,包装保持同形、末位补传),result 的片段
|
|
1391
|
+
// 形态与 find/outline 消费同一 roots(sa-/绝对路径分支在 resolveSessionId 内不受影响)。
|
|
1392
|
+
return doResult(params, agentDir, {
|
|
1393
|
+
...RESULT_ACTION_DEPS,
|
|
1394
|
+
resolveSessionId: (rawSession, action, ad, source, prefetchedManifests) =>
|
|
1395
|
+
resolveSessionId(rawSession, action, ad, source, prefetchedManifests, norm.liveSessionDir),
|
|
1396
|
+
})
|
|
1397
|
+
case 'doctor':
|
|
1398
|
+
return doDoctor(params, norm)
|
|
1568
1399
|
default: {
|
|
1569
|
-
// exhaustive guard:switch 覆盖全部
|
|
1400
|
+
// exhaustive guard:switch 覆盖全部 11 action,此处 params.action 收窄为 never;
|
|
1570
1401
|
// 仅防御运行时非法 action(schema 正常校验下不可达)
|
|
1571
1402
|
const exhaustive: never = params.action
|
|
1572
1403
|
throw err(
|
|
1573
|
-
`未知 action "${JSON.stringify(exhaustive)}"。👉 合法 action: find/family/outline/expand/detail/search/export/extract/workflow。`,
|
|
1404
|
+
`未知 action "${JSON.stringify(exhaustive)}"。👉 合法 action: find/family/outline/expand/detail/search/export/extract/workflow/result/doctor。`,
|
|
1574
1405
|
)
|
|
1575
1406
|
}
|
|
1576
1407
|
}
|