@zhushanwen/pi-session-reader 0.2.3 → 0.2.4

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.2.4",
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": {
@@ -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
  }
@@ -258,8 +287,8 @@ function formatHHMM(timestamp?: string): string {
258
287
 
259
288
  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
  }
@@ -91,7 +91,10 @@ export interface ToolResult {
91
91
  // 小工具
92
92
  // ---------------------------------------------------------------------------
93
93
 
94
- const pad = (n: number): string => String(n).padStart(3, '0')
94
+ /** turn 索引显示宽度(T013 三位补零)。 */
95
+ const TURN_INDEX_WIDTH = 3
96
+
97
+ const pad = (n: number): string => String(n).padStart(TURN_INDEX_WIDTH, '0')
95
98
 
96
99
  /** 构造带 👉 恢复指引的 Error(handler 抛出,由 execute 闭包 catch)。 */
97
100
  function err(message: string): Error {
@@ -103,24 +106,33 @@ function stripHash(s: string): string {
103
106
  return s.replace(/^#+/, '')
104
107
  }
105
108
 
109
+ /** formatDate 日期段(月/日)补零宽度。 */
110
+ const DATE_FIELD_WIDTH = 2
111
+
106
112
  function formatDate(ms: number): string {
107
113
  if (!ms) return ''
108
114
  const d = new Date(ms)
109
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
115
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(DATE_FIELD_WIDTH, '0')}-${String(
110
116
  d.getDate(),
111
- ).padStart(2, '0')}`
117
+ ).padStart(DATE_FIELD_WIDTH, '0')}`
112
118
  }
113
119
 
120
+ /** shortCwd 保留的目录末段数。 */
121
+ const SHORT_CWD_SEGMENTS = 2
122
+
114
123
  /** cwd 取末两段缩短显示(完整 cwd 在 details 里)。 */
115
124
  function shortCwd(cwd: string): string {
116
125
  const parts = cwd.split('/').filter(Boolean)
117
- return parts.slice(-2).join('/')
126
+ return parts.slice(-SHORT_CWD_SEGMENTS).join('/')
118
127
  }
119
128
 
129
+ /** bytes→KB 换算基数(formatOmitted 的 KB 显示)。 */
130
+ const BYTES_PER_KB = 1024
131
+
120
132
  function formatOmitted(bytes: number): string {
121
133
  if (bytes <= 0) return ''
122
- if (bytes < 1024) return `[${bytes}B omitted]`
123
- return `[${Math.round(bytes / 1024)}KB omitted]`
134
+ if (bytes < BYTES_PER_KB) return `[${bytes}B omitted]`
135
+ return `[${Math.round(bytes / BYTES_PER_KB)}KB omitted]`
124
136
  }
125
137
 
126
138
  /** F5 必填参数校验。 */
@@ -151,13 +163,16 @@ function parseTurnIndex(raw: string): number {
151
163
  return parseInt(m[1], 10)
152
164
  }
153
165
 
166
+ /** turns 范围 "T013-T015" 的段数。 */
167
+ const TURNS_RANGE_PARTS = 2
168
+
154
169
  function parseTurnsRange(raw: string): { start: number; end: number } {
155
170
  const parts = raw.split('-').map((s) => s.trim())
156
171
  if (parts.length === 1) {
157
172
  const i = parseTurnIndex(parts[0])
158
173
  return { start: i, end: i }
159
174
  }
160
- if (parts.length === 2) {
175
+ if (parts.length === TURNS_RANGE_PARTS) {
161
176
  const start = parseTurnIndex(parts[0])
162
177
  const end = parseTurnIndex(parts[1])
163
178
  if (end < start) {
@@ -349,10 +364,15 @@ function formatSaIdAmbiguous(saId: string, records: RecordManifest[]): string {
349
364
  )
350
365
  }
351
366
 
367
+ /** sessionId 列表行内的短显前缀长度。 */
368
+ const SESSION_ID_PREFIX_LEN = 8
369
+ /** 消歧提示的 uuid 片段长度(比短显略长,引导输入更长片段消歧)。 */
370
+ const HINT_ID_PREFIX_LEN = 12
371
+
352
372
  /** F1 无匹配 message(含最近 10 + 👉)。 */
353
373
  function formatNoMatch(query: string, recent: MatchedSession[]): string {
354
374
  const lines: string[] = recent.length
355
- ? recent.map((m, i) => ` ${i + 1}. ${m.sessionId.slice(0, 8)}… ${m.firstMessagePreview ?? ''}`.trimEnd())
375
+ ? recent.map((m, i) => ` ${i + 1}. ${m.sessionId.slice(0, SESSION_ID_PREFIX_LEN)}… ${m.firstMessagePreview ?? ''}`.trimEnd())
356
376
  : [' (无历史 session)']
357
377
  return (
358
378
  `无匹配 session:"${query}"。最近 ${recent.length} 个 session:\n${lines.join('\n')}\n` +
@@ -368,7 +388,7 @@ function disambiguate(query: string, candidates: MatchedSession[]): ToolResult {
368
388
  )
369
389
  const hint =
370
390
  candidates[0] !== undefined
371
- ? `(如 ${candidates[0].sessionId.slice(0, 12)})`
391
+ ? `(如 ${candidates[0].sessionId.slice(0, HINT_ID_PREFIX_LEN)})`
372
392
  : ''
373
393
  const text =
374
394
  `${candidates.length} 个匹配 "${query}":\n${lines.join('\n')}\n` +
@@ -400,7 +420,7 @@ function formatFindContent(
400
420
  truncated: boolean,
401
421
  ): string {
402
422
  const lines = matches.map((m, i) => {
403
- const parts = [`${i + 1}. ${m.sessionId.slice(0, 8)}…`, formatDate(m.mtime)]
423
+ const parts = [`${i + 1}. ${m.sessionId.slice(0, SESSION_ID_PREFIX_LEN)}…`, formatDate(m.mtime)]
404
424
  if (m.cwd) parts.push(shortCwd(m.cwd))
405
425
  if (m.firstMessagePreview) parts.push(m.firstMessagePreview)
406
426
  return parts.join(' · ')
@@ -504,10 +524,10 @@ function formatDetailText(
504
524
  .map((e) => {
505
525
  if (isToolResultSummary(e)) {
506
526
  // v2 O3:摘要态渲染(summary + 头 3 行 + 看全文提示)
507
- return `---\ntoolResultSummary (${e.id.slice(0, 8)})\n${e.summary}\n │ 共 ${e.totalLines} 行,前 3 行:${e.headLines}\n │ (+ includeToolResult:true 看全文)`
527
+ return `---\ntoolResultSummary (${e.id.slice(0, SESSION_ID_PREFIX_LEN)})\n${e.summary}\n │ 共 ${e.totalLines} 行,前 3 行:${e.headLines}\n │ (+ includeToolResult:true 看全文)`
508
528
  }
509
529
  const role = e.message ? `/${e.message.role}` : ''
510
- return `---\n${e.type}${role} (${e.id.slice(0, 8)})\n${entryReadableText(e)}`
530
+ return `---\n${e.type}${role} (${e.id.slice(0, SESSION_ID_PREFIX_LEN)})\n${entryReadableText(e)}`
511
531
  })
512
532
  .join('\n')
513
533
  return `${head}\n${body}`
@@ -517,15 +537,15 @@ function formatFamilyText(f: Family): string {
517
537
  const lines: string[] = []
518
538
  lines.push(`root: ${f.root.sessionId} (${formatDate(f.root.mtime)})`)
519
539
  if (f.parents.length)
520
- lines.push(`parents: ${f.parents.map((p) => p.sessionId.slice(0, 8)).join(', ')}`)
540
+ lines.push(`parents: ${f.parents.map((p) => p.sessionId.slice(0, SESSION_ID_PREFIX_LEN)).join(', ')}`)
521
541
  if (f.forks.length)
522
- lines.push(`forks: ${f.forks.map((p) => p.sessionId.slice(0, 8)).join(', ')}`)
542
+ lines.push(`forks: ${f.forks.map((p) => p.sessionId.slice(0, SESSION_ID_PREFIX_LEN)).join(', ')}`)
523
543
  if (f.subagents.length)
524
544
  lines.push(
525
545
  `subagents:\n${f.subagents
526
546
  .map(
527
547
  (s) =>
528
- ` ${s.sessionId.slice(0, 8)} root=${s.rootSessionId.slice(0, 8)} slug=${s.slug}${
548
+ ` ${s.sessionId.slice(0, SESSION_ID_PREFIX_LEN)} root=${s.rootSessionId.slice(0, SESSION_ID_PREFIX_LEN)} slug=${s.slug}${
529
549
  s.cleanedUp ? ' [已清理]' : ''
530
550
  }`,
531
551
  )
@@ -600,9 +620,12 @@ function searchableText(content: unknown): string {
600
620
  return safeStringify(content)
601
621
  }
602
622
 
623
+ /** search 命中片段的前后上下文字符数。 */
624
+ const SNIPPET_CONTEXT_CHARS = 20
625
+
603
626
  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)
627
+ const start = Math.max(0, idx - SNIPPET_CONTEXT_CHARS)
628
+ const end = Math.min(text.length, idx + len + SNIPPET_CONTEXT_CHARS)
606
629
  return (
607
630
  (start > 0 ? '…' : '') +
608
631
  text.slice(start, end).replace(/\s+/g, ' ').trim() +
@@ -614,12 +637,15 @@ function snippet(text: string, idx: number, len: number): string {
614
637
  // 各 action 实现
615
638
  // ===========================================================================
616
639
 
640
+ /** find action 的默认匹配数上限。 */
641
+ const FIND_DEFAULT_LIMIT = 20
642
+
617
643
  /** find:按片段/名称/recent 定位 session(design §3.4 find)。零匹配不抛,返回提示。 */
618
644
  async function doFind(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
619
645
  const query = requireStr(params.query, 'query', 'find')
620
646
  const { matches, truncated } = await findSessions(query, agentDir, {
621
647
  cwd: params.cwd,
622
- limit: params.limit ?? 20,
648
+ limit: params.limit ?? FIND_DEFAULT_LIMIT,
623
649
  ...(params.source ? { source: params.source } : {}),
624
650
  })
625
651
  if (matches.length === 0) {
@@ -745,6 +771,9 @@ async function doDetail(params: SessionReadParams, agentDir: string): Promise<To
745
771
  }
746
772
  }
747
773
 
774
+ /** search action 的默认命中数上限。 */
775
+ const SEARCH_DEFAULT_LIMIT = 20
776
+
748
777
  /** search:session 内全文检索(design §3.4 search,M3 新实现)。 */
749
778
  async function doSearch(
750
779
  params: SessionReadParams,
@@ -755,7 +784,7 @@ async function doSearch(
755
784
  if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
756
785
  const pattern = requireStr(params.pattern, 'pattern', 'search')
757
786
  const scope = params.scope ?? 'all'
758
- const limit = params.limit ?? 20
787
+ const limit = params.limit ?? SEARCH_DEFAULT_LIMIT
759
788
  const { entries } = await safeParse(resolved.fileName)
760
789
  const tree = buildTreeView(entries)
761
790
  const turns = segmentTurns(entries, new Set(tree.leafPath))
@@ -800,6 +829,9 @@ async function doSearch(
800
829
  return { content: [{ type: 'text', text }], details: { hits: sliced, truncated } }
801
830
  }
802
831
 
832
+ /** export full 模式的 entry 分隔线('=' 重复)宽度。 */
833
+ const EXPORT_SEPARATOR_LEN = 40
834
+
803
835
  /** export:物化摘要到 <agentDir>/tmp/session-view-<id>.md(design §3.4 export,D-8)。 */
804
836
  async function doExport(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
805
837
  const format = params.format ?? 'outline'
@@ -830,9 +862,9 @@ async function doExport(params: SessionReadParams, agentDir: string): Promise<To
830
862
  text = det
831
863
  .map((e) => {
832
864
  if (isToolResultSummary(e)) {
833
- return `${'='.repeat(40)}\ntoolResultSummary (${e.id.slice(0, 8)})\n${entryReadableText(e)}`
865
+ return `${'='.repeat(EXPORT_SEPARATOR_LEN)}\ntoolResultSummary (${e.id.slice(0, SESSION_ID_PREFIX_LEN)})\n${entryReadableText(e)}`
834
866
  }
835
- return `${'='.repeat(40)}\n${e.type}${e.message ? '/' + e.message.role : ''} (${e.id.slice(0, 8)})\n${entryReadableText(e)}`
867
+ 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
868
  })
837
869
  .join('\n')
838
870
  label = 'full'
@@ -927,10 +959,13 @@ function truncateText(s: string, max: number): string {
927
959
  return s.length <= max ? s : s.slice(0, max) + '…'
928
960
  }
929
961
 
962
+ /** turnsLabel 展示的 turn 数上限(超出折叠为 +N)。 */
963
+ const TURNS_LABEL_HEAD_COUNT = 5
964
+
930
965
  /** turns 数组紧凑标签(前 5 个 + +N,避免一行过长撑爆预算)。 */
931
966
  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}` : ''
967
+ const head = turns.slice(0, TURNS_LABEL_HEAD_COUNT).map((n) => `T${pad(n)}`)
968
+ const suffix = turns.length > TURNS_LABEL_HEAD_COUNT ? `+${turns.length - TURNS_LABEL_HEAD_COUNT}` : ''
934
969
  return head.join(',') + suffix
935
970
  }
936
971
 
@@ -955,9 +990,12 @@ function computeToolDistribution(
955
990
  .sort((a, b) => b.count - a.count)
956
991
  }
957
992
 
993
+ /** F8 提示展示的工具分布条数上限。 */
994
+ const TOOL_DISTRIBUTION_LIMIT = 10
995
+
958
996
  /** F8:commands/tool-results 的 tool 过滤零匹配 → 返回工具分布 + 👉(不抛错,design §3.3 F8)。 */
959
997
  function f8ToolNoMatch(what: ExtractWhat, tool: string, turns: Turn[]): ToolResult {
960
- const dist = computeToolDistribution(turns).slice(0, 10)
998
+ const dist = computeToolDistribution(turns).slice(0, TOOL_DISTRIBUTION_LIMIT)
961
999
  const distStr = dist.map((d) => `${d.name}×${d.count}`).join(', ')
962
1000
  const text = `what=${what} tool="${tool}" 无匹配。该 session 工具:${distStr}。👉 用存在的工具名重试。`
963
1001
  return { content: [{ type: 'text', text }], details: { what, tool, toolDistribution: dist } }
@@ -975,6 +1013,12 @@ function f8ToolNoMatch(what: ExtractWhat, tool: string, turns: Turn[]): ToolResu
975
1013
  *
976
1014
  * getTurns:从 item 提取 turn 列表(files 是 turns 数组,其余单值包数组),供 F9 文案报 turn 范围。
977
1015
  */
1016
+
1017
+ /** UTF8 单字符最大字节数(预算字节→字符的保守换算基数,防多字节字符被切半)。 */
1018
+ const UTF8_MAX_BYTES_PER_CHAR = 3
1019
+ /** token 估算换算基数(bytes/4 口径,与 render 层 chars/4 同近似)。 */
1020
+ const CHARS_PER_TOKEN = 4
1021
+
978
1022
  export function renderExtractItems<I>(
979
1023
  what: ExtractWhat,
980
1024
  items: I[],
@@ -999,7 +1043,7 @@ export function renderExtractItems<I>(
999
1043
  if (bytes + lineBytes > EXTRACT_BUDGET_BYTES) {
1000
1044
  // 超预算:对当前 line 内部截断到剩余预算(首项超大也截断,但保留截断后的内容)
1001
1045
  const remainingBytes = EXTRACT_BUDGET_BYTES - bytes
1002
- const charBudget = Math.floor(remainingBytes / 3) // 字节→字符 ×3 近似防 UTF8 切半
1046
+ const charBudget = Math.floor(remainingBytes / UTF8_MAX_BYTES_PER_CHAR) // 字节→字符 ×3 近似防 UTF8 切半
1003
1047
  if (charBudget > 0) {
1004
1048
  const sliced = line.slice(0, charBudget) + '…'
1005
1049
  shown.push(item)
@@ -1031,7 +1075,7 @@ export function renderExtractItems<I>(
1031
1075
  shownTurns.length > 0
1032
1076
  ? `(T${pad(Math.min(...shownTurns))}-T${pad(Math.max(...shownTurns))})`
1033
1077
  : ''
1034
- const actualTokens = Math.round(Buffer.byteLength(body, 'utf8') / 4)
1078
+ const actualTokens = Math.round(Buffer.byteLength(body, 'utf8') / CHARS_PER_TOKEN)
1035
1079
  const text =
1036
1080
  body +
1037
1081
  `\n[what=${what} 已显示 ${shown.length}/${items.length} 项${turnRange},约 ${actualTokens} token 达预算上限。👉 用较小 turns 范围(如 T000-T005)缩小,或换 what 重试。]`
@@ -1148,6 +1192,9 @@ function extractFiles(turns: Turn[]): ToolResult {
1148
1192
  *(git 操作未被 toolResult 捕获)或误报(git log 输出里的其他 hex)。每条标注来源 turn
1149
1193
  * + source + context,agent 可快速辨认。完全语义判断需 LLM,本工具零 LLM 依赖。
1150
1194
  */
1195
+ /** commits 提取的 hash 前后上下文字符数(次路径关键词判定窗口)。 */
1196
+ const COMMIT_CONTEXT_CHARS = 30
1197
+
1151
1198
  function extractCommits(turns: Turn[]): ToolResult {
1152
1199
  // 建 toolCallId → bash command 映射(用于判定 toolResult 是否来自 git 命令)
1153
1200
  const bashCmds = new Map<string, string>()
@@ -1184,7 +1231,7 @@ function extractCommits(turns: Turn[]): ToolResult {
1184
1231
  const hash = m[0]
1185
1232
  const idx = m.index ?? 0
1186
1233
  const ctx = text
1187
- .slice(Math.max(0, idx - 30), idx + hash.length + 30)
1234
+ .slice(Math.max(0, idx - COMMIT_CONTEXT_CHARS), idx + hash.length + COMMIT_CONTEXT_CHARS)
1188
1235
  .replace(/\s+/g, ' ')
1189
1236
  .trim()
1190
1237
  if (isGitBash) {
@@ -1219,6 +1266,9 @@ function extractCommits(turns: Turn[]): ToolResult {
1219
1266
  )
1220
1267
  }
1221
1268
 
1269
+ /** tool-results 正文截断上限(防爆)。 */
1270
+ const TOOL_RESULT_TEXT_MAX_CHARS = 500
1271
+
1222
1272
  /**
1223
1273
  * 预设 5:tool-results——role==='toolResult' 文本(design §3.3 D3)。
1224
1274
  * text 截断到 500 字防爆;可选 tool 过滤(msg.toolName);过滤零匹配 → F8。
@@ -1231,7 +1281,7 @@ function extractToolResults(turns: Turn[], tool: string | undefined): ToolResult
1231
1281
  if (e.message?.role !== 'toolResult') continue
1232
1282
  const tn = e.message.toolName ?? '?'
1233
1283
  if (tool !== undefined && tn !== tool) continue
1234
- const text = truncateText(extractContentText(e.message.content), 500)
1284
+ const text = truncateText(extractContentText(e.message.content), TOOL_RESULT_TEXT_MAX_CHARS)
1235
1285
  items.push({ turn: t.index, index: ei, toolName: tn, text })
1236
1286
  }
1237
1287
  }