@zhushanwen/pi-session-reader 0.5.1 → 0.6.1
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 +6 -3
- package/src/__tests__/cross-package-subagent-core.test.ts +42 -49
- package/src/__tests__/env.test.ts +54 -54
- package/src/__tests__/family.test.ts +0 -7
- package/src/__tests__/find.test.ts +6 -6
- package/src/__tests__/index.test.ts +1 -1
- package/src/__tests__/real-data.ts +23 -10
- package/src/__tests__/render.test.ts +36 -1
- package/src/__tests__/result.test.ts +20 -23
- package/src/__tests__/roots.test.ts +47 -81
- package/src/__tests__/session-command.test.ts +23 -0
- package/src/__tests__/subagents.test.ts +5 -7
- package/src/__tests__/tool-handler.test.ts +481 -216
- package/src/__tests__/workflow.test.ts +1 -7
- package/src/core/execution-tree.ts +1 -0
- package/src/core/family.ts +12 -9
- package/src/core/render.ts +41 -14
- package/src/core/toolcall.ts +1 -2
- package/src/core/units.ts +14 -0
- package/src/core/workflow.ts +7 -20
- package/src/discovery/env.ts +41 -41
- package/src/discovery/find.ts +43 -135
- package/src/discovery/roots.ts +18 -89
- package/src/discovery/session-header.ts +103 -0
- package/src/discovery/subagents.ts +23 -94
- package/src/doctor.ts +12 -143
- package/src/extract.ts +6 -19
- package/src/handler-utils.ts +28 -5
- package/src/index.ts +2 -2
- package/src/no-match.ts +3 -2
- package/src/result-action.ts +29 -34
- package/src/search-across.ts +6 -8
- package/src/tool-handler.ts +151 -176
- package/src/tui/hash-provider.ts +7 -3
- package/src/tui/session-command.ts +22 -10
- package/vitest.config.ts +2 -4
package/src/tool-handler.ts
CHANGED
|
@@ -11,26 +11,30 @@
|
|
|
11
11
|
* 域模块拆分(max-lines 拆分轮机械提取,零行为变更,result-action.ts 先例同型):
|
|
12
12
|
* result-action.ts(result)/ doctor.ts(doctor + SessionReadSignals)/
|
|
13
13
|
* search-across.ts(search 管线 + u12 跨会话)/ extract.ts(extract 预设)/
|
|
14
|
-
* no-match.ts(F1 自检行)/ handler-utils.ts(pad/err/
|
|
14
|
+
* no-match.ts(F1 自检行)/ handler-utils.ts(pad/err/stripHash/requireStr/
|
|
15
|
+
* SESSION_ID_PREFIX_LEN/turn 索引解析低层小工具)。
|
|
15
16
|
* 本模块保留公共类型、定位解析(resolveSessionId)、各 action 编排与共享渲染;
|
|
16
|
-
*
|
|
17
|
+
* 域模块符号不经此 re-export——从所属域模块直接 import(唯一例外 SessionReadSignals:
|
|
18
|
+
* 本模块 re-export 供 index.ts 生产消费)。
|
|
17
19
|
*
|
|
18
20
|
* 错误规格 F1-F6:handler 抛 Error(message 含 👉 恢复指引),index.ts 的 execute 闭包
|
|
19
21
|
* 原样传播给 pi——pi-agent-core 只对 execute throw 置 isError:true(返回值里的 isError
|
|
20
22
|
* 字段被丢弃,agent-loop.js:453-483)。handler 可抛(纯逻辑可测)。
|
|
21
23
|
* 例外:F2 多匹配与 F1 find 零匹配「不视为错误」,返回消歧/提示结果而非抛错。
|
|
22
24
|
*/
|
|
23
|
-
import { existsSync
|
|
24
|
-
import { mkdir, writeFile } from 'node:fs/promises'
|
|
25
|
+
import { existsSync } from 'node:fs'
|
|
26
|
+
import { mkdir, stat, writeFile } from 'node:fs/promises'
|
|
25
27
|
import { join, isAbsolute } from 'node:path'
|
|
26
28
|
import { homedir } from 'node:os'
|
|
29
|
+
import { toErrorMessage } from '@zhushanwen/pi-ext-guards'
|
|
27
30
|
import {
|
|
28
31
|
findSessions,
|
|
29
32
|
type MatchedSession,
|
|
30
33
|
type SessionMetadataEntry,
|
|
31
34
|
type SessionMetadataProvider,
|
|
32
35
|
} from './discovery/find.js'
|
|
33
|
-
import { resolveSessionRoots } from './discovery/roots.js'
|
|
36
|
+
import { resolveSessionRoots, type SessionRoot } from './discovery/roots.js'
|
|
37
|
+
import { readSessionHeaderIdSync } from './discovery/session-header.js'
|
|
34
38
|
import {
|
|
35
39
|
buildFamilyFromFs,
|
|
36
40
|
listRecordManifests,
|
|
@@ -52,10 +56,9 @@ import {
|
|
|
52
56
|
type ToolResultSummaryEntry,
|
|
53
57
|
} from './core/render.js'
|
|
54
58
|
import type { Family, SessionRef, WorkflowRef } from './core/family.js'
|
|
55
|
-
import { doResult
|
|
59
|
+
import { doResult } from './result-action.js'
|
|
56
60
|
|
|
57
|
-
// result action 主体在 result-action.ts(max-lines
|
|
58
|
-
// 导出面保持不变:extractFinalAssistantText 仍从本模块导出(包内测试白盒导入路径不变)。
|
|
61
|
+
// result action 主体在 result-action.ts(max-lines 拆分轮机械提取,零行为变更)。
|
|
59
62
|
import {
|
|
60
63
|
buildExecutionTree,
|
|
61
64
|
formatExecutionTreeText,
|
|
@@ -63,7 +66,18 @@ import {
|
|
|
63
66
|
} from './core/execution-tree.js'
|
|
64
67
|
// 同轮拆分的域模块(依赖方向:本模块 → 域模块 → handler-utils,无循环;
|
|
65
68
|
// 域模块对本模块仅 type import——编译期擦除,同 result-action.ts 先例)。
|
|
66
|
-
|
|
69
|
+
// stripHash/requireStr/SESSION_ID_PREFIX_LEN 经 handler-utils 供本模块与 result-action
|
|
70
|
+
// 直接消费(ext-simplify-04 E5:同包 helper 获取范式单一化)。
|
|
71
|
+
import {
|
|
72
|
+
SESSION_ID_PREFIX_LEN,
|
|
73
|
+
err,
|
|
74
|
+
pad,
|
|
75
|
+
parseTurnIndex,
|
|
76
|
+
parseTurnsRange,
|
|
77
|
+
rangeLabel,
|
|
78
|
+
requireStr,
|
|
79
|
+
stripHash,
|
|
80
|
+
} from './handler-utils.js'
|
|
67
81
|
import { formatNoMatch } from './no-match.js'
|
|
68
82
|
import {
|
|
69
83
|
collectSearchHits,
|
|
@@ -81,14 +95,10 @@ import {
|
|
|
81
95
|
extractUserMessages,
|
|
82
96
|
type ExtractWhat,
|
|
83
97
|
} from './extract.js'
|
|
84
|
-
import { doDoctor,
|
|
98
|
+
import { doDoctor, type SessionReadSignals } from './doctor.js'
|
|
85
99
|
|
|
86
|
-
//
|
|
100
|
+
// SessionReadSignals re-export 是生产链(index.ts 工具注册消费),非测试兼容转发。
|
|
87
101
|
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'
|
|
92
102
|
|
|
93
103
|
// ---------------------------------------------------------------------------
|
|
94
104
|
// 公共类型(与 index.ts 的 TypeBox schema 对齐)
|
|
@@ -149,11 +159,6 @@ export interface ToolResult {
|
|
|
149
159
|
// 小工具
|
|
150
160
|
// ---------------------------------------------------------------------------
|
|
151
161
|
|
|
152
|
-
/** 剥 # 前缀(TUI `#e6c96` 引用 → 纯片段,design §3.3 D-3/D-4)。 */
|
|
153
|
-
function stripHash(s: string): string {
|
|
154
|
-
return s.replace(/^#+/, '')
|
|
155
|
-
}
|
|
156
|
-
|
|
157
162
|
/** formatDate 日期段(月/日)补零宽度。 */
|
|
158
163
|
const DATE_FIELD_WIDTH = 2
|
|
159
164
|
|
|
@@ -174,18 +179,6 @@ function shortCwd(cwd: string): string {
|
|
|
174
179
|
return parts.slice(-SHORT_CWD_SEGMENTS).join('/')
|
|
175
180
|
}
|
|
176
181
|
|
|
177
|
-
/** F5 必填参数校验。 */
|
|
178
|
-
function requireStr(
|
|
179
|
-
val: string | undefined,
|
|
180
|
-
name: string,
|
|
181
|
-
action: SessionReadAction,
|
|
182
|
-
): string {
|
|
183
|
-
if (val === undefined || val === null || val.trim() === '') {
|
|
184
|
-
throw err(`action:"${action}" 需要参数 "${name}"。👉 补上 "${name}" 重试。`)
|
|
185
|
-
}
|
|
186
|
-
return val.trim()
|
|
187
|
-
}
|
|
188
|
-
|
|
189
182
|
// ---------------------------------------------------------------------------
|
|
190
183
|
// resolveSessionId:片段 → 完整 id(design §3.4 resolveSessionId 辅助)
|
|
191
184
|
// ---------------------------------------------------------------------------
|
|
@@ -194,50 +187,8 @@ export type ResolveResult =
|
|
|
194
187
|
| { kind: 'ok'; sessionId: string; fileName: string }
|
|
195
188
|
| { kind: 'multi'; query: string; candidates: MatchedSession[] }
|
|
196
189
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* 同步读 session 文件首行 header,返回 type==='session' 的 id。
|
|
202
|
-
*
|
|
203
|
-
* 任何异常(文件不存在/空文件/解析失败/type 不符)返回 undefined。与 find.ts readFirstLine/
|
|
204
|
-
* parseHeader 同构(定长 buffer 读首行 + JSON.parse + type 校验),但用同步 fs API
|
|
205
|
-
*(resolveSessionId 内仅调用 1 次,同步开销可接受),且不导出——避免与 w1 的 find.ts
|
|
206
|
-
* 文件交叉(CQ2 决策)。
|
|
207
|
-
*/
|
|
208
|
-
function readSessionHeaderId(filePath: string): string | undefined {
|
|
209
|
-
let fd: number | undefined
|
|
210
|
-
try {
|
|
211
|
-
fd = openSync(filePath, 'r')
|
|
212
|
-
const buf = Buffer.alloc(HEADER_READ_BYTES)
|
|
213
|
-
const bytesRead = readSync(fd, buf, 0, HEADER_READ_BYTES, 0)
|
|
214
|
-
if (bytesRead === 0) return undefined
|
|
215
|
-
const text = buf.subarray(0, bytesRead).toString('utf8')
|
|
216
|
-
const nl = text.indexOf('\n')
|
|
217
|
-
const line = nl === -1 ? text : text.slice(0, nl)
|
|
218
|
-
let raw: unknown
|
|
219
|
-
try {
|
|
220
|
-
raw = JSON.parse(line)
|
|
221
|
-
} catch {
|
|
222
|
-
return undefined
|
|
223
|
-
}
|
|
224
|
-
if (typeof raw !== 'object' || raw === null) return undefined
|
|
225
|
-
const o = raw as Record<string, unknown>
|
|
226
|
-
if (o.type !== 'session' || typeof o.id !== 'string') return undefined
|
|
227
|
-
return o.id
|
|
228
|
-
} catch {
|
|
229
|
-
return undefined
|
|
230
|
-
} finally {
|
|
231
|
-
if (fd !== undefined) {
|
|
232
|
-
try {
|
|
233
|
-
closeSync(fd)
|
|
234
|
-
} catch {
|
|
235
|
-
// closeSync 失败:fd 可能已无效,header 数据已读取,关闭失败不影响结果(best-effort)
|
|
236
|
-
void fd
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
190
|
+
// readSessionHeaderIdSync(同步读首行 header 取 id,resolveSessionId 形态①/②消费)
|
|
191
|
+
// 在 discovery/session-header.ts(D5 单源,sync 4KB 版原样搬入)。
|
|
241
192
|
|
|
242
193
|
/** ~ 前缀(home 目录简写),与 expandHome 配套避免 magic number。 */
|
|
243
194
|
const HOME_TILDE_PREFIX = '~/'
|
|
@@ -300,7 +251,7 @@ function resolveBySessionPath(session: string): ResolveResult {
|
|
|
300
251
|
if (!existsSync(expanded)) {
|
|
301
252
|
throw err(`读取失败:${session}(文件不存在)。👉 检查文件或换 session。`)
|
|
302
253
|
}
|
|
303
|
-
const headerId =
|
|
254
|
+
const headerId = readSessionHeaderIdSync(expanded)
|
|
304
255
|
if (headerId === undefined) {
|
|
305
256
|
throw err(
|
|
306
257
|
`读取失败:${session}(首行非合法 session header)。👉 检查文件或换 session。`,
|
|
@@ -327,7 +278,7 @@ async function resolveByRecordId(session: string, agentDir: string, prefetchedMa
|
|
|
327
278
|
if (!existsSync(record.sessionFile)) {
|
|
328
279
|
throw err(formatSessionGc(record))
|
|
329
280
|
}
|
|
330
|
-
const headerId =
|
|
281
|
+
const headerId = readSessionHeaderIdSync(record.sessionFile)
|
|
331
282
|
if (headerId === undefined) {
|
|
332
283
|
// header 读不出不降级 record.id(sa- 形态不可当 sessionId,CQ3)
|
|
333
284
|
throw err(
|
|
@@ -355,9 +306,10 @@ async function resolveByFragment(
|
|
|
355
306
|
}
|
|
356
307
|
const { matches } = await findSessions(session, agentDir, opts)
|
|
357
308
|
if (matches.length === 0) {
|
|
358
|
-
// F1 自检行需要发现层实况:无 options 的 resolveSessionRoots
|
|
359
|
-
//
|
|
360
|
-
//
|
|
309
|
+
// F1 自检行需要发现层实况:无 options 的 resolveSessionRoots 恒实扫(根扫描无
|
|
310
|
+
// 缓存——doctor 缓存机已删除,ext-simplify-04 U3),与 find 刚完成的扫描同一数据
|
|
311
|
+
// 源(roots.ts 薄包装语义);信号包同源(liveSessionDir 透传)——findSessions
|
|
312
|
+
// 内部对空串/undefined 已有降级 guard。
|
|
361
313
|
const roots = await resolveSessionRoots({ agentDir, liveSessionDir })
|
|
362
314
|
throw err(formatNoMatch(session, roots))
|
|
363
315
|
}
|
|
@@ -398,8 +350,6 @@ function formatSaIdAmbiguous(saId: string, records: RecordManifest[]): string {
|
|
|
398
350
|
)
|
|
399
351
|
}
|
|
400
352
|
|
|
401
|
-
/** sessionId 列表行内的短显前缀长度。 */
|
|
402
|
-
const SESSION_ID_PREFIX_LEN = 8
|
|
403
353
|
/** 消歧提示的 uuid 片段长度(比短显略长,引导输入更长片段消歧)。 */
|
|
404
354
|
const HINT_ID_PREFIX_LEN = 12
|
|
405
355
|
|
|
@@ -428,7 +378,7 @@ async function safeParse(fileName: string): Promise<ParseResult> {
|
|
|
428
378
|
return await parseSessionFile(fileName)
|
|
429
379
|
} catch (e) {
|
|
430
380
|
throw err(
|
|
431
|
-
`读取失败:${fileName}(${
|
|
381
|
+
`读取失败:${fileName}(${toErrorMessage(e)})。👉 检查文件或换 session。`,
|
|
432
382
|
)
|
|
433
383
|
}
|
|
434
384
|
}
|
|
@@ -500,21 +450,15 @@ function formatFindContent(query: string, groups: FindGroup[], truncated: boolea
|
|
|
500
450
|
return `${head}\n${lines.join('\n')}`
|
|
501
451
|
}
|
|
502
452
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const om = formatBytesMarker(b.omittedBytes)
|
|
512
|
-
if (om) parts.push(om)
|
|
513
|
-
if (b.branch) parts.push('[旁支]')
|
|
514
|
-
return parts.join(' · ')
|
|
515
|
-
})
|
|
453
|
+
/**
|
|
454
|
+
* outline 尾段(stats 摘要行 + truncated 提示)。E7/D3 行渲染统一:行主体 = result.lines
|
|
455
|
+
*(renderOutline 返回的渲染行,预算度量与展示同一份),行格式知识只在 core/render.ts 的
|
|
456
|
+
* formatLine 一处,tool-handler 不再重建行格式;本函数只拼 stats 尾段(skippedLines 由
|
|
457
|
+
* doOutline 用 ParseResult 覆盖后再渲染)。doOutline/doExport 同一拼装
|
|
458
|
+
* (`lines.join('\n')` + 本尾段),两 action 输出一致 by construction。
|
|
459
|
+
*/
|
|
460
|
+
function formatOutlineTail(r: OutlineResult): string {
|
|
516
461
|
const tail = [
|
|
517
|
-
'',
|
|
518
462
|
`${r.stats.totalTurns} turns · ${r.stats.totalEntries} entries · ~${r.tokenEstimate} tokens${
|
|
519
463
|
r.stats.skippedLines > 0 ? ` · ${r.stats.skippedLines} skipped lines` : ''
|
|
520
464
|
}`,
|
|
@@ -522,7 +466,7 @@ function formatOutlineText(r: OutlineResult): string {
|
|
|
522
466
|
]
|
|
523
467
|
.filter(Boolean)
|
|
524
468
|
.join('\n')
|
|
525
|
-
return
|
|
469
|
+
return tail
|
|
526
470
|
}
|
|
527
471
|
|
|
528
472
|
function formatExpandText(turn: string, entries: EntryBrief[]): string {
|
|
@@ -602,6 +546,35 @@ function formatDetailText(
|
|
|
602
546
|
return `${head}\n${body}`
|
|
603
547
|
}
|
|
604
548
|
|
|
549
|
+
/**
|
|
550
|
+
* family subagents 行的 task 摘要截断宽度(D2②:LLM 判断「哪个 subagent 分支相关」所需
|
|
551
|
+
* 的信息量,信息密度对齐 find 的 firstMessagePreview;探针 P4 输出量级锚点)。
|
|
552
|
+
*/
|
|
553
|
+
const FAMILY_TASK_RENDER_LIMIT = 60
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* task → 单行摘要:压平空白(task 原文可含换行,换行会破坏 family 输出的行结构)后截断。
|
|
557
|
+
*/
|
|
558
|
+
function familyTaskSummary(task: string): string {
|
|
559
|
+
const flat = task.replace(/\s+/g, ' ').trim()
|
|
560
|
+
return flat.length <= FAMILY_TASK_RENDER_LIMIT ? flat : flat.slice(0, FAMILY_TASK_RENDER_LIMIT) + '…'
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* subagents 行富字段展示(ext-simplify-04 D2②):status 终态短标签 + agent 名 + task 摘要。
|
|
565
|
+
* 富字段来自 manifest/identity 组装(SubagentRef,不经 enrichRefs——已删除);孤儿
|
|
566
|
+
*(cleanedUp)只标 [已清理],不再展开摘要(已清理即终局,文件 GC 后无深读入口)。
|
|
567
|
+
*/
|
|
568
|
+
function formatSubagentLine(s: Family['subagents'][number]): string {
|
|
569
|
+
const base = ` ${s.sessionId.slice(0, SESSION_ID_PREFIX_LEN)} root=${s.rootSessionId.slice(0, SESSION_ID_PREFIX_LEN)} slug=${s.slug}`
|
|
570
|
+
if (s.cleanedUp) return `${base} [已清理]`
|
|
571
|
+
const parts: string[] = []
|
|
572
|
+
if (s.status) parts.push(`[${s.status}]`)
|
|
573
|
+
if (s.agentName) parts.push(s.agentName)
|
|
574
|
+
if (s.task) parts.push(`· ${familyTaskSummary(s.task)}`)
|
|
575
|
+
return parts.length > 0 ? `${base} ${parts.join(' ')}` : base
|
|
576
|
+
}
|
|
577
|
+
|
|
605
578
|
function formatFamilyText(f: Family): string {
|
|
606
579
|
const lines: string[] = []
|
|
607
580
|
lines.push(`root: ${f.root.sessionId} (${formatDate(f.root.mtime)})`)
|
|
@@ -610,16 +583,7 @@ function formatFamilyText(f: Family): string {
|
|
|
610
583
|
if (f.forks.length)
|
|
611
584
|
lines.push(`forks: ${f.forks.map((p) => p.sessionId.slice(0, SESSION_ID_PREFIX_LEN)).join(', ')}`)
|
|
612
585
|
if (f.subagents.length)
|
|
613
|
-
lines.push(
|
|
614
|
-
`subagents:\n${f.subagents
|
|
615
|
-
.map(
|
|
616
|
-
(s) =>
|
|
617
|
-
` ${s.sessionId.slice(0, SESSION_ID_PREFIX_LEN)} root=${s.rootSessionId.slice(0, SESSION_ID_PREFIX_LEN)} slug=${s.slug}${
|
|
618
|
-
s.cleanedUp ? ' [已清理]' : ''
|
|
619
|
-
}`,
|
|
620
|
-
)
|
|
621
|
-
.join('\n')}`,
|
|
622
|
-
)
|
|
586
|
+
lines.push(`subagents:\n${f.subagents.map(formatSubagentLine).join('\n')}`)
|
|
623
587
|
if (f.workflows.length)
|
|
624
588
|
lines.push(
|
|
625
589
|
`workflows:\n${f.workflows
|
|
@@ -637,15 +601,18 @@ function formatFamilyText(f: Family): string {
|
|
|
637
601
|
const FIND_DEFAULT_LIMIT = 20
|
|
638
602
|
|
|
639
603
|
/**
|
|
640
|
-
* find 零匹配:F1 自检行(u9)。计数取本次实扫(无 options
|
|
641
|
-
*
|
|
604
|
+
* find 零匹配:F1 自检行(u9)。计数取本次实扫(无 options 恒实扫——根扫描无缓存,
|
|
605
|
+
* doctor 缓存机已删除,ext-simplify-04 U3),完整信号包保证 [live] 根(最高优先级)
|
|
606
|
+
* 计数可见。
|
|
607
|
+
*
|
|
608
|
+
* E1(ext-simplify-04 §3 D1):roots 由 doFind 预解析传入——与匹配用同一次实扫
|
|
609
|
+
* (调用方保证无 options),不再独立第三次全量扫盘。
|
|
642
610
|
*/
|
|
643
|
-
function findNoMatch(query: string,
|
|
644
|
-
|
|
645
|
-
return resolveSessionRoots(signals).then((roots) => ({
|
|
611
|
+
function findNoMatch(query: string, roots: SessionRoot[]): ToolResult {
|
|
612
|
+
return {
|
|
646
613
|
content: [{ type: 'text', text: formatNoMatch(query, roots) }],
|
|
647
614
|
details: { matches: [], truncated: false },
|
|
648
|
-
}
|
|
615
|
+
}
|
|
649
616
|
}
|
|
650
617
|
|
|
651
618
|
/**
|
|
@@ -667,6 +634,12 @@ function findNoMatch(query: string, signals: SessionReadSignals): Promise<ToolRe
|
|
|
667
634
|
* 检索的惰性/窄化/TTL 缓存策略都在发现层与注入包装侧,本函数只负责透传(缺省
|
|
668
635
|
* undefined = 现状行为)。多次 findSessions 调用(分组探测)经注入侧 TTL 缓存去重,
|
|
669
636
|
* 标题 listAll 每 TTL 窗口至多一次/目录。
|
|
637
|
+
*
|
|
638
|
+
* E1 预解析根复用(ext-simplify-04 §3 D1,清 impl-plan D-15④ 债):开头一次
|
|
639
|
+
* resolveSessionRoots(signals)(无 options 恒实扫——根扫描无缓存,doctor 缓存机
|
|
640
|
+
* 已删除,ext-simplify-04 U3),显式 source / 分组两段 findSessions 与零匹配
|
|
641
|
+
* findNoMatch(F1 自检行)共用——一次 doFind 内 signals 恒同值、数据完全同源,
|
|
642
|
+
* 目录扫描从 2-3 次收敛为恒 1 次。
|
|
670
643
|
*/
|
|
671
644
|
async function doFind(
|
|
672
645
|
params: SessionReadParams,
|
|
@@ -676,6 +649,8 @@ async function doFind(
|
|
|
676
649
|
const query = requireStr(params.query, 'query', 'find')
|
|
677
650
|
const limit = params.limit ?? FIND_DEFAULT_LIMIT
|
|
678
651
|
const cwd = params.cwd
|
|
652
|
+
// E1:单次根解析(完整信号包——空 liveSessionDir 由 sessionRootSpecs 内部 guard 降级)
|
|
653
|
+
const roots = await resolveSessionRoots(signals)
|
|
679
654
|
|
|
680
655
|
// 显式 source:单组,匹配层原语义(mtime 排序 + limit 截断),无分组展示
|
|
681
656
|
if (params.source !== undefined) {
|
|
@@ -685,8 +660,9 @@ async function doFind(
|
|
|
685
660
|
source: params.source,
|
|
686
661
|
liveSessionDir: signals.liveSessionDir,
|
|
687
662
|
metadataProvider,
|
|
663
|
+
roots,
|
|
688
664
|
})
|
|
689
|
-
if (matches.length === 0) return findNoMatch(query,
|
|
665
|
+
if (matches.length === 0) return findNoMatch(query, roots)
|
|
690
666
|
return {
|
|
691
667
|
content: [
|
|
692
668
|
{
|
|
@@ -709,6 +685,7 @@ async function doFind(
|
|
|
709
685
|
source: 'main',
|
|
710
686
|
liveSessionDir: signals.liveSessionDir,
|
|
711
687
|
metadataProvider,
|
|
688
|
+
roots,
|
|
712
689
|
})
|
|
713
690
|
const mainHasMore = mainRes.matches.length > limit
|
|
714
691
|
const mainShown = mainHasMore ? mainRes.matches.slice(0, limit) : mainRes.matches
|
|
@@ -723,12 +700,13 @@ async function doFind(
|
|
|
723
700
|
source: 'subagent',
|
|
724
701
|
liveSessionDir: signals.liveSessionDir,
|
|
725
702
|
metadataProvider,
|
|
703
|
+
roots,
|
|
726
704
|
})
|
|
727
705
|
const subOverflow = remaining > 0 ? subRes.matches.length > remaining : subRes.matches.length > 0
|
|
728
706
|
const subShown = subRes.matches.slice(0, Math.max(remaining, 0))
|
|
729
707
|
|
|
730
708
|
const matches = [...mainShown, ...subShown]
|
|
731
|
-
if (matches.length === 0) return findNoMatch(query,
|
|
709
|
+
if (matches.length === 0) return findNoMatch(query, roots)
|
|
732
710
|
const truncated = mainHasMore || subOverflow
|
|
733
711
|
return {
|
|
734
712
|
content: [
|
|
@@ -779,7 +757,7 @@ async function doFamily(
|
|
|
779
757
|
tree = await buildExecutionTree(resolved.sessionId, agentDir, resolved.fileName)
|
|
780
758
|
} catch (e) {
|
|
781
759
|
throw err(
|
|
782
|
-
`构建执行树失败:${resolved.sessionId}(${
|
|
760
|
+
`构建执行树失败:${resolved.sessionId}(${toErrorMessage(e)})。👉 检查 session 或用 find 重新定位,或改用 recursive:false 看 flat family 兜底。`,
|
|
783
761
|
)
|
|
784
762
|
}
|
|
785
763
|
return {
|
|
@@ -794,13 +772,14 @@ async function doFamily(
|
|
|
794
772
|
family = await buildFamilyFromFs(resolved.sessionId, agentDir)
|
|
795
773
|
} catch (e) {
|
|
796
774
|
throw err(
|
|
797
|
-
`读取家族失败:${resolved.sessionId}(${
|
|
775
|
+
`读取家族失败:${resolved.sessionId}(${toErrorMessage(e)})。👉 检查 session 或用 find 重新定位。`,
|
|
798
776
|
)
|
|
799
777
|
}
|
|
800
778
|
return { content: [{ type: 'text', text: formatFamilyText(family) }], details: family }
|
|
801
779
|
}
|
|
802
780
|
|
|
803
|
-
/** outline:turn 级全貌 TOC(design §3.4 outline,~1500 token;
|
|
781
|
+
/** outline:turn 级全貌 TOC(design §3.4 outline,~1500 token;budget 走 render 侧
|
|
782
|
+
* OUTLINE_DEFAULT_BUDGET_TOKENS 默认,handler 不再传测试专用缝参数)。 */
|
|
804
783
|
async function doOutline(
|
|
805
784
|
params: SessionReadParams,
|
|
806
785
|
agentDir: string,
|
|
@@ -819,7 +798,6 @@ async function doOutline(
|
|
|
819
798
|
const tree = buildTreeView(entries)
|
|
820
799
|
const turns = segmentTurns(entries, new Set(tree.leafPath))
|
|
821
800
|
const opts: OutlineOptions = {
|
|
822
|
-
budget: 2000,
|
|
823
801
|
allBranches: params.allBranches,
|
|
824
802
|
granularity: params.granularity,
|
|
825
803
|
}
|
|
@@ -830,7 +808,11 @@ async function doOutline(
|
|
|
830
808
|
// [D8d] skippedLines 同模式覆盖:parser 已检测坏行计数(render 签名不含 ParseResult 恒 0),
|
|
831
809
|
// 有检测必有报告——静默跳过行对调用方不可见 = 数据完整性缺口
|
|
832
810
|
result.stats.skippedLines = skippedLines
|
|
833
|
-
|
|
811
|
+
// E7 行渲染统一:行主体 = result.lines(renderOutline 渲染行),handler 只拼 stats 尾段
|
|
812
|
+
return {
|
|
813
|
+
content: [{ type: 'text', text: `${result.lines.join('\n')}\n${formatOutlineTail(result)}` }],
|
|
814
|
+
details: result,
|
|
815
|
+
}
|
|
834
816
|
}
|
|
835
817
|
|
|
836
818
|
/** expand:单 turn 的 entry 列表(design §3.4 expand)。turn 越界抛 F4。 */
|
|
@@ -982,7 +964,7 @@ async function doExport(
|
|
|
982
964
|
family = await buildFamilyFromFs(resolved.sessionId, agentDir)
|
|
983
965
|
} catch (e) {
|
|
984
966
|
throw err(
|
|
985
|
-
`读取家族失败:${resolved.sessionId}(${
|
|
967
|
+
`读取家族失败:${resolved.sessionId}(${toErrorMessage(e)})。👉 检查 session 或用 find 重新定位。`,
|
|
986
968
|
)
|
|
987
969
|
}
|
|
988
970
|
text = formatFamilyText(family)
|
|
@@ -1009,11 +991,11 @@ async function doExport(
|
|
|
1009
991
|
const tree = buildTreeView(entries)
|
|
1010
992
|
const turns = segmentTurns(entries, new Set(tree.leafPath))
|
|
1011
993
|
const result = renderOutline(turns, tree, {
|
|
1012
|
-
budget: 2000,
|
|
1013
994
|
allBranches: params.allBranches,
|
|
1014
995
|
granularity: params.granularity,
|
|
1015
996
|
})
|
|
1016
|
-
|
|
997
|
+
// E7 行渲染统一:与 doOutline 同一拼装(lines + 尾段),两 action 输出一致
|
|
998
|
+
text = `${result.lines.join('\n')}\n${formatOutlineTail(result)}`
|
|
1017
999
|
label = 'outline'
|
|
1018
1000
|
}
|
|
1019
1001
|
|
|
@@ -1187,7 +1169,7 @@ async function doWorkflow(
|
|
|
1187
1169
|
workflows = await resolveWorkflows(resolved.sessionId, sessionIdToPath, pathToRef)
|
|
1188
1170
|
} catch (e) {
|
|
1189
1171
|
throw err(
|
|
1190
|
-
`读取 workflow run 失败:${resolved.sessionId}(${
|
|
1172
|
+
`读取 workflow run 失败:${resolved.sessionId}(${toErrorMessage(e)})。👉 检查 session 或用 find 重新定位。`,
|
|
1191
1173
|
)
|
|
1192
1174
|
}
|
|
1193
1175
|
const allRunIds = workflows.map((w) => w.runId)
|
|
@@ -1266,9 +1248,9 @@ async function doWorkflow(
|
|
|
1266
1248
|
// ---------------------------------------------------------------------------
|
|
1267
1249
|
|
|
1268
1250
|
/**
|
|
1269
|
-
* 标题缓存条目(SessionMetadataEntry[] keyed by
|
|
1270
|
-
*
|
|
1271
|
-
*
|
|
1251
|
+
* 标题缓存条目(SessionMetadataEntry[] keyed by 目录字面路径)。进程内唯一实例,
|
|
1252
|
+
* 仅缓存标题元数据(session_info name / firstMessage,低频变更)——根扫描统计
|
|
1253
|
+
* 不再有缓存(doctor 缓存机已删除,ext-simplify-04 U3),与根扫描无共享状态。
|
|
1272
1254
|
*/
|
|
1273
1255
|
interface MetadataCacheEntry {
|
|
1274
1256
|
entries: SessionMetadataEntry[]
|
|
@@ -1283,11 +1265,23 @@ const metadataCache = new Map<string, MetadataCacheEntry>()
|
|
|
1283
1265
|
// 分裂成两份仅多一次 miss 重扫,无正确性影响,不升级 globalThis 单例。
|
|
1284
1266
|
|
|
1285
1267
|
/**
|
|
1286
|
-
* 标题缓存 TTL(秒级,§6.6 策略
|
|
1287
|
-
*
|
|
1288
|
-
|
|
1268
|
+
* 标题缓存 TTL(秒级,§6.6 策略 ③)。独立定义 5000(原「与 doctor 根扫描缓存同档
|
|
1269
|
+
* (DOCTOR_CACHE_TTL_MS)」的别名已随 doctor 缓存机删除而撤销,ext-simplify-04 U3);
|
|
1270
|
+
* 量级待 §11.3a 实测校准;mtime 是主失效通道,TTL 兜「目录内文件追加不改目录 mtime」
|
|
1271
|
+
* 的陈旧面(标题恰好随首条消息落盘,同窗口内新增标题最多延迟一个 TTL 可见,可接受)。
|
|
1289
1272
|
*/
|
|
1290
|
-
export const METADATA_CACHE_TTL_MS =
|
|
1273
|
+
export const METADATA_CACHE_TTL_MS = 5000
|
|
1274
|
+
|
|
1275
|
+
/** stat 目录 mtime;不存在返回 null(与缓存条目的 null 比对 = 存在性未翻转)。 */
|
|
1276
|
+
async function statDirMtimeOrNull(path: string): Promise<number | null> {
|
|
1277
|
+
try {
|
|
1278
|
+
return (await stat(path)).mtimeMs
|
|
1279
|
+
} catch (err) {
|
|
1280
|
+
// 目录不存在是常态输入(候选根降级形态),非异常——void 同 roots.ts 容错
|
|
1281
|
+
void err
|
|
1282
|
+
return null
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1291
1285
|
|
|
1292
1286
|
/**
|
|
1293
1287
|
* 把注入的 metadataProvider 包上 TTL 缓存(get 失效判定 + set 快照)。
|
|
@@ -1296,7 +1290,7 @@ export const METADATA_CACHE_TTL_MS = DOCTOR_CACHE_TTL_MS
|
|
|
1296
1290
|
* 且瞬态失败不污染缓存(下个查询即重试)。find 的多次 findSessions 调用(u10 分组探测
|
|
1297
1291
|
* main/subagent 两路)与连续 keyword 查询都经此处去重,listAll 每 TTL 窗口至多一次/目录。
|
|
1298
1292
|
*/
|
|
1299
|
-
|
|
1293
|
+
function withMetadataCache(provider: SessionMetadataProvider): SessionMetadataProvider {
|
|
1300
1294
|
return async (dir) => {
|
|
1301
1295
|
const hit = metadataCache.get(dir)
|
|
1302
1296
|
if (hit !== undefined) {
|
|
@@ -1315,23 +1309,6 @@ export function withMetadataCache(provider: SessionMetadataProvider): SessionMet
|
|
|
1315
1309
|
}
|
|
1316
1310
|
}
|
|
1317
1311
|
|
|
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
|
-
|
|
1335
1312
|
// ===========================================================================
|
|
1336
1313
|
// 入口:按 action 分发
|
|
1337
1314
|
// ===========================================================================
|
|
@@ -1343,10 +1320,8 @@ export { extractFinalAssistantText }
|
|
|
1343
1320
|
* F1(resolve)/F4/F5/F6 抛 Error(含 👉);F2 多匹配与 find 零匹配返回结果不抛。
|
|
1344
1321
|
*
|
|
1345
1322
|
* @param signals 发现层信号包(design §7B:index.ts 采集 { agentDir, liveSessionDir? },
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
* 完整信号包)。u9 起 find/F1 路径消费根列表(F1 自检行恒走无 options 实扫,
|
|
1349
|
-
* 不读 doctor 缓存,§7B 要点 8)。
|
|
1323
|
+
* 采集端全可选链可降级)。u9 起 find/F1 路径消费根列表(F1 自检行计数恒取本次
|
|
1324
|
+
* 实扫,无任何根扫描缓存,§7B 要点 8)。
|
|
1350
1325
|
* @param signal 可选 AbortSignal(MF-5):仅 search 消费(长扫描可中断);其余 action 有界,不接。
|
|
1351
1326
|
* @param metadataProvider 可选标题元数据注入(u11,design 2026-09-10 §6.6):index.ts 构造
|
|
1352
1327
|
* `(dir) => SessionManager.listAll(dir)`,此处包 TTL 缓存后透传 find。缺省 = undefined =
|
|
@@ -1355,47 +1330,47 @@ export { extractFinalAssistantText }
|
|
|
1355
1330
|
*/
|
|
1356
1331
|
export async function handleSessionRead(
|
|
1357
1332
|
params: SessionReadParams,
|
|
1358
|
-
signals: SessionReadSignals
|
|
1333
|
+
signals: SessionReadSignals,
|
|
1359
1334
|
signal?: AbortSignal,
|
|
1360
1335
|
metadataProvider?: SessionMetadataProvider,
|
|
1361
1336
|
): Promise<ToolResult> {
|
|
1362
|
-
|
|
1363
|
-
//
|
|
1364
|
-
const norm: SessionReadSignals = typeof signals === 'string' ? { agentDir: signals } : signals
|
|
1365
|
-
const agentDir = norm.agentDir
|
|
1366
|
-
// u11:TTL 缓存包装在注入边界(策略 ③,独立于 doctorScanCache 的实例);仅 find 消费。
|
|
1337
|
+
const agentDir = signals.agentDir
|
|
1338
|
+
// u11:TTL 缓存包装在注入边界(策略 ③,metadata 缓存独立实例);仅 find/search 消费。
|
|
1367
1339
|
const cachedProvider =
|
|
1368
1340
|
metadataProvider === undefined ? undefined : withMetadataCache(metadataProvider)
|
|
1369
1341
|
switch (params.action) {
|
|
1370
1342
|
case 'find':
|
|
1371
|
-
return doFind(params,
|
|
1343
|
+
return doFind(params, signals, cachedProvider)
|
|
1372
1344
|
case 'family':
|
|
1373
|
-
return doFamily(params, agentDir,
|
|
1345
|
+
return doFamily(params, agentDir, signals.liveSessionDir)
|
|
1374
1346
|
case 'outline':
|
|
1375
|
-
return doOutline(params, agentDir,
|
|
1347
|
+
return doOutline(params, agentDir, signals.liveSessionDir)
|
|
1376
1348
|
case 'expand':
|
|
1377
|
-
return doExpand(params, agentDir,
|
|
1349
|
+
return doExpand(params, agentDir, signals.liveSessionDir)
|
|
1378
1350
|
case 'detail':
|
|
1379
|
-
return doDetail(params, agentDir,
|
|
1351
|
+
return doDetail(params, agentDir, signals.liveSessionDir)
|
|
1380
1352
|
case 'search':
|
|
1381
|
-
return doSearch(params,
|
|
1353
|
+
return doSearch(params, signals, signal, cachedProvider)
|
|
1382
1354
|
case 'export':
|
|
1383
|
-
return doExport(params, agentDir,
|
|
1355
|
+
return doExport(params, agentDir, signals.liveSessionDir)
|
|
1384
1356
|
case 'extract':
|
|
1385
|
-
return doExtract(params, agentDir,
|
|
1357
|
+
return doExtract(params, agentDir, signals.liveSessionDir)
|
|
1386
1358
|
case 'workflow':
|
|
1387
|
-
return doWorkflow(params, agentDir,
|
|
1359
|
+
return doWorkflow(params, agentDir, signals.liveSessionDir)
|
|
1388
1360
|
case 'result':
|
|
1389
|
-
// per-call
|
|
1390
|
-
|
|
1391
|
-
//
|
|
1361
|
+
// per-call 构造注入面(仅剩 tool-handler 文件私有 helper,纯函数经 handler-utils
|
|
1362
|
+
// 直接 import——ext-simplify-04 E5):resolveSessionId 包装把信号包中的
|
|
1363
|
+
// liveSessionDir 闭包进解析调用(ResultActionDeps 接口签名固定 5 参,包装保持
|
|
1364
|
+
// 同形、末位补传),result 的片段形态与 find/outline 消费同一 roots
|
|
1365
|
+
//(sa-/绝对路径分支在 resolveSessionId 内不受影响)。
|
|
1392
1366
|
return doResult(params, agentDir, {
|
|
1393
|
-
...RESULT_ACTION_DEPS,
|
|
1394
1367
|
resolveSessionId: (rawSession, action, ad, source, prefetchedManifests) =>
|
|
1395
|
-
resolveSessionId(rawSession, action, ad, source, prefetchedManifests,
|
|
1368
|
+
resolveSessionId(rawSession, action, ad, source, prefetchedManifests, signals.liveSessionDir),
|
|
1369
|
+
disambiguate,
|
|
1370
|
+
safeParse,
|
|
1396
1371
|
})
|
|
1397
1372
|
case 'doctor':
|
|
1398
|
-
return doDoctor(params,
|
|
1373
|
+
return doDoctor(params, signals)
|
|
1399
1374
|
default: {
|
|
1400
1375
|
// exhaustive guard:switch 覆盖全部 11 action,此处 params.action 收窄为 never;
|
|
1401
1376
|
// 仅防御运行时非法 action(schema 正常校验下不可达)
|
package/src/tui/hash-provider.ts
CHANGED
|
@@ -23,8 +23,9 @@ import { SessionManager, type SessionInfo } from '@earendil-works/pi-coding-agen
|
|
|
23
23
|
* design D-4:插入纯文本 # uuid,不展开——工具侧(tool-handler)剥 # 前缀后按子串匹配。
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
/** # 补全默认返回上限(TUI 弹窗可读上限,design G6
|
|
27
|
-
|
|
26
|
+
/** # 补全默认返回上限(TUI 弹窗可读上限,design G6)。/session-pick 命令复用同一上限
|
|
27
|
+
* (两入口同一可读上限,2026-09 双常量合一,session-command 不再自持 PICK_LIMIT)。 */
|
|
28
|
+
export const DEFAULT_LIMIT = 10
|
|
28
29
|
/**
|
|
29
30
|
* description(预览/name)最大字符数。SessionInfo.firstMessage 是首消息全文(可能含
|
|
30
31
|
* `<skill>` 注入全文,上千字符),预截断避免传超大字符串给 pi-tui。100 覆盖到 ~140 列
|
|
@@ -206,7 +207,10 @@ export function createHashAutocompleteProvider(
|
|
|
206
207
|
return { items, prefix: `#${fragment}` }
|
|
207
208
|
},
|
|
208
209
|
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
209
|
-
//
|
|
210
|
+
// 非 # item = 经本 provider 的 getSuggestions 转发的 current(内置 @ 文件 / / 命令 /
|
|
211
|
+
// 路径)建议——pi-tui 实装(editor 持单一 autocompleteProvider,applyCompletion 直调
|
|
212
|
+
// 最外层包装者,无按 suggestion source 的路由)决定了这些 item 的 apply 也回到本
|
|
213
|
+
// provider,必须显式委托 current(组合责任在 factory,同 getSuggestions 的委托)
|
|
210
214
|
if (!item.value.startsWith('#')) {
|
|
211
215
|
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix)
|
|
212
216
|
}
|