@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.
@@ -4,7 +4,7 @@
4
4
  * 分层约定(同 scheduler/cw-tool):本文件零 pi 依赖——agentDir 作参数注入,
5
5
  * 不调用 getAgentDir(),可完全单测;pi 注册与 getAgentDir() 调用在 index.ts。
6
6
  *
7
- * 按 action 分发到 8 条路径,串联 M1 core(parser/tree/turns/render)+ M2 discovery
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(可能仍在运行——终态 record 在 completed/failed 后才写)。` +
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 = formatOmitted(b.omittedBytes)
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 或调大 budget]` : '',
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 ? ' ' + formatOmitted(e.omittedBytes) : ''
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,~500 token)。 */
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:session 内全文检索(design §3.4 search,M3 新实现)。 */
778
- async function doSearch(
779
- params: SessionReadParams,
780
- agentDir: string,
781
- signal?: AbortSignal,
782
- ): Promise<ToolResult> {
783
- const resolved = await resolveSessionId(params.session, 'search', agentDir, params.source)
784
- if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
785
- const pattern = requireStr(params.pattern, 'pattern', 'search')
786
- const scope = params.scope ?? 'all'
787
- const limit = params.limit ?? SEARCH_DEFAULT_LIMIT
788
- const { entries } = await safeParse(resolved.fileName)
789
- const tree = buildTreeView(entries)
790
- const turns = segmentTurns(entries, new Set(tree.leafPath))
791
- const regex = compilePattern(pattern)
792
- // S-3:启发式降级时在 header 标注,避免 LLM 把 0 hit(s) 误读为「无匹配」(静默错数据)
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
- const truncated = hits.length > limit
822
- 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 {
823
817
  const lines = sliced.map(
824
818
  (h) => ` T${pad(h.turnIndex)} #${h.entryIndex} ${h.role}: ${h.matchSnippet}`,
825
819
  )
826
- const text = `${sliced.length} hit(s) for /${pattern}/${degraded ? '(已降级为字面子串匹配)' : ''}${
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
- function extractCommits(turns: Turn[]): ToolResult {
1199
- // 建 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> {
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
- type CommitItem = {
1214
- hash: string
1215
- turn: number
1216
- source: 'git-cmd' | 'commit-context'
1217
- 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
+ }
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
- for (const m of text.matchAll(SHORT_HASH_RE)) {
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
- // 去重:高置信优先,同 hash 保留首次
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 仅含目标 session,call 引用
1389
- * 走 sessionRefFromPath 文件名最小回退(sessionId+fileName,足够 LLM 跳 outline/detail 深读)。
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 都跳过的兑底提示(ES-wf-snapshot-read-fail 末段)
1519
+ // 全部 run 都跳过的兜底提示(ES-wf-snapshot-read-fail 末段)
1472
1520
  let text: string
1473
1521
  if (runs.length === 0) {
1474
1522
  text =
@@ -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-fragment reference into the editor.',
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
@@ -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
  })