@zhushanwen/pi-session-reader 0.2.3 → 0.3.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-session-reader",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "xyz-agent": {
@@ -25,9 +25,9 @@
25
25
  "vitest.config.ts"
26
26
  ],
27
27
  "peerDependencies": {
28
- "@earendil-works/pi-coding-agent": "^0.84.1",
29
- "@earendil-works/pi-ai": "^0.84.1",
30
- "@earendil-works/pi-tui": "^0.84.1",
28
+ "@earendil-works/pi-coding-agent": "^0.84.4",
29
+ "@earendil-works/pi-ai": "^0.84.4",
30
+ "@earendil-works/pi-tui": "^0.84.4",
31
31
  "typebox": "*"
32
32
  },
33
33
  "peerDependenciesMeta": {
@@ -93,7 +93,7 @@ describe('renderOutline', () => {
93
93
  })
94
94
 
95
95
  it('2a. 降级:单行 toolSummary 过长超 perTurnBudget → 砍 toolSummary,保 userBrief 骨架', () => {
96
- // L1 行不含 assistantBrief(design §3.5 算法1 step2);budget=40, 3 turns → perTurnCharBudget≈53
96
+ // 预算紧张降级后 L1 行不含 assistantBrief(预算充足 level 0 行含);budget=40, 3 turns → perTurnCharBudget≈53
97
97
  // 行 = head + userBrief(10) + toolSummary(20 个工具名≈63 chars) → ~80 > 53 → 砍 toolSummary
98
98
  const toolCalls = Array.from({ length: 20 }, (_, k) => ({ name: `t${String(k).padStart(2, '0')}` }))
99
99
  const turns = [0, 1, 2].map((i) =>
@@ -183,7 +183,7 @@ describe('renderOutline', () => {
183
183
  // stats 完整性
184
184
  expect(result.stats.totalTurns).toBe(32)
185
185
  // totalEntries 近似(leaf+branch+orphan)不含 session header(segmentTurns 规则1 跳过);
186
- // 准确值由 M2 工具层用 ParseResult.totalEntries 覆盖。M1 验量级。
186
+ // 工具层仅覆盖 stats.totalBytes skippedLines(ParseResulttotalEntries 字段)。M1 验量级。
187
187
  expect(result.stats.totalEntries).toBeGreaterThan(1000)
188
188
  // 5.6MB 全量解析在并发/高负载下可能超 vitest 默认 5s,显式放宽
189
189
  }, 60000)
@@ -22,7 +22,7 @@ import {
22
22
  * 测试框架 vitest(禁止 node:test/tsx)。直接调 handleSessionRead(纯逻辑,agentDir 注入),
23
23
  * 传真实 `/Users/zhushanwen/.pi/agent` 作 agentDir——用本机真实历史 session 数据,无需 mock。
24
24
  *
25
- * 覆盖 7 action 主路径 + F1(find 零匹配)/F4(turn 越界)/F5(缺参)/resolveSessionId 片段等价。
25
+ * 覆盖 9 action 主路径 + F1(find 零匹配)/F4(turn 越界)/F5(缺参)/resolveSessionId 片段等价。
26
26
  *
27
27
  * 真实数据用例全部带 skipIf 守卫(CI 无本机 ~/.pi/agent → skip,不硬失败);
28
28
  * renderExtractItems F9 截断是纯 fixture,无条件跑。
@@ -782,7 +782,7 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
782
782
  await handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, dir)
783
783
  } catch (e) {
784
784
  const msg = (e as Error).message
785
- expect(msg).toContain('可能仍在运行')
785
+ expect(msg).toContain('可能尚未落盘')
786
786
  expect(msg).toContain('action:"family"')
787
787
  expect(msg).toContain('👉')
788
788
  }
@@ -173,7 +173,6 @@ function collectRelatedRecords(
173
173
  // buildExecutionTree(IF3 核心)
174
174
  // ============================================================
175
175
 
176
- /** 建树过程的共享可变状态(避免递归函数参数爆炸,封装为 context)。 */
177
176
  /** 建树过程的共享可变状态(避免递归函数参数爆炸,封装为 context)。 */
178
177
  interface BuildContext {
179
178
  rootSessionId: string
@@ -11,7 +11,7 @@ import type { Entry } from './parser.js'
11
11
  * - SessionRef.mtime/sizeBytes:从 fileStats 取(M1 key=sessionId),取不到为 0
12
12
  * - SubagentRef.sessionId:identity entry 不含 subagent session 的 id → 占位用 entry.id
13
13
  * - SubagentRef.cwd:identity entry(custom 类型)无 cwd → 占位空串
14
- * - fileStats 的 key:M1 用 sessionId(M2 改用真实文件路径),cleanedUp 逻辑 M1/M2 通用
14
+ * - fileStats 的 key:sessionId(M2 实现沿用此 key,cleanedUp sessionId 命中),逻辑通用
15
15
  *
16
16
  * 隔代关联规则(design §3.3 D-7 Q1)见 resolveFamily 注释。
17
17
  */
@@ -24,7 +24,6 @@ export interface SessionRef {
24
24
  cwd: string
25
25
  /** fork 文件指向来源的路径(来自 header 的 parentSession,原始文件路径字符串) */
26
26
  parentSession?: string
27
- name?: string
28
27
  }
29
28
 
30
29
  export interface SubagentRef extends SessionRef {
@@ -194,7 +193,7 @@ export function buildFamilyIndex(
194
193
  mtime: stat?.mtime ?? 0,
195
194
  sizeBytes: stat?.size ?? 0,
196
195
  cwd: '', // identity entry 无 cwd;M2 从 subagent 文件 header 补
197
- // M1: fileStats key=sessionId;M2 改用 subagent 真实文件路径(SubagentRef.fileName)查 fileStats
196
+ // fileStats key=sessionId(buildFamilyFromFs header.id 写入),cleanedUp 按 sessionId 命中
198
197
  cleanedUp: !fileStats.has(ident.id),
199
198
  // U4 富字段:从 identity data 读(manifest 主/P-fallback 由 buildFamilyFromFs 组装时决定)
200
199
  task: ident.data.task,
@@ -3,6 +3,35 @@ import { extractToolCalls, formatToolCallSummary, type ToolCallInfo } from './to
3
3
  import type { Turn } from './turns.js'
4
4
  import type { TreeView } from './tree.js'
5
5
 
6
+ // ---------------------------------------------------------------------------
7
+ // 模块常量(渲染口径:截断宽度 / 换算基数 / 降级档位)
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /** bytes→KB 换算基数(omitted 字节 / read 结果规模的 KB 显示)。 */
11
+ const BYTES_PER_KB = 1024
12
+ /** userBrief 截断字符数(TurnBrief.userBrief 文档口径「截 60 字符」)。 */
13
+ const USER_BRIEF_MAX_CHARS = 60
14
+ /** assistantBrief 截断字符数(TurnBrief.assistantBrief 文档口径「截 80 字符」)。 */
15
+ const ASSISTANT_BRIEF_MAX_CHARS = 80
16
+ /** L2 expand 单 entry brief 截断字符数。 */
17
+ const ENTRY_BRIEF_MAX_CHARS = 100
18
+ /** turn 索引显示宽度(T013 三位补零)。 */
19
+ const TURN_INDEX_WIDTH = 3
20
+ /** outline 默认 token 预算(OutlineOptions.budget 缺省值)。 */
21
+ const OUTLINE_DEFAULT_BUDGET_TOKENS = 2000
22
+ /** token 估算换算基数(chars/4,与 tokenEstimate 口径一致)。 */
23
+ const CHARS_PER_TOKEN = 4
24
+ /** toolResult 摘要头行数(O3 摘要态展示前 N 行)。 */
25
+ const SUMMARY_HEAD_LINE_COUNT = 3
26
+ /** toolResult 摘要单头行截断字符数。 */
27
+ const SUMMARY_HEAD_LINE_MAX_CHARS = 80
28
+
29
+ /** 行渲染降级档位(design §3.5 算法 1 step3):0=全有 / 1=砍 assistantBrief / 2=再砍 toolSummary(骨架)。 */
30
+ const LINE_LEVEL_FULL = 0
31
+ const LINE_LEVEL_NO_ASSISTANT = 1
32
+ const LINE_LEVEL_SKELETON = 2
33
+ type LineLevel = typeof LINE_LEVEL_FULL | typeof LINE_LEVEL_NO_ASSISTANT | typeof LINE_LEVEL_SKELETON
34
+
6
35
  /**
7
36
  * L1 outline 的单行 turn 摘要(design §3.5 算法 1 的渲染单元,冻结接口)。
8
37
  *
@@ -163,7 +192,7 @@ function formatToolResultSummary(
163
192
  }
164
193
  if (toolName === 'read') {
165
194
  if (resultText === '') return base
166
- const kb = Math.max(1, Math.round(utf8Bytes(resultText) / 1024))
195
+ const kb = Math.max(1, Math.round(utf8Bytes(resultText) / BYTES_PER_KB))
167
196
  return `${base} (${kb}KB)`
168
197
  }
169
198
  return base
@@ -216,7 +245,7 @@ function computeBrief(turn: Turn): TurnBrief {
216
245
  if (turn.isCompaction) {
217
246
  const first = turn.entries[0]
218
247
  const summaryStr = typeof first?.summary === 'string' ? first.summary : ''
219
- userBrief = truncate('[compaction] ' + summaryStr, 60)
248
+ userBrief = truncate('[compaction] ' + summaryStr, USER_BRIEF_MAX_CHARS)
220
249
  } else {
221
250
  let userText = ''
222
251
  for (const entry of turn.entries) {
@@ -225,7 +254,7 @@ function computeBrief(turn: Turn): TurnBrief {
225
254
  userText += extractText(msg.content)
226
255
  }
227
256
  }
228
- userBrief = truncate(userText, 60)
257
+ userBrief = truncate(userText, USER_BRIEF_MAX_CHARS)
229
258
  }
230
259
 
231
260
  return {
@@ -233,7 +262,7 @@ function computeBrief(turn: Turn): TurnBrief {
233
262
  startTime: turn.startTime,
234
263
  userBrief,
235
264
  toolSummary: formatToolSummary(toolCounts),
236
- assistantBrief: truncate(assistantText, 80),
265
+ assistantBrief: truncate(assistantText, ASSISTANT_BRIEF_MAX_CHARS),
237
266
  omittedBytes: omitted,
238
267
  }
239
268
  }
@@ -256,10 +285,10 @@ function formatHHMM(timestamp?: string): string {
256
285
  return m ? `${m[1]}:${m[2]}` : ''
257
286
  }
258
287
 
259
- function formatBytesMarker(bytes: number): string {
288
+ export function formatBytesMarker(bytes: number): string {
260
289
  if (bytes <= 0) return ''
261
- if (bytes < 1024) return `[${bytes}B omitted]`
262
- return `[${Math.round(bytes / 1024)}KB omitted]`
290
+ if (bytes < BYTES_PER_KB) return `[${bytes}B omitted]`
291
+ return `[${Math.round(bytes / BYTES_PER_KB)}KB omitted]`
263
292
  }
264
293
 
265
294
  /**
@@ -268,14 +297,14 @@ function formatBytesMarker(bytes: number): string {
268
297
  * level: 0=全有(toolSummary + assistantBrief)/ 1=砍 assistantBrief / 2=再砍 toolSummary(骨架)。
269
298
  * userBrief + omittedBytes 骨架永保。assistantBrief 格式 `→ <结论>`,在 toolSummary 后、omitted 前。
270
299
  */
271
- function formatLine(b: TurnBrief, level: 0 | 1 | 2, branchSize?: number): string {
300
+ function formatLine(b: TurnBrief, level: LineLevel, branchSize?: number): string {
272
301
  const parts: string[] = []
273
- const head = `T${String(b.index).padStart(3, '0')}`
302
+ const head = `T${String(b.index).padStart(TURN_INDEX_WIDTH, '0')}`
274
303
  const time = formatHHMM(b.startTime)
275
304
  parts.push(time ? `${head} ${time}` : head)
276
305
  if (b.userBrief) parts.push(b.userBrief)
277
- if (level <= 1 && b.toolSummary) parts.push(b.toolSummary)
278
- if (level <= 0 && b.assistantBrief) parts.push(`→ ${b.assistantBrief}`)
306
+ if (level <= LINE_LEVEL_NO_ASSISTANT && b.toolSummary) parts.push(b.toolSummary)
307
+ if (level <= LINE_LEVEL_FULL && b.assistantBrief) parts.push(`→ ${b.assistantBrief}`)
279
308
  const marker = formatBytesMarker(b.omittedBytes)
280
309
  if (marker) parts.push(marker)
281
310
  if (branchSize !== undefined && branchSize > 0) parts.push(`[旁支 ${branchSize} entries]`)
@@ -306,7 +335,7 @@ export function renderOutline(
306
335
  tree: TreeView,
307
336
  options?: OutlineOptions,
308
337
  ): OutlineResult {
309
- const budget = options?.budget ?? 2000
338
+ const budget = options?.budget ?? OUTLINE_DEFAULT_BUDGET_TOKENS
310
339
  const allBranches = options?.allBranches ?? false
311
340
  const granularity = options?.granularity ?? 'turn'
312
341
 
@@ -344,20 +373,20 @@ export function renderOutline(
344
373
  }
345
374
 
346
375
  // 2. perTurnBudget(token → chars×4)
347
- const perTurnCharBudget = (budget / turns.length) * 4
376
+ const perTurnCharBudget = (budget / turns.length) * CHARS_PER_TOKEN
348
377
 
349
378
  // 3. 降级序:level 0 全有;超预算降到 level 1 砍 assistantBrief;仍超降到 level 2 砍 toolSummary(骨架)。design §3.5 算法 1 step3
350
379
  const lineCache: string[] = []
351
380
  for (const b of briefs) {
352
381
  const branchSize = b.branch !== undefined ? tree.branches.get(b.branch) : undefined
353
- let line = formatLine(b, 0, branchSize)
382
+ let line = formatLine(b, LINE_LEVEL_FULL, branchSize)
354
383
  if (line.length > perTurnCharBudget) {
355
384
  // 超预算:先砍 assistantBrief(level 1),仍超再砍 toolSummary(level 2,骨架)
356
385
  b.assistantBrief = ''
357
- line = formatLine(b, 1, branchSize)
386
+ line = formatLine(b, LINE_LEVEL_NO_ASSISTANT, branchSize)
358
387
  if (line.length > perTurnCharBudget) {
359
388
  b.toolSummary = ''
360
- line = formatLine(b, 2, branchSize)
389
+ line = formatLine(b, LINE_LEVEL_SKELETON, branchSize)
361
390
  }
362
391
  }
363
392
  lineCache.push(line)
@@ -366,9 +395,9 @@ export function renderOutline(
366
395
  // 4. 总预算截断(从尾部丢弃)
367
396
  let totalChars = lineCache.reduce((s, l) => s + l.length, 0)
368
397
  let truncated: number | undefined
369
- if (totalChars / 4 > budget) {
398
+ if (totalChars / CHARS_PER_TOKEN > budget) {
370
399
  let kept = lineCache.length
371
- while (kept > 0 && totalChars / 4 > budget) {
400
+ while (kept > 0 && totalChars / CHARS_PER_TOKEN > budget) {
372
401
  kept--
373
402
  totalChars -= lineCache[kept].length
374
403
  }
@@ -377,7 +406,7 @@ export function renderOutline(
377
406
  briefs.length = kept
378
407
  }
379
408
 
380
- const tokenEstimate = Math.ceil(totalChars / 4)
409
+ const tokenEstimate = Math.ceil(totalChars / CHARS_PER_TOKEN)
381
410
  return { turns: briefs, stats, tokenEstimate, truncated }
382
411
  }
383
412
 
@@ -405,7 +434,7 @@ function renderEntryGranularity(
405
434
  const b: TurnBrief = {
406
435
  index: entryIdx++,
407
436
  startTime: e.timestamp,
408
- userBrief: truncate(text, 60) || `[${e.type}]`,
437
+ userBrief: truncate(text, USER_BRIEF_MAX_CHARS) || `[${e.type}]`,
409
438
  toolSummary: formatToolSummary(counts),
410
439
  assistantBrief: '',
411
440
  omittedBytes: entryOmittedBytes(e),
@@ -416,13 +445,13 @@ function renderEntryGranularity(
416
445
  }
417
446
 
418
447
  const lines = briefs.map((b) =>
419
- formatLine(b, 0, b.branch !== undefined ? tree.branches.get(b.branch) : undefined),
448
+ formatLine(b, LINE_LEVEL_FULL, b.branch !== undefined ? tree.branches.get(b.branch) : undefined),
420
449
  )
421
450
  let totalChars = lines.reduce((s, l) => s + l.length, 0)
422
451
  let truncated: number | undefined
423
- if (totalChars / 4 > budget) {
452
+ if (totalChars / CHARS_PER_TOKEN > budget) {
424
453
  let kept = lines.length
425
- while (kept > 0 && totalChars / 4 > budget) {
454
+ while (kept > 0 && totalChars / CHARS_PER_TOKEN > budget) {
426
455
  kept--
427
456
  totalChars -= lines[kept].length
428
457
  }
@@ -433,7 +462,7 @@ function renderEntryGranularity(
433
462
  return {
434
463
  turns: briefs,
435
464
  stats,
436
- tokenEstimate: Math.ceil(totalChars / 4),
465
+ tokenEstimate: Math.ceil(totalChars / CHARS_PER_TOKEN),
437
466
  truncated,
438
467
  }
439
468
  }
@@ -459,7 +488,7 @@ function entryBrief(e: Entry, tcMap?: Map<string, ToolCallInfo>): string {
459
488
  const msg = e.message
460
489
  if (msg !== undefined) {
461
490
  if (msg.role === 'user' || msg.role === 'assistant') {
462
- return truncate(extractText(msg.content), 100) || `[${msg.role}]`
491
+ return truncate(extractText(msg.content), ENTRY_BRIEF_MAX_CHARS) || `[${msg.role}]`
463
492
  }
464
493
  if (msg.role === 'toolResult') {
465
494
  const toolCallId = msg.toolCallId
@@ -469,7 +498,7 @@ function entryBrief(e: Entry, tcMap?: Map<string, ToolCallInfo>): string {
469
498
  }
470
499
  if (e.type === 'compaction') {
471
500
  const s = typeof e.summary === 'string' ? e.summary : '[compaction]'
472
- return truncate(s, 100)
501
+ return truncate(s, ENTRY_BRIEF_MAX_CHARS)
473
502
  }
474
503
  if (e.type === 'custom') return `[custom:${e.customType ?? 'unknown'}]`
475
504
  return `[${e.type}]`
@@ -479,7 +508,7 @@ export function renderExpand(turn: Turn): {
479
508
  turn: string
480
509
  entries: EntryBrief[]
481
510
  } {
482
- const head = `T${String(turn.index).padStart(3, '0')}`
511
+ const head = `T${String(turn.index).padStart(TURN_INDEX_WIDTH, '0')}`
483
512
  const bits = [`${turn.entries.length} entries`]
484
513
  if (turn.startTime !== undefined) bits.push(`started ${formatHHMM(turn.startTime)}`)
485
514
  if (turn.isCompaction) bits.push('compaction')
@@ -559,7 +588,7 @@ export function renderDetail(
559
588
  // 空文本口径与 formatToolResultSummary 一致:空结果 = 0 行
560
589
  //(''.split('\n') 返 [''] length=1,会与 summary 的 (0行) 自相矛盾)
561
590
  const lines = text === '' ? [] : text.split('\n')
562
- const headLines = lines.slice(0, 3).map((l) => truncate(l, 80)).join(' | ')
591
+ const headLines = lines.slice(0, SUMMARY_HEAD_LINE_COUNT).map((l) => truncate(l, SUMMARY_HEAD_LINE_MAX_CHARS)).join(' | ')
563
592
  out.push({
564
593
  type: 'toolResultSummary',
565
594
  id: e.id,
@@ -10,6 +10,19 @@
10
10
  */
11
11
  import type { Entry } from './parser.js'
12
12
 
13
+ // ---------------------------------------------------------------------------
14
+ // 模块常量(参数摘要的截断宽度 / 换算基数)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** bash 命令参数摘要截断字符数。 */
18
+ const BASH_CMD_MAX_CHARS = 60
19
+ /** subagent task 参数摘要截断字符数。 */
20
+ const SUBAGENT_TASK_MAX_CHARS = 40
21
+ /** 未知工具 arguments JSON 摘要截断字符数。 */
22
+ const ARGS_JSON_MAX_CHARS = 50
23
+ /** bytes→KB 换算基数(write content 的 KB 显示)。 */
24
+ const BYTES_PER_KB = 1024
25
+
13
26
  /** 单次工具调用信息(从 assistant content 的 toolCall block 提取)。 */
14
27
  export interface ToolCallInfo {
15
28
  id: string
@@ -45,8 +58,10 @@ function coerceArgs(raw: unknown): Record<string, unknown> {
45
58
  if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
46
59
  return parsed as Record<string, unknown>
47
60
  }
48
- } catch {
49
- // 非法 JSON 字符串 → 空对象兜底(实测 arguments 全为 object,此分支纯防御)
61
+ } catch (err) {
62
+ // 非法 JSON 字符串 → 空对象兜底(实测 arguments 全为 object,此分支纯防御);
63
+ // 共享层保持零依赖(无 logger 可用),不留 void err 以外的语句
64
+ void err
50
65
  }
51
66
  }
52
67
  return {}
@@ -103,7 +118,7 @@ export function formatToolCallSummary(tc: ToolCallInfo): string {
103
118
  switch (name) {
104
119
  case 'bash': {
105
120
  const cmd = strArg(args, 'command')
106
- return cmd !== undefined ? `bash: ${truncate(cmd, 60)}` : 'bash'
121
+ return cmd !== undefined ? `bash: ${truncate(cmd, BASH_CMD_MAX_CHARS)}` : 'bash'
107
122
  }
108
123
  case 'read': {
109
124
  const p = strArg(args, 'path')
@@ -122,12 +137,12 @@ export function formatToolCallSummary(tc: ToolCallInfo): string {
122
137
  const head = p !== undefined ? `write: ${basename(p)}` : 'write'
123
138
  if (c === undefined) return head
124
139
  // utf8 字节转 KB 取整;小文件至少 1KB(0KB 无信息量)
125
- const kb = Math.max(1, Math.round(Buffer.byteLength(c, 'utf8') / 1024))
140
+ const kb = Math.max(1, Math.round(Buffer.byteLength(c, 'utf8') / BYTES_PER_KB))
126
141
  return `${head} (${kb}KB)`
127
142
  }
128
143
  case 'subagent': {
129
144
  const task = strArg(args, 'task')
130
- return task !== undefined ? `subagent: ${truncate(task, 40)}` : 'subagent'
145
+ return task !== undefined ? `subagent: ${truncate(task, SUBAGENT_TASK_MAX_CHARS)}` : 'subagent'
131
146
  }
132
147
  case 'head': {
133
148
  const p = strArg(args, 'path')
@@ -159,7 +174,7 @@ export function formatToolCallSummary(tc: ToolCallInfo): string {
159
174
  // 未知工具:arguments 非空 → name: <json 前50>;空对象 → 仅 name({} 无信息量)
160
175
  if (Object.keys(args).length === 0) return name
161
176
  try {
162
- return `${name}: ${truncate(JSON.stringify(args), 50)}`
177
+ return `${name}: ${truncate(JSON.stringify(args), ARGS_JSON_MAX_CHARS)}`
163
178
  } catch {
164
179
  return name
165
180
  }
@@ -62,8 +62,10 @@ async function scanJsonlRecursive(
62
62
  try {
63
63
  const s = await stat(full)
64
64
  results.push({ path: full, mtime: s.mtimeMs, size: s.size })
65
- } catch {
66
- // 文件并发删除等致 stat 失败 → 跳过(不中断整体扫描)
65
+ } catch (err) {
66
+ // 文件并发删除等致 stat 失败 → 跳过(不中断整体扫描);
67
+ // 本模块零 pi 依赖(无 logger 可用),不留 void err 以外的语句
68
+ void err
67
69
  }
68
70
  }
69
71
  }
@@ -23,7 +23,8 @@ import { resolveWorkflows } from './workflows.js'
23
23
  * 是 records/<sa-id>.json manifest(subagent 创建时写入,持久存在)。故 manifest 是孤儿/
24
24
  * cleanedUp 的来源(design §3.3 D-7 "records/*.json manifest 作孤儿补充")。
25
25
  * - workflows:M1 resolveFamily 恒返回 []。M2 在此单独读目标 session 的 workflow-state-link
26
- * custom entry → link.data.path(wf-state 文件绝对路径)→ 读该文件最后一行(最新快照)取 calls。
26
+ * custom entry → link.data.path(wf-state 文件绝对路径)→ 从文件尾向头找首个非空且
27
+ * JSON.parse 成功的行(最新快照,容错尾半截 JSON)取 calls。
27
28
  *
28
29
  * @throws sessionId 不在任意 main session header → Error(M3 tool-adapter 层转 F1 恢复指引)
29
30
  */
@@ -87,7 +88,7 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
87
88
  // manifest 主(TC-u4-manifest-enrich):透 task/slug/model/status/sessionFile 全字段
88
89
  data = {
89
90
  rootSessionId: manifest.rootSessionId,
90
- slug: manifest.slug ?? '', // slug 兼容旧 manifest(缺→空串兑底,m0 契约)
91
+ slug: manifest.slug ?? '', // slug 兼容旧 manifest(缺→空串兜底,m0 契约)
91
92
  task: manifest.task,
92
93
  agent: manifest.agentName, // 同语义异名:manifest.agentName ↔ identity.data.agent
93
94
  model: manifest.model,
@@ -163,7 +164,7 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
163
164
  const family = resolveFamily(sessionId, index)
164
165
 
165
166
  // ---- 6. 补 M1 占位字段(fileName / subagent cwd)+ workflows ----
166
- enrichRefs(family, sessionIdToPath, pathToRef)
167
+ enrichRefs(family, pathToRef)
167
168
  family.workflows = await resolveWorkflows(sessionId, sessionIdToPath, pathToRef)
168
169
 
169
170
  return family
@@ -330,11 +331,6 @@ function isRecordManifest(v: unknown): v is RecordManifest {
330
331
  )
331
332
  }
332
333
 
333
- /**
334
- * 扫描 subagents/<cwdSlug>/records/*.json —— subagent 注册清单。
335
- * 每个 manifest 在 subagent 创建时写入,持久存在即使 .jsonl 被 GC。坏 manifest(缺必填字段
336
- * /JSON 损坏)跳过,不中断扫描。
337
- */
338
334
  /** 读单个 manifest 文件并校验;坏 manifest(JSON 损坏/缺必填字段)返回 undefined。 */
339
335
  async function tryReadManifest(path: string): Promise<RecordManifest | undefined> {
340
336
  try {
@@ -345,6 +341,11 @@ async function tryReadManifest(path: string): Promise<RecordManifest | undefined
345
341
  }
346
342
  }
347
343
 
344
+ /**
345
+ * 扫描 subagents/<cwdSlug>/records/*.json —— subagent 注册清单。
346
+ * 每个 manifest 在 subagent 创建时写入,持久存在即使 .jsonl 被 GC。坏 manifest(缺必填字段
347
+ * /JSON 损坏)跳过,不中断扫描。
348
+ */
348
349
  export async function listRecordManifests(agentDir: string): Promise<RecordManifest[]> {
349
350
  const root = join(agentDir, 'subagents')
350
351
  const out: RecordManifest[] = []
@@ -395,11 +396,7 @@ export function extractSessionIdFromFilename(name: string): string {
395
396
  *(identity 无 cwd)。此处用已扫描的真实文件信息补全:alive 的 ref 补 fileName + cwd;
396
397
  * cleanedUp 孤儿(无文件)保持占位。
397
398
  */
398
- function enrichRefs(
399
- family: Family,
400
- sessionIdToPath: Map<string, string>,
401
- pathToRef: Map<string, SessionRef>,
402
- ): void {
399
+ function enrichRefs(family: Family, pathToRef: Map<string, SessionRef>): void {
403
400
  // sessionId → 完整 ref(含真实 fileName/cwd),由 pathToRef 反建
404
401
  const bySid = new Map<string, SessionRef>()
405
402
  for (const ref of pathToRef.values()) bySid.set(ref.sessionId, ref)
@@ -426,6 +423,4 @@ function enrichRefs(
426
423
  cwd: full.cwd || s.cwd,
427
424
  } as SubagentRef
428
425
  })
429
- // sessionIdToPath 仅用于类型完整性占位引用,避免未用警告(实际路径信息已在 pathToRef)
430
- void sessionIdToPath
431
426
  }
package/src/index.ts CHANGED
@@ -15,8 +15,6 @@ import { createSessionCommand } from './tui/session-command.js'
15
15
  * (错误直接 throw 给 pi——pi-agent-core agent-loop 只对 execute throw 置
16
16
  * isError:true,返回值里的 isError 字段被丢弃;W4 修复,锚点
17
17
  * agent-loop.js:453-483/525-547,pi 自带 bash 工具同范式)
18
- *
19
- * M4 将在此 addAutocompleteProvider(TUI # 补全,ctx.mode === 'tui' 时)。
20
18
  */
21
19
 
22
20
  // ---- TypeBox 参数 schema(design §3.4 14 字段)----
@@ -32,7 +30,7 @@ const SessionReadSchema = Type.Object({
32
30
  session: Type.Optional(
33
31
  Type.String({
34
32
  description:
35
- 'Session id, uuid fragment (e.g. e6c96), subagent record id (sa-xxx, precise lookup), or absolute .jsonl path (~ or ~/ allowed). Required for family/outline/expand/detail/search/export/workflow. # prefix auto-stripped.',
33
+ 'Session id, uuid fragment (e.g. e6c96), subagent record id (sa-xxx, precise lookup), or absolute .jsonl path (~ or ~/ allowed). Required for family/outline/expand/detail/search/export/extract/workflow. # prefix auto-stripped.',
36
34
  }),
37
35
  ),
38
36
  query: Type.Optional(
@@ -43,7 +41,7 @@ const SessionReadSchema = Type.Object({
43
41
  ),
44
42
  turns: Type.Optional(
45
43
  Type.String({
46
- description: 'detail action: turn range, "T013-T015" or "T013".',
44
+ description: 'detail/extract action: turn range, "T013-T015" or "T013".',
47
45
  }),
48
46
  ),
49
47
  turn: Type.Optional(
@@ -74,7 +72,8 @@ const SessionReadSchema = Type.Object({
74
72
  ),
75
73
  allBranches: Type.Optional(
76
74
  Type.Boolean({
77
- description: 'outline/family: include abandoned side-branches. Default false.',
75
+ description:
76
+ "outline (and export's outline section): include abandoned side-branches. Not supported by family. Default false.",
78
77
  }),
79
78
  ),
80
79
  granularity: Type.Optional(
@@ -87,7 +86,8 @@ const SessionReadSchema = Type.Object({
87
86
  ),
88
87
  source: Type.Optional(
89
88
  StringEnum(['main', 'subagent'], {
90
- description: 'find action: filter by source. "main" = sessions/, "subagent" = subagents/. Default both (merged).',
89
+ description:
90
+ 'find and session-resolving actions: filter by source. "main" = sessions/, "subagent" = subagents/. Default both (merged).',
91
91
  }),
92
92
  ),
93
93
  limit: Type.Optional(
@@ -123,8 +123,8 @@ const SessionReadSchema = Type.Object({
123
123
  // ---- guidelines(注入 LLM,design §3.4)----
124
124
 
125
125
  const guidelines = [
126
- 'Progressive reading: outline (~500 token overview) → expand (one turn) → detail (full text). Default omits toolResult/thinking noise.',
127
- 'find first to locate a session by uuid fragment or name. TUI #references are uuid fragments.',
126
+ 'Progressive reading: outline (~1500 token overview) → expand (one turn) → detail (full text). Default omits toolResult/thinking noise.',
127
+ 'find first to locate a session by uuid fragment or name. TUI #references are full uuids.',
128
128
  'outline before detail. Never read raw .jsonl files—use this tool.',
129
129
  'family traces fork parents/children, subagent sessions, and workflow runs.',
130
130
  'extract what=<type> to pull user messages / commands / files / commits / tool results across turns (optional tool= filter for commands/tool-results).',
@@ -134,7 +134,7 @@ const guidelines = [
134
134
 
135
135
  // ---- 工具 description(design §3.4,照搬措辞)----
136
136
 
137
- const description = `Read pi session files (conversation history) by semantic structure instead of raw bytes. Use when you need to review another session, trace a fork/subagent/workflow family, or locate a past decision. Nine actions: find (locate by name/uuid fragment), family (fork/subagent/workflow relations), outline (turn-level overview, ~500 token), expand (single-turn entry list), detail (full text of turns), search (full-text grep across a session), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type), workflow (workflow run overview: status/budget/steps, step call sessionId jumps to outline/detail). Progressive reading: outline → expand → detail. Do NOT use for the current session (use get_messages) or to edit sessions (pi has /resume /fork).`
137
+ const description = `Read pi session files (conversation history) by semantic structure instead of raw bytes. Use when you need to review another session, trace a fork/subagent/workflow family, or locate a past decision. Nine actions: find (locate by name/uuid fragment), family (fork/subagent/workflow relations), outline (turn-level overview, ~1500 token), expand (single-turn entry list), detail (full text of turns), search (full-text grep across a session), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type), workflow (workflow run overview: status/budget/steps, step call sessionId jumps to outline/detail). Progressive reading: outline → expand → detail. Do NOT use for the current session (the host provides current-session access) or to edit sessions (pi has /resume /fork).`
138
138
 
139
139
  /**
140
140
  * 已注册过 TUI provider/command 的 pi 实例集合。
@@ -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,
@@ -91,7 +92,10 @@ export interface ToolResult {
91
92
  // 小工具
92
93
  // ---------------------------------------------------------------------------
93
94
 
94
- const pad = (n: number): string => String(n).padStart(3, '0')
95
+ /** turn 索引显示宽度(T013 三位补零)。 */
96
+ const TURN_INDEX_WIDTH = 3
97
+
98
+ const pad = (n: number): string => String(n).padStart(TURN_INDEX_WIDTH, '0')
95
99
 
96
100
  /** 构造带 👉 恢复指引的 Error(handler 抛出,由 execute 闭包 catch)。 */
97
101
  function err(message: string): Error {
@@ -103,24 +107,24 @@ function stripHash(s: string): string {
103
107
  return s.replace(/^#+/, '')
104
108
  }
105
109
 
110
+ /** formatDate 日期段(月/日)补零宽度。 */
111
+ const DATE_FIELD_WIDTH = 2
112
+
106
113
  function formatDate(ms: number): string {
107
114
  if (!ms) return ''
108
115
  const d = new Date(ms)
109
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
116
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(DATE_FIELD_WIDTH, '0')}-${String(
110
117
  d.getDate(),
111
- ).padStart(2, '0')}`
118
+ ).padStart(DATE_FIELD_WIDTH, '0')}`
112
119
  }
113
120
 
121
+ /** shortCwd 保留的目录末段数。 */
122
+ const SHORT_CWD_SEGMENTS = 2
123
+
114
124
  /** cwd 取末两段缩短显示(完整 cwd 在 details 里)。 */
115
125
  function shortCwd(cwd: string): string {
116
126
  const parts = cwd.split('/').filter(Boolean)
117
- return parts.slice(-2).join('/')
118
- }
119
-
120
- function formatOmitted(bytes: number): string {
121
- if (bytes <= 0) return ''
122
- if (bytes < 1024) return `[${bytes}B omitted]`
123
- return `[${Math.round(bytes / 1024)}KB omitted]`
127
+ return parts.slice(-SHORT_CWD_SEGMENTS).join('/')
124
128
  }
125
129
 
126
130
  /** F5 必填参数校验。 */
@@ -151,13 +155,16 @@ function parseTurnIndex(raw: string): number {
151
155
  return parseInt(m[1], 10)
152
156
  }
153
157
 
158
+ /** turns 范围 "T013-T015" 的段数。 */
159
+ const TURNS_RANGE_PARTS = 2
160
+
154
161
  function parseTurnsRange(raw: string): { start: number; end: number } {
155
162
  const parts = raw.split('-').map((s) => s.trim())
156
163
  if (parts.length === 1) {
157
164
  const i = parseTurnIndex(parts[0])
158
165
  return { start: i, end: i }
159
166
  }
160
- if (parts.length === 2) {
167
+ if (parts.length === TURNS_RANGE_PARTS) {
161
168
  const start = parseTurnIndex(parts[0])
162
169
  const end = parseTurnIndex(parts[1])
163
170
  if (end < start) {
@@ -332,7 +339,7 @@ function formatSessionGc(record: RecordManifest): string {
332
339
  /** ES2(SA_ID_NO_MATCH):sa-id 无精确匹配(可能仍在运行 / 片段输入)。 */
333
340
  function formatSaIdNotFound(saId: string): string {
334
341
  return (
335
- `subagent "${saId}" 无匹配 record(可能仍在运行——终态 record completed/failed 后才写)。` +
342
+ `subagent "${saId}" 无匹配 record(若刚启动,record 可能尚未落盘)。` +
336
343
  `\n👉 用 session_read { action:"family" } 查活跃/已完成的 subagent;` +
337
344
  `若是片段输入,请用完整 sa- id 或 action:"find" 重试。`
338
345
  )
@@ -349,10 +356,15 @@ function formatSaIdAmbiguous(saId: string, records: RecordManifest[]): string {
349
356
  )
350
357
  }
351
358
 
359
+ /** sessionId 列表行内的短显前缀长度。 */
360
+ const SESSION_ID_PREFIX_LEN = 8
361
+ /** 消歧提示的 uuid 片段长度(比短显略长,引导输入更长片段消歧)。 */
362
+ const HINT_ID_PREFIX_LEN = 12
363
+
352
364
  /** F1 无匹配 message(含最近 10 + 👉)。 */
353
365
  function formatNoMatch(query: string, recent: MatchedSession[]): string {
354
366
  const lines: string[] = recent.length
355
- ? recent.map((m, i) => ` ${i + 1}. ${m.sessionId.slice(0, 8)}… ${m.firstMessagePreview ?? ''}`.trimEnd())
367
+ ? recent.map((m, i) => ` ${i + 1}. ${m.sessionId.slice(0, SESSION_ID_PREFIX_LEN)}… ${m.firstMessagePreview ?? ''}`.trimEnd())
356
368
  : [' (无历史 session)']
357
369
  return (
358
370
  `无匹配 session:"${query}"。最近 ${recent.length} 个 session:\n${lines.join('\n')}\n` +
@@ -368,7 +380,7 @@ function disambiguate(query: string, candidates: MatchedSession[]): ToolResult {
368
380
  )
369
381
  const hint =
370
382
  candidates[0] !== undefined
371
- ? `(如 ${candidates[0].sessionId.slice(0, 12)})`
383
+ ? `(如 ${candidates[0].sessionId.slice(0, HINT_ID_PREFIX_LEN)})`
372
384
  : ''
373
385
  const text =
374
386
  `${candidates.length} 个匹配 "${query}":\n${lines.join('\n')}\n` +
@@ -400,7 +412,7 @@ function formatFindContent(
400
412
  truncated: boolean,
401
413
  ): string {
402
414
  const lines = matches.map((m, i) => {
403
- const parts = [`${i + 1}. ${m.sessionId.slice(0, 8)}…`, formatDate(m.mtime)]
415
+ const parts = [`${i + 1}. ${m.sessionId.slice(0, SESSION_ID_PREFIX_LEN)}…`, formatDate(m.mtime)]
404
416
  if (m.cwd) parts.push(shortCwd(m.cwd))
405
417
  if (m.firstMessagePreview) parts.push(m.firstMessagePreview)
406
418
  return parts.join(' · ')
@@ -419,7 +431,7 @@ function formatOutlineText(r: OutlineResult): string {
419
431
  if (b.toolSummary) parts.push(b.toolSummary)
420
432
  // v2 O1:补 assistant 结论行(→ )让 outline 单独可决策
421
433
  if (b.assistantBrief) parts.push('→ ' + b.assistantBrief)
422
- const om = formatOmitted(b.omittedBytes)
434
+ const om = formatBytesMarker(b.omittedBytes)
423
435
  if (om) parts.push(om)
424
436
  if (b.branch) parts.push('[旁支]')
425
437
  return parts.join(' · ')
@@ -429,7 +441,7 @@ function formatOutlineText(r: OutlineResult): string {
429
441
  `${r.stats.totalTurns} turns · ${r.stats.totalEntries} entries · ~${r.tokenEstimate} tokens${
430
442
  r.stats.skippedLines > 0 ? ` · ${r.stats.skippedLines} skipped lines` : ''
431
443
  }`,
432
- r.truncated ? `[还有 ${r.truncated} 轮未显示,用 detail 或调大 budget]` : '',
444
+ r.truncated ? `[还有 ${r.truncated} 轮未显示,用 detail turns 参数看指定 turn 范围]` : '',
433
445
  ]
434
446
  .filter(Boolean)
435
447
  .join('\n')
@@ -440,7 +452,7 @@ function formatExpandText(turn: string, entries: EntryBrief[]): string {
440
452
  const lines = entries.map(
441
453
  (e) =>
442
454
  ` [${e.index}] ${e.type}${e.role ? '/' + e.role : ''} ${e.brief}${
443
- e.omittedBytes > 0 ? ' ' + formatOmitted(e.omittedBytes) : ''
455
+ e.omittedBytes > 0 ? ' ' + formatBytesMarker(e.omittedBytes) : ''
444
456
  }`,
445
457
  )
446
458
  return `${turn}\n${lines.join('\n')}`
@@ -504,10 +516,10 @@ function formatDetailText(
504
516
  .map((e) => {
505
517
  if (isToolResultSummary(e)) {
506
518
  // v2 O3:摘要态渲染(summary + 头 3 行 + 看全文提示)
507
- return `---\ntoolResultSummary (${e.id.slice(0, 8)})\n${e.summary}\n │ 共 ${e.totalLines} 行,前 3 行:${e.headLines}\n │ (+ includeToolResult:true 看全文)`
519
+ return `---\ntoolResultSummary (${e.id.slice(0, SESSION_ID_PREFIX_LEN)})\n${e.summary}\n │ 共 ${e.totalLines} 行,前 3 行:${e.headLines}\n │ (+ includeToolResult:true 看全文)`
508
520
  }
509
521
  const role = e.message ? `/${e.message.role}` : ''
510
- return `---\n${e.type}${role} (${e.id.slice(0, 8)})\n${entryReadableText(e)}`
522
+ return `---\n${e.type}${role} (${e.id.slice(0, SESSION_ID_PREFIX_LEN)})\n${entryReadableText(e)}`
511
523
  })
512
524
  .join('\n')
513
525
  return `${head}\n${body}`
@@ -517,15 +529,15 @@ function formatFamilyText(f: Family): string {
517
529
  const lines: string[] = []
518
530
  lines.push(`root: ${f.root.sessionId} (${formatDate(f.root.mtime)})`)
519
531
  if (f.parents.length)
520
- lines.push(`parents: ${f.parents.map((p) => p.sessionId.slice(0, 8)).join(', ')}`)
532
+ lines.push(`parents: ${f.parents.map((p) => p.sessionId.slice(0, SESSION_ID_PREFIX_LEN)).join(', ')}`)
521
533
  if (f.forks.length)
522
- lines.push(`forks: ${f.forks.map((p) => p.sessionId.slice(0, 8)).join(', ')}`)
534
+ lines.push(`forks: ${f.forks.map((p) => p.sessionId.slice(0, SESSION_ID_PREFIX_LEN)).join(', ')}`)
523
535
  if (f.subagents.length)
524
536
  lines.push(
525
537
  `subagents:\n${f.subagents
526
538
  .map(
527
539
  (s) =>
528
- ` ${s.sessionId.slice(0, 8)} root=${s.rootSessionId.slice(0, 8)} slug=${s.slug}${
540
+ ` ${s.sessionId.slice(0, SESSION_ID_PREFIX_LEN)} root=${s.rootSessionId.slice(0, SESSION_ID_PREFIX_LEN)} slug=${s.slug}${
529
541
  s.cleanedUp ? ' [已清理]' : ''
530
542
  }`,
531
543
  )
@@ -600,9 +612,12 @@ function searchableText(content: unknown): string {
600
612
  return safeStringify(content)
601
613
  }
602
614
 
615
+ /** search 命中片段的前后上下文字符数。 */
616
+ const SNIPPET_CONTEXT_CHARS = 20
617
+
603
618
  function snippet(text: string, idx: number, len: number): string {
604
- const start = Math.max(0, idx - 20)
605
- const end = Math.min(text.length, idx + len + 20)
619
+ const start = Math.max(0, idx - SNIPPET_CONTEXT_CHARS)
620
+ const end = Math.min(text.length, idx + len + SNIPPET_CONTEXT_CHARS)
606
621
  return (
607
622
  (start > 0 ? '…' : '') +
608
623
  text.slice(start, end).replace(/\s+/g, ' ').trim() +
@@ -614,12 +629,15 @@ function snippet(text: string, idx: number, len: number): string {
614
629
  // 各 action 实现
615
630
  // ===========================================================================
616
631
 
632
+ /** find action 的默认匹配数上限。 */
633
+ const FIND_DEFAULT_LIMIT = 20
634
+
617
635
  /** find:按片段/名称/recent 定位 session(design §3.4 find)。零匹配不抛,返回提示。 */
618
636
  async function doFind(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
619
637
  const query = requireStr(params.query, 'query', 'find')
620
638
  const { matches, truncated } = await findSessions(query, agentDir, {
621
639
  cwd: params.cwd,
622
- limit: params.limit ?? 20,
640
+ limit: params.limit ?? FIND_DEFAULT_LIMIT,
623
641
  ...(params.source ? { source: params.source } : {}),
624
642
  })
625
643
  if (matches.length === 0) {
@@ -655,7 +673,7 @@ async function doFamily(params: SessionReadParams, agentDir: string): Promise<To
655
673
  tree = await buildExecutionTree(resolved.sessionId, agentDir, resolved.fileName)
656
674
  } catch (e) {
657
675
  throw err(
658
- `构建执行树失败:${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 兜底。`,
659
677
  )
660
678
  }
661
679
  return {
@@ -676,7 +694,7 @@ async function doFamily(params: SessionReadParams, agentDir: string): Promise<To
676
694
  return { content: [{ type: 'text', text: formatFamilyText(family) }], details: family }
677
695
  }
678
696
 
679
- /** outline:turn 级全貌 TOC(design §3.4 outline,~500 token)。 */
697
+ /** outline:turn 级全貌 TOC(design §3.4 outline,~1500 token;render budget 硬编码 2000)。 */
680
698
  async function doOutline(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
681
699
  const resolved = await resolveSessionId(params.session, 'outline', agentDir, params.source)
682
700
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
@@ -745,6 +763,9 @@ async function doDetail(params: SessionReadParams, agentDir: string): Promise<To
745
763
  }
746
764
  }
747
765
 
766
+ /** search action 的默认命中数上限。 */
767
+ const SEARCH_DEFAULT_LIMIT = 20
768
+
748
769
  /** search:session 内全文检索(design §3.4 search,M3 新实现)。 */
749
770
  async function doSearch(
750
771
  params: SessionReadParams,
@@ -755,7 +776,7 @@ async function doSearch(
755
776
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
756
777
  const pattern = requireStr(params.pattern, 'pattern', 'search')
757
778
  const scope = params.scope ?? 'all'
758
- const limit = params.limit ?? 20
779
+ const limit = params.limit ?? SEARCH_DEFAULT_LIMIT
759
780
  const { entries } = await safeParse(resolved.fileName)
760
781
  const tree = buildTreeView(entries)
761
782
  const turns = segmentTurns(entries, new Set(tree.leafPath))
@@ -800,6 +821,9 @@ async function doSearch(
800
821
  return { content: [{ type: 'text', text }], details: { hits: sliced, truncated } }
801
822
  }
802
823
 
824
+ /** export full 模式的 entry 分隔线('=' 重复)宽度。 */
825
+ const EXPORT_SEPARATOR_LEN = 40
826
+
803
827
  /** export:物化摘要到 <agentDir>/tmp/session-view-<id>.md(design §3.4 export,D-8)。 */
804
828
  async function doExport(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
805
829
  const format = params.format ?? 'outline'
@@ -830,9 +854,9 @@ async function doExport(params: SessionReadParams, agentDir: string): Promise<To
830
854
  text = det
831
855
  .map((e) => {
832
856
  if (isToolResultSummary(e)) {
833
- return `${'='.repeat(40)}\ntoolResultSummary (${e.id.slice(0, 8)})\n${entryReadableText(e)}`
857
+ return `${'='.repeat(EXPORT_SEPARATOR_LEN)}\ntoolResultSummary (${e.id.slice(0, SESSION_ID_PREFIX_LEN)})\n${entryReadableText(e)}`
834
858
  }
835
- return `${'='.repeat(40)}\n${e.type}${e.message ? '/' + e.message.role : ''} (${e.id.slice(0, 8)})\n${entryReadableText(e)}`
859
+ return `${'='.repeat(EXPORT_SEPARATOR_LEN)}\n${e.type}${e.message ? '/' + e.message.role : ''} (${e.id.slice(0, SESSION_ID_PREFIX_LEN)})\n${entryReadableText(e)}`
836
860
  })
837
861
  .join('\n')
838
862
  label = 'full'
@@ -927,10 +951,13 @@ function truncateText(s: string, max: number): string {
927
951
  return s.length <= max ? s : s.slice(0, max) + '…'
928
952
  }
929
953
 
954
+ /** turnsLabel 展示的 turn 数上限(超出折叠为 +N)。 */
955
+ const TURNS_LABEL_HEAD_COUNT = 5
956
+
930
957
  /** turns 数组紧凑标签(前 5 个 + +N,避免一行过长撑爆预算)。 */
931
958
  function turnsLabel(turns: number[]): string {
932
- const head = turns.slice(0, 5).map((n) => `T${pad(n)}`)
933
- const suffix = turns.length > 5 ? `+${turns.length - 5}` : ''
959
+ const head = turns.slice(0, TURNS_LABEL_HEAD_COUNT).map((n) => `T${pad(n)}`)
960
+ const suffix = turns.length > TURNS_LABEL_HEAD_COUNT ? `+${turns.length - TURNS_LABEL_HEAD_COUNT}` : ''
934
961
  return head.join(',') + suffix
935
962
  }
936
963
 
@@ -955,9 +982,12 @@ function computeToolDistribution(
955
982
  .sort((a, b) => b.count - a.count)
956
983
  }
957
984
 
985
+ /** F8 提示展示的工具分布条数上限。 */
986
+ const TOOL_DISTRIBUTION_LIMIT = 10
987
+
958
988
  /** F8:commands/tool-results 的 tool 过滤零匹配 → 返回工具分布 + 👉(不抛错,design §3.3 F8)。 */
959
989
  function f8ToolNoMatch(what: ExtractWhat, tool: string, turns: Turn[]): ToolResult {
960
- const dist = computeToolDistribution(turns).slice(0, 10)
990
+ const dist = computeToolDistribution(turns).slice(0, TOOL_DISTRIBUTION_LIMIT)
961
991
  const distStr = dist.map((d) => `${d.name}×${d.count}`).join(', ')
962
992
  const text = `what=${what} tool="${tool}" 无匹配。该 session 工具:${distStr}。👉 用存在的工具名重试。`
963
993
  return { content: [{ type: 'text', text }], details: { what, tool, toolDistribution: dist } }
@@ -975,6 +1005,12 @@ function f8ToolNoMatch(what: ExtractWhat, tool: string, turns: Turn[]): ToolResu
975
1005
  *
976
1006
  * getTurns:从 item 提取 turn 列表(files 是 turns 数组,其余单值包数组),供 F9 文案报 turn 范围。
977
1007
  */
1008
+
1009
+ /** UTF8 单字符最大字节数(预算字节→字符的保守换算基数,防多字节字符被切半)。 */
1010
+ const UTF8_MAX_BYTES_PER_CHAR = 3
1011
+ /** token 估算换算基数(bytes/4 口径,与 render 层 chars/4 同近似)。 */
1012
+ const CHARS_PER_TOKEN = 4
1013
+
978
1014
  export function renderExtractItems<I>(
979
1015
  what: ExtractWhat,
980
1016
  items: I[],
@@ -999,7 +1035,7 @@ export function renderExtractItems<I>(
999
1035
  if (bytes + lineBytes > EXTRACT_BUDGET_BYTES) {
1000
1036
  // 超预算:对当前 line 内部截断到剩余预算(首项超大也截断,但保留截断后的内容)
1001
1037
  const remainingBytes = EXTRACT_BUDGET_BYTES - bytes
1002
- const charBudget = Math.floor(remainingBytes / 3) // 字节→字符 ×3 近似防 UTF8 切半
1038
+ const charBudget = Math.floor(remainingBytes / UTF8_MAX_BYTES_PER_CHAR) // 字节→字符 ×3 近似防 UTF8 切半
1003
1039
  if (charBudget > 0) {
1004
1040
  const sliced = line.slice(0, charBudget) + '…'
1005
1041
  shown.push(item)
@@ -1031,7 +1067,7 @@ export function renderExtractItems<I>(
1031
1067
  shownTurns.length > 0
1032
1068
  ? `(T${pad(Math.min(...shownTurns))}-T${pad(Math.max(...shownTurns))})`
1033
1069
  : ''
1034
- const actualTokens = Math.round(Buffer.byteLength(body, 'utf8') / 4)
1070
+ const actualTokens = Math.round(Buffer.byteLength(body, 'utf8') / CHARS_PER_TOKEN)
1035
1071
  const text =
1036
1072
  body +
1037
1073
  `\n[what=${what} 已显示 ${shown.length}/${items.length} 项${turnRange},约 ${actualTokens} token 达预算上限。👉 用较小 turns 范围(如 T000-T005)缩小,或换 what 重试。]`
@@ -1148,6 +1184,9 @@ function extractFiles(turns: Turn[]): ToolResult {
1148
1184
  *(git 操作未被 toolResult 捕获)或误报(git log 输出里的其他 hex)。每条标注来源 turn
1149
1185
  * + source + context,agent 可快速辨认。完全语义判断需 LLM,本工具零 LLM 依赖。
1150
1186
  */
1187
+ /** commits 提取的 hash 前后上下文字符数(次路径关键词判定窗口)。 */
1188
+ const COMMIT_CONTEXT_CHARS = 30
1189
+
1151
1190
  function extractCommits(turns: Turn[]): ToolResult {
1152
1191
  // 建 toolCallId → bash command 映射(用于判定 toolResult 是否来自 git 命令)
1153
1192
  const bashCmds = new Map<string, string>()
@@ -1184,7 +1223,7 @@ function extractCommits(turns: Turn[]): ToolResult {
1184
1223
  const hash = m[0]
1185
1224
  const idx = m.index ?? 0
1186
1225
  const ctx = text
1187
- .slice(Math.max(0, idx - 30), idx + hash.length + 30)
1226
+ .slice(Math.max(0, idx - COMMIT_CONTEXT_CHARS), idx + hash.length + COMMIT_CONTEXT_CHARS)
1188
1227
  .replace(/\s+/g, ' ')
1189
1228
  .trim()
1190
1229
  if (isGitBash) {
@@ -1219,6 +1258,9 @@ function extractCommits(turns: Turn[]): ToolResult {
1219
1258
  )
1220
1259
  }
1221
1260
 
1261
+ /** tool-results 正文截断上限(防爆)。 */
1262
+ const TOOL_RESULT_TEXT_MAX_CHARS = 500
1263
+
1222
1264
  /**
1223
1265
  * 预设 5:tool-results——role==='toolResult' 文本(design §3.3 D3)。
1224
1266
  * text 截断到 500 字防爆;可选 tool 过滤(msg.toolName);过滤零匹配 → F8。
@@ -1231,7 +1273,7 @@ function extractToolResults(turns: Turn[], tool: string | undefined): ToolResult
1231
1273
  if (e.message?.role !== 'toolResult') continue
1232
1274
  const tn = e.message.toolName ?? '?'
1233
1275
  if (tool !== undefined && tn !== tool) continue
1234
- const text = truncateText(extractContentText(e.message.content), 500)
1276
+ const text = truncateText(extractContentText(e.message.content), TOOL_RESULT_TEXT_MAX_CHARS)
1235
1277
  items.push({ turn: t.index, index: ei, toolName: tn, text })
1236
1278
  }
1237
1279
  }
@@ -1335,8 +1377,9 @@ interface SkippedRun {
1335
1377
  * session 会抛「session not found in family index」)——resolveSessionId 已把 session 解析到
1336
1378
  * 真实文件(kind==='ok' 保证文件存在,三形态:绝对路径/sa-id 均 existsSync 校验,片段匹配
1337
1379
  * 来自实际 fs 扫描),直接用 resolved.fileName 构造单条目 sessionIdToPath 调 resolveWorkflows
1338
- *(与 buildFamilyFromFs 步骤 6 的 workflow 腿同源)。pathToRef 仅含目标 session,call 引用
1339
- * 走 sessionRefFromPath 文件名最小回退(sessionId+fileName,足够 LLM 跳 outline/detail 深读)。
1380
+ *(与 buildFamilyFromFs 步骤 6 的 workflow 腿同源)。pathToRef 传空 Map(单条目链路无其他
1381
+ * 文件可反查),call 引用 100% 走 sessionRefFromPath 文件名最小回退(sessionId+fileName
1382
+ * 足够 LLM 跳 outline/detail 深读)。
1340
1383
  *
1341
1384
  * 错误契约(C2):workflow 概览探索语义,三类错误均返回 ToolResult 不抛错。
1342
1385
  * step 的 call sessionId/sessionFile 是 LLM 跳 outline/detail 的入口(m0 resolveSessionId
@@ -1418,7 +1461,7 @@ async function doWorkflow(params: SessionReadParams, agentDir: string): Promise<
1418
1461
  if (requestedRunId !== undefined) details.requestedRunId = requestedRunId
1419
1462
  if (skippedRuns.length > 0) details.skippedRuns = skippedRuns
1420
1463
 
1421
- // 全部 run 都跳过的兑底提示(ES-wf-snapshot-read-fail 末段)
1464
+ // 全部 run 都跳过的兜底提示(ES-wf-snapshot-read-fail 末段)
1422
1465
  let text: string
1423
1466
  if (runs.length === 0) {
1424
1467
  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())