@zhushanwen/pi-session-reader 0.2.2 → 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 +7 -4
- package/src/core/render.ts +56 -27
- package/src/core/toolcall.ts +21 -6
- package/src/discovery/roots.ts +4 -2
- package/src/tool-handler.ts +78 -28
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-session-reader",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
|
+
"xyz-agent": {
|
|
7
|
+
"role": "universal"
|
|
8
|
+
},
|
|
6
9
|
"pi": {
|
|
7
10
|
"extensions": [
|
|
8
11
|
"./index.ts"
|
|
@@ -22,9 +25,9 @@
|
|
|
22
25
|
"vitest.config.ts"
|
|
23
26
|
],
|
|
24
27
|
"peerDependencies": {
|
|
25
|
-
"@earendil-works/pi-coding-agent": "
|
|
26
|
-
"@earendil-works/pi-ai": "
|
|
27
|
-
"@earendil-works/pi-tui": "
|
|
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",
|
|
28
31
|
"typebox": "*"
|
|
29
32
|
},
|
|
30
33
|
"peerDependenciesMeta": {
|
package/src/core/render.ts
CHANGED
|
@@ -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) /
|
|
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,
|
|
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,
|
|
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,
|
|
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 <
|
|
262
|
-
return `[${Math.round(bytes /
|
|
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:
|
|
300
|
+
function formatLine(b: TurnBrief, level: LineLevel, branchSize?: number): string {
|
|
272
301
|
const parts: string[] = []
|
|
273
|
-
const head = `T${String(b.index).padStart(
|
|
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 <=
|
|
278
|
-
if (level <=
|
|
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 ??
|
|
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) *
|
|
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,
|
|
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,
|
|
386
|
+
line = formatLine(b, LINE_LEVEL_NO_ASSISTANT, branchSize)
|
|
358
387
|
if (line.length > perTurnCharBudget) {
|
|
359
388
|
b.toolSummary = ''
|
|
360
|
-
line = formatLine(b,
|
|
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 /
|
|
398
|
+
if (totalChars / CHARS_PER_TOKEN > budget) {
|
|
370
399
|
let kept = lineCache.length
|
|
371
|
-
while (kept > 0 && totalChars /
|
|
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 /
|
|
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,
|
|
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,
|
|
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 /
|
|
452
|
+
if (totalChars / CHARS_PER_TOKEN > budget) {
|
|
424
453
|
let kept = lines.length
|
|
425
|
-
while (kept > 0 && totalChars /
|
|
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 /
|
|
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),
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
package/src/core/toolcall.ts
CHANGED
|
@@ -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,
|
|
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') /
|
|
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,
|
|
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),
|
|
177
|
+
return `${name}: ${truncate(JSON.stringify(args), ARGS_JSON_MAX_CHARS)}`
|
|
163
178
|
} catch {
|
|
164
179
|
return name
|
|
165
180
|
}
|
package/src/discovery/roots.ts
CHANGED
|
@@ -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
|
}
|
package/src/tool-handler.ts
CHANGED
|
@@ -91,7 +91,10 @@ export interface ToolResult {
|
|
|
91
91
|
// 小工具
|
|
92
92
|
// ---------------------------------------------------------------------------
|
|
93
93
|
|
|
94
|
-
|
|
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(
|
|
115
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(DATE_FIELD_WIDTH, '0')}-${String(
|
|
110
116
|
d.getDate(),
|
|
111
|
-
).padStart(
|
|
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(-
|
|
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 <
|
|
123
|
-
return `[${Math.round(bytes /
|
|
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 ===
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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 -
|
|
605
|
-
const end = Math.min(text.length, idx + len +
|
|
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 ??
|
|
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 ??
|
|
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(
|
|
865
|
+
return `${'='.repeat(EXPORT_SEPARATOR_LEN)}\ntoolResultSummary (${e.id.slice(0, SESSION_ID_PREFIX_LEN)})\n${entryReadableText(e)}`
|
|
834
866
|
}
|
|
835
|
-
return `${'='.repeat(
|
|
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,
|
|
933
|
-
const suffix = turns.length >
|
|
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,
|
|
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 /
|
|
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') /
|
|
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 -
|
|
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),
|
|
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
|
}
|