@zhushanwen/pi-session-reader 0.2.4 → 0.3.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 +1 -1
- package/src/__tests__/find.test.ts +15 -1
- package/src/__tests__/render.test.ts +2 -2
- package/src/__tests__/tool-handler.test.ts +148 -11
- package/src/__tests__/toolcall.test.ts +13 -0
- package/src/__tests__/workflow.test.ts +59 -1
- package/src/core/execution-tree.ts +0 -1
- package/src/core/family.ts +45 -20
- package/src/core/parser.ts +28 -17
- package/src/core/render.ts +60 -35
- package/src/core/toolcall.ts +105 -69
- package/src/core/workflow.ts +139 -135
- package/src/discovery/find.ts +120 -74
- package/src/discovery/subagents.ts +196 -119
- package/src/discovery/workflows.ts +44 -29
- package/src/index.ts +9 -9
- package/src/tool-handler.ts +115 -67
- package/src/tui/hash-provider.ts +0 -3
- package/src/tui/session-command.ts +1 -1
- package/vitest.config.ts +2 -0
package/src/tool-handler.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 分层约定(同 scheduler/cw-tool):本文件零 pi 依赖——agentDir 作参数注入,
|
|
5
5
|
* 不调用 getAgentDir(),可完全单测;pi 注册与 getAgentDir() 调用在 index.ts。
|
|
6
6
|
*
|
|
7
|
-
* 按 action 分发到
|
|
7
|
+
* 按 action 分发到 9 条路径,串联 M1 core(parser/tree/turns/render)+ M2 discovery
|
|
8
8
|
*(find/subagents)。content 给 LLM 读(人类可读摘要),details 供程序化消费/测试断言。
|
|
9
9
|
*
|
|
10
10
|
* 错误规格 F1-F6:handler 抛 Error(message 含 👉 恢复指引),index.ts 的 execute 闭包
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
renderOutline,
|
|
29
29
|
renderExpand,
|
|
30
30
|
renderDetail,
|
|
31
|
+
formatBytesMarker,
|
|
31
32
|
type OutlineOptions,
|
|
32
33
|
type OutlineResult,
|
|
33
34
|
type EntryBrief,
|
|
@@ -126,15 +127,6 @@ function shortCwd(cwd: string): string {
|
|
|
126
127
|
return parts.slice(-SHORT_CWD_SEGMENTS).join('/')
|
|
127
128
|
}
|
|
128
129
|
|
|
129
|
-
/** bytes→KB 换算基数(formatOmitted 的 KB 显示)。 */
|
|
130
|
-
const BYTES_PER_KB = 1024
|
|
131
|
-
|
|
132
|
-
function formatOmitted(bytes: number): string {
|
|
133
|
-
if (bytes <= 0) return ''
|
|
134
|
-
if (bytes < BYTES_PER_KB) return `[${bytes}B omitted]`
|
|
135
|
-
return `[${Math.round(bytes / BYTES_PER_KB)}KB omitted]`
|
|
136
|
-
}
|
|
137
|
-
|
|
138
130
|
/** F5 必填参数校验。 */
|
|
139
131
|
function requireStr(
|
|
140
132
|
val: string | undefined,
|
|
@@ -347,7 +339,7 @@ function formatSessionGc(record: RecordManifest): string {
|
|
|
347
339
|
/** ES2(SA_ID_NO_MATCH):sa-id 无精确匹配(可能仍在运行 / 片段输入)。 */
|
|
348
340
|
function formatSaIdNotFound(saId: string): string {
|
|
349
341
|
return (
|
|
350
|
-
`subagent "${saId}" 无匹配 record
|
|
342
|
+
`subagent "${saId}" 无匹配 record(若刚启动,record 可能尚未落盘)。` +
|
|
351
343
|
`\n👉 用 session_read { action:"family" } 查活跃/已完成的 subagent;` +
|
|
352
344
|
`若是片段输入,请用完整 sa- id 或 action:"find" 重试。`
|
|
353
345
|
)
|
|
@@ -439,7 +431,7 @@ function formatOutlineText(r: OutlineResult): string {
|
|
|
439
431
|
if (b.toolSummary) parts.push(b.toolSummary)
|
|
440
432
|
// v2 O1:补 assistant 结论行(→ )让 outline 单独可决策
|
|
441
433
|
if (b.assistantBrief) parts.push('→ ' + b.assistantBrief)
|
|
442
|
-
const om =
|
|
434
|
+
const om = formatBytesMarker(b.omittedBytes)
|
|
443
435
|
if (om) parts.push(om)
|
|
444
436
|
if (b.branch) parts.push('[旁支]')
|
|
445
437
|
return parts.join(' · ')
|
|
@@ -449,7 +441,7 @@ function formatOutlineText(r: OutlineResult): string {
|
|
|
449
441
|
`${r.stats.totalTurns} turns · ${r.stats.totalEntries} entries · ~${r.tokenEstimate} tokens${
|
|
450
442
|
r.stats.skippedLines > 0 ? ` · ${r.stats.skippedLines} skipped lines` : ''
|
|
451
443
|
}`,
|
|
452
|
-
r.truncated ? `[还有 ${r.truncated} 轮未显示,用 detail
|
|
444
|
+
r.truncated ? `[还有 ${r.truncated} 轮未显示,用 detail 的 turns 参数看指定 turn 范围]` : '',
|
|
453
445
|
]
|
|
454
446
|
.filter(Boolean)
|
|
455
447
|
.join('\n')
|
|
@@ -460,7 +452,7 @@ function formatExpandText(turn: string, entries: EntryBrief[]): string {
|
|
|
460
452
|
const lines = entries.map(
|
|
461
453
|
(e) =>
|
|
462
454
|
` [${e.index}] ${e.type}${e.role ? '/' + e.role : ''} ${e.brief}${
|
|
463
|
-
e.omittedBytes > 0 ? ' ' +
|
|
455
|
+
e.omittedBytes > 0 ? ' ' + formatBytesMarker(e.omittedBytes) : ''
|
|
464
456
|
}`,
|
|
465
457
|
)
|
|
466
458
|
return `${turn}\n${lines.join('\n')}`
|
|
@@ -681,7 +673,7 @@ async function doFamily(params: SessionReadParams, agentDir: string): Promise<To
|
|
|
681
673
|
tree = await buildExecutionTree(resolved.sessionId, agentDir, resolved.fileName)
|
|
682
674
|
} catch (e) {
|
|
683
675
|
throw err(
|
|
684
|
-
`构建执行树失败:${resolved.sessionId}(${e instanceof Error ? e.message : String(e)})。👉 检查 session 或用 find 重新定位,或改用 recursive:false 看 flat family
|
|
676
|
+
`构建执行树失败:${resolved.sessionId}(${e instanceof Error ? e.message : String(e)})。👉 检查 session 或用 find 重新定位,或改用 recursive:false 看 flat family 兜底。`,
|
|
685
677
|
)
|
|
686
678
|
}
|
|
687
679
|
return {
|
|
@@ -702,7 +694,7 @@ async function doFamily(params: SessionReadParams, agentDir: string): Promise<To
|
|
|
702
694
|
return { content: [{ type: 'text', text: formatFamilyText(family) }], details: family }
|
|
703
695
|
}
|
|
704
696
|
|
|
705
|
-
/** outline:turn 级全貌 TOC(design §3.4 outline,~
|
|
697
|
+
/** outline:turn 级全貌 TOC(design §3.4 outline,~1500 token;render budget 硬编码 2000)。 */
|
|
706
698
|
async function doOutline(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
|
|
707
699
|
const resolved = await resolveSessionId(params.session, 'outline', agentDir, params.source)
|
|
708
700
|
if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
|
|
@@ -774,29 +766,22 @@ async function doDetail(params: SessionReadParams, agentDir: string): Promise<To
|
|
|
774
766
|
/** search action 的默认命中数上限。 */
|
|
775
767
|
const SEARCH_DEFAULT_LIMIT = 20
|
|
776
768
|
|
|
777
|
-
/** search
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
const degraded = isCatastrophicPattern(pattern)
|
|
794
|
-
const hits: Array<{
|
|
795
|
-
turnIndex: number
|
|
796
|
-
entryIndex: number
|
|
797
|
-
role: string
|
|
798
|
-
matchSnippet: string
|
|
799
|
-
}> = []
|
|
769
|
+
/** search 单条命中(turn 内 entry 定位 + 角色 + 摘要片段)。 */
|
|
770
|
+
interface SearchHit {
|
|
771
|
+
turnIndex: number
|
|
772
|
+
entryIndex: number
|
|
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[] = []
|
|
800
785
|
for (const t of turns) {
|
|
801
786
|
// MF-5:Esc/abort 后 pi 已丢弃本 turn 结果,尽早退出避免继续扫描长 session
|
|
802
787
|
if (signal?.aborted) {
|
|
@@ -818,14 +803,46 @@ async function doSearch(
|
|
|
818
803
|
}
|
|
819
804
|
}
|
|
820
805
|
}
|
|
821
|
-
|
|
822
|
-
|
|
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 {
|
|
823
817
|
const lines = sliced.map(
|
|
824
818
|
(h) => ` T${pad(h.turnIndex)} #${h.entryIndex} ${h.role}: ${h.matchSnippet}`,
|
|
825
819
|
)
|
|
826
|
-
|
|
820
|
+
return `${sliced.length} hit(s) for /${pattern}/${degraded ? '(已降级为字面子串匹配)' : ''}${
|
|
827
821
|
scope !== 'all' ? ' scope=' + scope : ''
|
|
828
822
|
}${truncated ? ` (truncated, showing first ${sliced.length})` : ''}\n${lines.join('\n')}`
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** search:session 内全文检索(design §3.4 search,M3 新实现)。 */
|
|
826
|
+
async function doSearch(
|
|
827
|
+
params: SessionReadParams,
|
|
828
|
+
agentDir: string,
|
|
829
|
+
signal?: AbortSignal,
|
|
830
|
+
): 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
|
+
const pattern = requireStr(params.pattern, 'pattern', 'search')
|
|
834
|
+
const scope = params.scope ?? 'all'
|
|
835
|
+
const limit = params.limit ?? SEARCH_DEFAULT_LIMIT
|
|
836
|
+
const { entries } = await safeParse(resolved.fileName)
|
|
837
|
+
const tree = buildTreeView(entries)
|
|
838
|
+
const turns = segmentTurns(entries, new Set(tree.leafPath))
|
|
839
|
+
const regex = compilePattern(pattern)
|
|
840
|
+
// S-3:启发式降级时在 header 标注,避免 LLM 把 0 hit(s) 误读为「无匹配」(静默错数据)
|
|
841
|
+
const degraded = isCatastrophicPattern(pattern)
|
|
842
|
+
const hits = collectSearchHits(turns, regex, scope, signal)
|
|
843
|
+
const truncated = hits.length > limit
|
|
844
|
+
const sliced = truncated ? hits.slice(0, limit) : hits
|
|
845
|
+
const text = formatSearchText(pattern, degraded, scope, sliced, truncated)
|
|
829
846
|
return { content: [{ type: 'text', text }], details: { hits: sliced, truncated } }
|
|
830
847
|
}
|
|
831
848
|
|
|
@@ -1195,8 +1212,16 @@ function extractFiles(turns: Turn[]): ToolResult {
|
|
|
1195
1212
|
/** commits 提取的 hash 前后上下文字符数(次路径关键词判定窗口)。 */
|
|
1196
1213
|
const COMMIT_CONTEXT_CHARS = 30
|
|
1197
1214
|
|
|
1198
|
-
|
|
1199
|
-
|
|
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> {
|
|
1200
1225
|
const bashCmds = new Map<string, string>()
|
|
1201
1226
|
for (const t of turns) {
|
|
1202
1227
|
for (const e of t.entries) {
|
|
@@ -1209,16 +1234,39 @@ function extractCommits(turns: Turn[]): ToolResult {
|
|
|
1209
1234
|
}
|
|
1210
1235
|
}
|
|
1211
1236
|
}
|
|
1237
|
+
return bashCmds
|
|
1238
|
+
}
|
|
1212
1239
|
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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
|
+
}
|
|
1218
1260
|
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/** 主扫描阶段:遍历 toolResult entry,分类收集高/低置信 commit 候选。 */
|
|
1264
|
+
function collectCommitCandidates(
|
|
1265
|
+
turns: Turn[],
|
|
1266
|
+
bashCmds: Map<string, string>,
|
|
1267
|
+
): { high: CommitItem[]; low: CommitItem[] } {
|
|
1219
1268
|
const high: CommitItem[] = []
|
|
1220
1269
|
const low: CommitItem[] = []
|
|
1221
|
-
|
|
1222
1270
|
for (const t of turns) {
|
|
1223
1271
|
for (const e of t.entries) {
|
|
1224
1272
|
if (e.message?.role !== 'toolResult') continue
|
|
@@ -1227,23 +1275,14 @@ function extractCommits(turns: Turn[]): ToolResult {
|
|
|
1227
1275
|
if (text === '') continue
|
|
1228
1276
|
const cmd = msg.toolCallId !== undefined ? bashCmds.get(msg.toolCallId) : undefined
|
|
1229
1277
|
const isGitBash = msg.toolName === 'bash' && cmd !== undefined && GIT_CMD_RE.test(cmd)
|
|
1230
|
-
|
|
1231
|
-
const hash = m[0]
|
|
1232
|
-
const idx = m.index ?? 0
|
|
1233
|
-
const ctx = text
|
|
1234
|
-
.slice(Math.max(0, idx - COMMIT_CONTEXT_CHARS), idx + hash.length + COMMIT_CONTEXT_CHARS)
|
|
1235
|
-
.replace(/\s+/g, ' ')
|
|
1236
|
-
.trim()
|
|
1237
|
-
if (isGitBash) {
|
|
1238
|
-
high.push({ hash, turn: t.index, source: 'git-cmd', context: ctx })
|
|
1239
|
-
} else if (COMMIT_CTX_RE.test(ctx)) {
|
|
1240
|
-
low.push({ hash, turn: t.index, source: 'commit-context', context: ctx })
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1278
|
+
matchCommitsInResult(text, t.index, isGitBash, high, low)
|
|
1243
1279
|
}
|
|
1244
1280
|
}
|
|
1281
|
+
return { high, low }
|
|
1282
|
+
}
|
|
1245
1283
|
|
|
1246
|
-
|
|
1284
|
+
/** 收口阶段:去重,高置信优先,同 hash 保留首次。 */
|
|
1285
|
+
function dedupeCommits(high: CommitItem[], low: CommitItem[]): CommitItem[] {
|
|
1247
1286
|
const seen = new Set<string>()
|
|
1248
1287
|
const items: CommitItem[] = []
|
|
1249
1288
|
for (const c of high) {
|
|
@@ -1256,6 +1295,14 @@ function extractCommits(turns: Turn[]): ToolResult {
|
|
|
1256
1295
|
seen.add(c.hash)
|
|
1257
1296
|
items.push(c)
|
|
1258
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)
|
|
1259
1306
|
|
|
1260
1307
|
return renderExtractItems(
|
|
1261
1308
|
'commits',
|
|
@@ -1385,8 +1432,9 @@ interface SkippedRun {
|
|
|
1385
1432
|
* session 会抛「session not found in family index」)——resolveSessionId 已把 session 解析到
|
|
1386
1433
|
* 真实文件(kind==='ok' 保证文件存在,三形态:绝对路径/sa-id 均 existsSync 校验,片段匹配
|
|
1387
1434
|
* 来自实际 fs 扫描),直接用 resolved.fileName 构造单条目 sessionIdToPath 调 resolveWorkflows
|
|
1388
|
-
*(与 buildFamilyFromFs 步骤 6 的 workflow 腿同源)。pathToRef
|
|
1389
|
-
* 走 sessionRefFromPath 文件名最小回退(sessionId+fileName
|
|
1435
|
+
*(与 buildFamilyFromFs 步骤 6 的 workflow 腿同源)。pathToRef 传空 Map(单条目链路无其他
|
|
1436
|
+
* 文件可反查),call 引用 100% 走 sessionRefFromPath 文件名最小回退(sessionId+fileName,
|
|
1437
|
+
* 足够 LLM 跳 outline/detail 深读)。
|
|
1390
1438
|
*
|
|
1391
1439
|
* 错误契约(C2):workflow 概览探索语义,三类错误均返回 ToolResult 不抛错。
|
|
1392
1440
|
* step 的 call sessionId/sessionFile 是 LLM 跳 outline/detail 的入口(m0 resolveSessionId
|
|
@@ -1468,7 +1516,7 @@ async function doWorkflow(params: SessionReadParams, agentDir: string): Promise<
|
|
|
1468
1516
|
if (requestedRunId !== undefined) details.requestedRunId = requestedRunId
|
|
1469
1517
|
if (skippedRuns.length > 0) details.skippedRuns = skippedRuns
|
|
1470
1518
|
|
|
1471
|
-
// 全部 run
|
|
1519
|
+
// 全部 run 都跳过的兜底提示(ES-wf-snapshot-read-fail 末段)
|
|
1472
1520
|
let text: string
|
|
1473
1521
|
if (runs.length === 0) {
|
|
1474
1522
|
text =
|
package/src/tui/hash-provider.ts
CHANGED
|
@@ -47,8 +47,6 @@ const AGE_NUM_DIGITS = 2
|
|
|
47
47
|
export interface AutocompleteCandidate {
|
|
48
48
|
/** 显示文本(满宽 label)。`${age} ${预览/name}`,如 "01m 看看 pi-session-reader..." */
|
|
49
49
|
label: string
|
|
50
|
-
/** 副信息(次列)。本 provider 不设(undefined)——触发 SelectList 满宽 label 分支 */
|
|
51
|
-
description?: string
|
|
52
50
|
/** 插入编辑器,如 "#019e6c96-aaaa-bbbb-cccc-dddddddddddd"(design D-3:完整 uuid,非名称;不显示给用户看) */
|
|
53
51
|
insertText: string
|
|
54
52
|
}
|
|
@@ -203,7 +201,6 @@ export function createHashAutocompleteProvider(
|
|
|
203
201
|
const items: AutocompleteItem[] = candidates.map((c) => ({
|
|
204
202
|
value: c.insertText,
|
|
205
203
|
label: c.label,
|
|
206
|
-
description: c.description,
|
|
207
204
|
}))
|
|
208
205
|
// prefix = 光标前匹配到的整段(# 及片段),applyCompletion 据此定位替换区间
|
|
209
206
|
return { items, prefix: `#${fragment}` }
|
|
@@ -36,7 +36,7 @@ export function createSessionCommand(
|
|
|
36
36
|
handler(args: string, ctx: ExtensionCommandContext): Promise<void>
|
|
37
37
|
} {
|
|
38
38
|
return {
|
|
39
|
-
description: 'Pick a session and insert a #uuid
|
|
39
|
+
description: 'Pick a session and insert a #uuid reference into the editor.',
|
|
40
40
|
async getArgumentCompletions(argumentPrefix) {
|
|
41
41
|
const trimmed = argumentPrefix.trim()
|
|
42
42
|
const all = await SessionManager.listAll(getCwdSessionDir())
|
package/vitest.config.ts
CHANGED