@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.
@@ -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.v === 'wf-run-v1' || s.v === 'wf-run-v2'
38
- let callsRaw: unknown
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 co = c as Record<string, unknown>
52
- // NEW: call 本身;OLD: {key, value},取 value
53
- const item: Record<string, unknown> = isNew
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
  *
@@ -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:session 内全文检索(design §3.4 search,M3 新实现)。 */
770
- async function doSearch(
771
- params: SessionReadParams,
772
- agentDir: string,
773
- signal?: AbortSignal,
774
- ): Promise<ToolResult> {
775
- const resolved = await resolveSessionId(params.session, 'search', agentDir, params.source)
776
- if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
777
- const pattern = requireStr(params.pattern, 'pattern', 'search')
778
- const scope = params.scope ?? 'all'
779
- const limit = params.limit ?? SEARCH_DEFAULT_LIMIT
780
- const { entries } = await safeParse(resolved.fileName)
781
- const tree = buildTreeView(entries)
782
- const turns = segmentTurns(entries, new Set(tree.leafPath))
783
- const regex = compilePattern(pattern)
784
- // S-3:启发式降级时在 header 标注,避免 LLM 把 0 hit(s) 误读为「无匹配」(静默错数据)
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
- const truncated = hits.length > limit
814
- const sliced = truncated ? hits.slice(0, limit) : hits
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
- const text = `${sliced.length} hit(s) for /${pattern}/${degraded ? '(已降级为字面子串匹配)' : ''}${
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
- function extractCommits(turns: Turn[]): ToolResult {
1191
- // 建 toolCallId → bash command 映射(用于判定 toolResult 是否来自 git 命令)
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
- type CommitItem = {
1206
- hash: string
1207
- turn: number
1208
- source: 'git-cmd' | 'commit-context'
1209
- context: string
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
- for (const m of text.matchAll(SHORT_HASH_RE)) {
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
- // 去重:高置信优先,同 hash 保留首次
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
@@ -2,6 +2,8 @@ import { defineConfig } from 'vitest/config'
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ reporters: ['default', 'junit'],
6
+ outputFile: { junit: './test-results/vitest-junit.xml' },
5
7
  include: ['src/__tests__/**/*.test.ts'],
6
8
  },
7
9
  })