@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 +4 -4
- package/src/__tests__/render.test.ts +2 -2
- package/src/__tests__/tool-handler.test.ts +2 -2
- package/src/core/execution-tree.ts +0 -1
- package/src/core/family.ts +2 -3
- package/src/core/render.ts +57 -28
- package/src/core/toolcall.ts +21 -6
- package/src/discovery/roots.ts +4 -2
- package/src/discovery/subagents.ts +10 -15
- package/src/index.ts +9 -9
- package/src/tool-handler.ts +85 -42
- package/src/tui/hash-provider.ts +0 -3
- package/src/tui/session-command.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-session-reader",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
29
|
-
"@earendil-works/pi-ai": "^0.84.
|
|
30
|
-
"@earendil-works/pi-tui": "^0.84.
|
|
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
|
|
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
|
-
//
|
|
186
|
+
// 工具层仅覆盖 stats.totalBytes 与 skippedLines(ParseResult 无 totalEntries 字段)。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
|
-
* 覆盖
|
|
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
|
package/src/core/family.ts
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
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,
|
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
|
}
|
|
@@ -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 <
|
|
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
|
}
|
|
@@ -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 文件绝对路径)→
|
|
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
|
|
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,
|
|
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:
|
|
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:
|
|
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 (~
|
|
127
|
-
'find first to locate a session by uuid fragment or name. TUI #references are
|
|
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, ~
|
|
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 实例集合。
|
package/src/tool-handler.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 分层约定(同 scheduler/cw-tool):本文件零 pi 依赖——agentDir 作参数注入,
|
|
5
5
|
* 不调用 getAgentDir(),可完全单测;pi 注册与 getAgentDir() 调用在 index.ts。
|
|
6
6
|
*
|
|
7
|
-
* 按 action 分发到
|
|
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
|
-
|
|
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(
|
|
116
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(DATE_FIELD_WIDTH, '0')}-${String(
|
|
110
117
|
d.getDate(),
|
|
111
|
-
).padStart(
|
|
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(-
|
|
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 ===
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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 =
|
|
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
|
|
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 ? ' ' +
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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 -
|
|
605
|
-
const end = Math.min(text.length, idx + len +
|
|
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 ??
|
|
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,~
|
|
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 ??
|
|
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(
|
|
857
|
+
return `${'='.repeat(EXPORT_SEPARATOR_LEN)}\ntoolResultSummary (${e.id.slice(0, SESSION_ID_PREFIX_LEN)})\n${entryReadableText(e)}`
|
|
834
858
|
}
|
|
835
|
-
return `${'='.repeat(
|
|
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,
|
|
933
|
-
const suffix = turns.length >
|
|
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,
|
|
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 /
|
|
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') /
|
|
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 -
|
|
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),
|
|
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
|
|
1339
|
-
* 走 sessionRefFromPath 文件名最小回退(sessionId+fileName
|
|
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
|
|
1464
|
+
// 全部 run 都跳过的兜底提示(ES-wf-snapshot-read-fail 末段)
|
|
1422
1465
|
let text: string
|
|
1423
1466
|
if (runs.length === 0) {
|
|
1424
1467
|
text =
|
package/src/tui/hash-provider.ts
CHANGED
|
@@ -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
|
|
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())
|