@zhushanwen/pi-session-reader 0.3.0 → 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__/tool-handler.test.ts +146 -9
- package/src/__tests__/toolcall.test.ts +13 -0
- package/src/__tests__/workflow.test.ts +59 -1
- package/src/core/family.ts +43 -17
- package/src/core/parser.ts +28 -17
- package/src/core/render.ts +59 -34
- 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 +188 -106
- package/src/discovery/workflows.ts +44 -29
- package/src/tool-handler.ts +103 -48
- package/vitest.config.ts +2 -0
|
@@ -34,42 +34,57 @@ export function extractCallSessionFiles(snap: unknown): string[] {
|
|
|
34
34
|
const out: string[] = []
|
|
35
35
|
if (typeof snap !== 'object' || snap === null) return out
|
|
36
36
|
const s = snap as Record<string, unknown>
|
|
37
|
-
const isNew = s
|
|
38
|
-
|
|
39
|
-
if (isNew) {
|
|
40
|
-
const state = s.state
|
|
41
|
-
callsRaw =
|
|
42
|
-
typeof state === 'object' && state !== null
|
|
43
|
-
? (state as Record<string, unknown>).calls
|
|
44
|
-
: undefined
|
|
45
|
-
} else {
|
|
46
|
-
callsRaw = s.callCache
|
|
47
|
-
}
|
|
37
|
+
const isNew = isNewSnapshotFormat(s)
|
|
38
|
+
const callsRaw = extractCallsRaw(s, isNew)
|
|
48
39
|
if (!Array.isArray(callsRaw)) return out
|
|
49
40
|
for (const c of callsRaw) {
|
|
50
41
|
if (typeof c !== 'object' || c === null) continue
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
? co
|
|
55
|
-
: typeof co.value === 'object' && co.value !== null
|
|
56
|
-
? (co.value as Record<string, unknown>)
|
|
57
|
-
: co
|
|
58
|
-
const sf = item.sessionFile
|
|
59
|
-
if (typeof sf === 'string') {
|
|
60
|
-
out.push(sf)
|
|
61
|
-
continue
|
|
62
|
-
}
|
|
63
|
-
const result = item.result
|
|
64
|
-
const sf2 =
|
|
65
|
-
typeof result === 'object' && result !== null
|
|
66
|
-
? (result as Record<string, unknown>).sessionFile
|
|
67
|
-
: undefined
|
|
68
|
-
if (typeof sf2 === 'string') out.push(sf2)
|
|
42
|
+
const item = resolveCallItem(c as Record<string, unknown>, isNew)
|
|
43
|
+
const sf = extractSessionFileFromItem(item)
|
|
44
|
+
if (sf !== undefined) out.push(sf)
|
|
69
45
|
}
|
|
70
46
|
return out
|
|
71
47
|
}
|
|
72
48
|
|
|
49
|
+
/** 快照是否为 NEW 格式(v="wf-run-v1"/"wf-run-v2",读取面形状一致) */
|
|
50
|
+
function isNewSnapshotFormat(s: Record<string, unknown>): boolean {
|
|
51
|
+
return s.v === 'wf-run-v1' || s.v === 'wf-run-v2'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 按格式取 calls 原始集合:NEW 读 state.calls(state 守卫后取),OLD 读顶层 callCache */
|
|
55
|
+
function extractCallsRaw(s: Record<string, unknown>, isNew: boolean): unknown {
|
|
56
|
+
if (isNew) {
|
|
57
|
+
const state = s.state
|
|
58
|
+
return typeof state === 'object' && state !== null
|
|
59
|
+
? (state as Record<string, unknown>).calls
|
|
60
|
+
: undefined
|
|
61
|
+
}
|
|
62
|
+
return s.callCache
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 单条 call 归一为可取 sessionFile 的对象:
|
|
67
|
+
* NEW: call 本身;OLD: {key, value},取 value(value 非对象时回退 call 本身,容错脏数据)
|
|
68
|
+
*/
|
|
69
|
+
function resolveCallItem(co: Record<string, unknown>, isNew: boolean): Record<string, unknown> {
|
|
70
|
+
if (isNew) return co
|
|
71
|
+
const value = co.value
|
|
72
|
+
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : co
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 从归一后的 call 对象提 sessionFile:顶层优先,回退 result.sessionFile;均缺 → undefined */
|
|
76
|
+
function extractSessionFileFromItem(item: Record<string, unknown>): string | undefined {
|
|
77
|
+
const sf = item.sessionFile
|
|
78
|
+
if (typeof sf === 'string') return sf
|
|
79
|
+
const result = item.result
|
|
80
|
+
const sf2 =
|
|
81
|
+
typeof result === 'object' && result !== null
|
|
82
|
+
? (result as Record<string, unknown>).sessionFile
|
|
83
|
+
: undefined
|
|
84
|
+
if (typeof sf2 === 'string') return sf2
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
73
88
|
/**
|
|
74
89
|
* 读 wf-state 文件,从尾向头找首个 trim 非空且 JSON.parse 成功的行,返回解析后的原始对象。
|
|
75
90
|
*
|
package/src/tool-handler.ts
CHANGED
|
@@ -766,29 +766,22 @@ async function doDetail(params: SessionReadParams, agentDir: string): Promise<To
|
|
|
766
766
|
/** search action 的默认命中数上限。 */
|
|
767
767
|
const SEARCH_DEFAULT_LIMIT = 20
|
|
768
768
|
|
|
769
|
-
/** search
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
const degraded = isCatastrophicPattern(pattern)
|
|
786
|
-
const hits: Array<{
|
|
787
|
-
turnIndex: number
|
|
788
|
-
entryIndex: number
|
|
789
|
-
role: string
|
|
790
|
-
matchSnippet: string
|
|
791
|
-
}> = []
|
|
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[] = []
|
|
792
785
|
for (const t of turns) {
|
|
793
786
|
// MF-5:Esc/abort 后 pi 已丢弃本 turn 结果,尽早退出避免继续扫描长 session
|
|
794
787
|
if (signal?.aborted) {
|
|
@@ -810,14 +803,46 @@ async function doSearch(
|
|
|
810
803
|
}
|
|
811
804
|
}
|
|
812
805
|
}
|
|
813
|
-
|
|
814
|
-
|
|
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 {
|
|
815
817
|
const lines = sliced.map(
|
|
816
818
|
(h) => ` T${pad(h.turnIndex)} #${h.entryIndex} ${h.role}: ${h.matchSnippet}`,
|
|
817
819
|
)
|
|
818
|
-
|
|
820
|
+
return `${sliced.length} hit(s) for /${pattern}/${degraded ? '(已降级为字面子串匹配)' : ''}${
|
|
819
821
|
scope !== 'all' ? ' scope=' + scope : ''
|
|
820
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)
|
|
821
846
|
return { content: [{ type: 'text', text }], details: { hits: sliced, truncated } }
|
|
822
847
|
}
|
|
823
848
|
|
|
@@ -1187,8 +1212,16 @@ function extractFiles(turns: Turn[]): ToolResult {
|
|
|
1187
1212
|
/** commits 提取的 hash 前后上下文字符数(次路径关键词判定窗口)。 */
|
|
1188
1213
|
const COMMIT_CONTEXT_CHARS = 30
|
|
1189
1214
|
|
|
1190
|
-
|
|
1191
|
-
|
|
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> {
|
|
1192
1225
|
const bashCmds = new Map<string, string>()
|
|
1193
1226
|
for (const t of turns) {
|
|
1194
1227
|
for (const e of t.entries) {
|
|
@@ -1201,16 +1234,39 @@ function extractCommits(turns: Turn[]): ToolResult {
|
|
|
1201
1234
|
}
|
|
1202
1235
|
}
|
|
1203
1236
|
}
|
|
1237
|
+
return bashCmds
|
|
1238
|
+
}
|
|
1204
1239
|
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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
|
+
}
|
|
1210
1260
|
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/** 主扫描阶段:遍历 toolResult entry,分类收集高/低置信 commit 候选。 */
|
|
1264
|
+
function collectCommitCandidates(
|
|
1265
|
+
turns: Turn[],
|
|
1266
|
+
bashCmds: Map<string, string>,
|
|
1267
|
+
): { high: CommitItem[]; low: CommitItem[] } {
|
|
1211
1268
|
const high: CommitItem[] = []
|
|
1212
1269
|
const low: CommitItem[] = []
|
|
1213
|
-
|
|
1214
1270
|
for (const t of turns) {
|
|
1215
1271
|
for (const e of t.entries) {
|
|
1216
1272
|
if (e.message?.role !== 'toolResult') continue
|
|
@@ -1219,23 +1275,14 @@ function extractCommits(turns: Turn[]): ToolResult {
|
|
|
1219
1275
|
if (text === '') continue
|
|
1220
1276
|
const cmd = msg.toolCallId !== undefined ? bashCmds.get(msg.toolCallId) : undefined
|
|
1221
1277
|
const isGitBash = msg.toolName === 'bash' && cmd !== undefined && GIT_CMD_RE.test(cmd)
|
|
1222
|
-
|
|
1223
|
-
const hash = m[0]
|
|
1224
|
-
const idx = m.index ?? 0
|
|
1225
|
-
const ctx = text
|
|
1226
|
-
.slice(Math.max(0, idx - COMMIT_CONTEXT_CHARS), idx + hash.length + COMMIT_CONTEXT_CHARS)
|
|
1227
|
-
.replace(/\s+/g, ' ')
|
|
1228
|
-
.trim()
|
|
1229
|
-
if (isGitBash) {
|
|
1230
|
-
high.push({ hash, turn: t.index, source: 'git-cmd', context: ctx })
|
|
1231
|
-
} else if (COMMIT_CTX_RE.test(ctx)) {
|
|
1232
|
-
low.push({ hash, turn: t.index, source: 'commit-context', context: ctx })
|
|
1233
|
-
}
|
|
1234
|
-
}
|
|
1278
|
+
matchCommitsInResult(text, t.index, isGitBash, high, low)
|
|
1235
1279
|
}
|
|
1236
1280
|
}
|
|
1281
|
+
return { high, low }
|
|
1282
|
+
}
|
|
1237
1283
|
|
|
1238
|
-
|
|
1284
|
+
/** 收口阶段:去重,高置信优先,同 hash 保留首次。 */
|
|
1285
|
+
function dedupeCommits(high: CommitItem[], low: CommitItem[]): CommitItem[] {
|
|
1239
1286
|
const seen = new Set<string>()
|
|
1240
1287
|
const items: CommitItem[] = []
|
|
1241
1288
|
for (const c of high) {
|
|
@@ -1248,6 +1295,14 @@ function extractCommits(turns: Turn[]): ToolResult {
|
|
|
1248
1295
|
seen.add(c.hash)
|
|
1249
1296
|
items.push(c)
|
|
1250
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)
|
|
1251
1306
|
|
|
1252
1307
|
return renderExtractItems(
|
|
1253
1308
|
'commits',
|
package/vitest.config.ts
CHANGED