@zhushanwen/pi-session-reader 0.1.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/index.ts +1 -0
- package/package.json +47 -0
- package/src/__tests__/family.test.ts +207 -0
- package/src/__tests__/find.test.ts +214 -0
- package/src/__tests__/hash-provider.test.ts +498 -0
- package/src/__tests__/index.test.ts +193 -0
- package/src/__tests__/parser.test.ts +166 -0
- package/src/__tests__/real-data.ts +53 -0
- package/src/__tests__/render.test.ts +367 -0
- package/src/__tests__/roots.test.ts +132 -0
- package/src/__tests__/session-command.test.ts +198 -0
- package/src/__tests__/subagents.test.ts +430 -0
- package/src/__tests__/tool-handler.test.ts +510 -0
- package/src/__tests__/toolcall.test.ts +256 -0
- package/src/__tests__/tree.test.ts +101 -0
- package/src/__tests__/turns.test.ts +142 -0
- package/src/core/family.ts +240 -0
- package/src/core/parser.ts +160 -0
- package/src/core/render.ts +584 -0
- package/src/core/toolcall.ts +168 -0
- package/src/core/tree.ts +93 -0
- package/src/core/turns.ts +97 -0
- package/src/discovery/find.ts +247 -0
- package/src/discovery/roots.ts +92 -0
- package/src/discovery/subagents.ts +470 -0
- package/src/index.ts +189 -0
- package/src/tool-handler.ts +1160 -0
- package/src/tui/hash-provider.ts +230 -0
- package/src/tui/session-command.ts +85 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* toolCall 提取与摘要的共享层(O1 工具概览 + O2/O3 toolResult 类型化摘要 + O4 extract 复用)。
|
|
3
|
+
*
|
|
4
|
+
* 数据源(probe 019e6c96 实测确认):
|
|
5
|
+
* - 工具调用在 assistant message.content 的 `{type:"toolCall", id, name, arguments}` block(519 个),
|
|
6
|
+
* message.toolCalls 顶层字段从未存在——v1 render.ts 读 toolCalls 恒返 [] 是 O1 要修的 bug。
|
|
7
|
+
* - arguments 始终是 object(10 工具 100%);string 形态做 JSON.parse 兜底(失败返 {}),防御历史/异类实现。
|
|
8
|
+
* - toolResult.message 自带 toolName + toolCallId(515/515,全部匹配 toolCall.id),
|
|
9
|
+
* parser.ts 已 additive 透出这两个字段,O2/O3 据此精确关联取参数。
|
|
10
|
+
*/
|
|
11
|
+
import type { Entry } from './parser.js'
|
|
12
|
+
|
|
13
|
+
/** 单次工具调用信息(从 assistant content 的 toolCall block 提取)。 */
|
|
14
|
+
export interface ToolCallInfo {
|
|
15
|
+
id: string
|
|
16
|
+
name: string
|
|
17
|
+
arguments: Record<string, unknown>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 截断到 max 字符,超出加省略号(与 render.ts 的 truncate 同口径)。 */
|
|
21
|
+
function truncate(s: string, max: number): string {
|
|
22
|
+
return s.length <= max ? s : s.slice(0, max) + '…'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 路径最后一段(去尾部斜杠后取 basename)。 */
|
|
26
|
+
export function basename(path: string): string {
|
|
27
|
+
const clean = path.replace(/\/+$/, '')
|
|
28
|
+
const idx = clean.lastIndexOf('/')
|
|
29
|
+
return idx >= 0 ? clean.slice(idx + 1) : clean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 把 toolCall block 的 arguments 归一化为 Record<string, unknown>。
|
|
34
|
+
* - object(非数组、非 null)→ 原样用
|
|
35
|
+
* - string → JSON.parse 兜底(parse 失败或结果非对象 → {})
|
|
36
|
+
* - 其他(number/boolean/null/undefined/数组)→ {}(实测 0%,纯防御)
|
|
37
|
+
*/
|
|
38
|
+
function coerceArgs(raw: unknown): Record<string, unknown> {
|
|
39
|
+
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
40
|
+
return raw as Record<string, unknown>
|
|
41
|
+
}
|
|
42
|
+
if (typeof raw === 'string') {
|
|
43
|
+
try {
|
|
44
|
+
const parsed: unknown = JSON.parse(raw)
|
|
45
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
46
|
+
return parsed as Record<string, unknown>
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// 非法 JSON 字符串 → 空对象兜底(实测 arguments 全为 object,此分支纯防御)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 从 entry.message.content 的 `{type:"toolCall"}` block 提取工具调用列表。
|
|
57
|
+
*
|
|
58
|
+
* content 是 unknown 做类型守卫;非数组或无 toolCall block 返 []。
|
|
59
|
+
* 仅纳入 id + name 均为 string 的 block(O2 靠 id 关联 toolResult,O1 靠 name 聚合;
|
|
60
|
+
* 缺 id 的块无法关联,缺 name 无法摘要,跳过——实测 0 缺失)。
|
|
61
|
+
*/
|
|
62
|
+
export function extractToolCalls(entry: Entry): ToolCallInfo[] {
|
|
63
|
+
const msg = entry.message
|
|
64
|
+
if (msg === undefined) return []
|
|
65
|
+
const content = msg.content
|
|
66
|
+
if (!Array.isArray(content)) return []
|
|
67
|
+
const out: ToolCallInfo[] = []
|
|
68
|
+
for (const block of content) {
|
|
69
|
+
if (block === null || typeof block !== 'object') continue
|
|
70
|
+
const b = block as Record<string, unknown>
|
|
71
|
+
if (b.type !== 'toolCall') continue
|
|
72
|
+
if (typeof b.id !== 'string' || typeof b.name !== 'string') continue
|
|
73
|
+
out.push({ id: b.id, name: b.name, arguments: coerceArgs(b.arguments) })
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 取 args[k] 为 string,否则 undefined(参数缺失统一入口)。 */
|
|
79
|
+
function strArg(args: Record<string, unknown>, k: string): string | undefined {
|
|
80
|
+
const v = args[k]
|
|
81
|
+
return typeof v === 'string' ? v : undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 取 args[k] 为 number,否则 undefined。 */
|
|
85
|
+
function numArg(args: Record<string, unknown>, k: string): number | undefined {
|
|
86
|
+
const v = args[k]
|
|
87
|
+
return typeof v === 'number' ? v : undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 按工具类型把 ToolCallInfo 映射成参数摘要串(design §3.3 D1 表)。
|
|
92
|
+
*
|
|
93
|
+
* 参数缺失时优雅降级(只输出工具名或省略对应维度)。未知工具 fallback 带 arguments JSON
|
|
94
|
+
* 前 50 字;arguments 为空对象时省略 JSON(`{}` 无信息量)。
|
|
95
|
+
*
|
|
96
|
+
* 单位口径:edit 的 blocks 是参数维度(edits 数组长度),write 的 KB 是参数维度(content 字节),
|
|
97
|
+
* 这两者已含参数规模;O2/O3 的 toolResult 摘要在此基础再加结果规模时,bash/read 单独 append
|
|
98
|
+
* 结果行数/KB(见 render.ts 的 formatToolResultSummary),避免与参数规模重复。
|
|
99
|
+
*/
|
|
100
|
+
export function formatToolCallSummary(tc: ToolCallInfo): string {
|
|
101
|
+
const { name, arguments: args } = tc
|
|
102
|
+
|
|
103
|
+
switch (name) {
|
|
104
|
+
case 'bash': {
|
|
105
|
+
const cmd = strArg(args, 'command')
|
|
106
|
+
return cmd !== undefined ? `bash: ${truncate(cmd, 60)}` : 'bash'
|
|
107
|
+
}
|
|
108
|
+
case 'read': {
|
|
109
|
+
const p = strArg(args, 'path')
|
|
110
|
+
return p !== undefined ? `read: ${basename(p)}` : 'read'
|
|
111
|
+
}
|
|
112
|
+
case 'edit': {
|
|
113
|
+
const p = strArg(args, 'path')
|
|
114
|
+
const edits = args.edits
|
|
115
|
+
const blocks = Array.isArray(edits) ? edits.length : undefined
|
|
116
|
+
const head = p !== undefined ? `edit: ${basename(p)}` : 'edit'
|
|
117
|
+
return blocks !== undefined ? `${head} (${blocks} blocks)` : head
|
|
118
|
+
}
|
|
119
|
+
case 'write': {
|
|
120
|
+
const p = strArg(args, 'path')
|
|
121
|
+
const c = strArg(args, 'content')
|
|
122
|
+
const head = p !== undefined ? `write: ${basename(p)}` : 'write'
|
|
123
|
+
if (c === undefined) return head
|
|
124
|
+
// utf8 字节转 KB 取整;小文件至少 1KB(0KB 无信息量)
|
|
125
|
+
const kb = Math.max(1, Math.round(Buffer.byteLength(c, 'utf8') / 1024))
|
|
126
|
+
return `${head} (${kb}KB)`
|
|
127
|
+
}
|
|
128
|
+
case 'subagent': {
|
|
129
|
+
const task = strArg(args, 'task')
|
|
130
|
+
return task !== undefined ? `subagent: ${truncate(task, 40)}` : 'subagent'
|
|
131
|
+
}
|
|
132
|
+
case 'head': {
|
|
133
|
+
const p = strArg(args, 'path')
|
|
134
|
+
// limit 可能是 number 或 string(实测 number,防御 string)
|
|
135
|
+
const lim: number | string | undefined = numArg(args, 'limit') ?? strArg(args, 'limit')
|
|
136
|
+
const head = p !== undefined ? `head: ${basename(p)}` : 'head'
|
|
137
|
+
return lim !== undefined ? `${head} (${lim})` : head
|
|
138
|
+
}
|
|
139
|
+
case 'todo': {
|
|
140
|
+
const action = strArg(args, 'action')
|
|
141
|
+
if (action === undefined) return 'todo'
|
|
142
|
+
// id 可能是 number(todo 列表 id)或 string
|
|
143
|
+
const id = args.id
|
|
144
|
+
const idStr =
|
|
145
|
+
typeof id === 'string' ? id : typeof id === 'number' ? String(id) : undefined
|
|
146
|
+
return idStr !== undefined ? `todo: ${action}(${idStr})` : `todo: ${action}`
|
|
147
|
+
}
|
|
148
|
+
case 'coding-workflow-gate': {
|
|
149
|
+
const phase = args.phase
|
|
150
|
+
return phase !== undefined ? `cw-gate: phase=${String(phase)}` : 'cw-gate'
|
|
151
|
+
}
|
|
152
|
+
case 'coding-workflow-init': {
|
|
153
|
+
const slug = strArg(args, 'slug')
|
|
154
|
+
return slug !== undefined ? `cw-init: ${slug}` : 'cw-init'
|
|
155
|
+
}
|
|
156
|
+
case 'coding-workflow-phase-start':
|
|
157
|
+
return 'cw-phase-start'
|
|
158
|
+
default: {
|
|
159
|
+
// 未知工具:arguments 非空 → name: <json 前50>;空对象 → 仅 name({} 无信息量)
|
|
160
|
+
if (Object.keys(args).length === 0) return name
|
|
161
|
+
try {
|
|
162
|
+
return `${name}: ${truncate(JSON.stringify(args), 50)}`
|
|
163
|
+
} catch {
|
|
164
|
+
return name
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/core/tree.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Entry } from './parser.js'
|
|
2
|
+
|
|
3
|
+
export interface TreeView {
|
|
4
|
+
/** root → leaf 的 id 序列(pi 重开视角的当前对话线,design D-2) */
|
|
5
|
+
leafPath: string[]
|
|
6
|
+
/** forkPointId(=leafPath 上某节点 id) → 该分叉下旁支子树的 entry 数 */
|
|
7
|
+
branches: Map<string, number>
|
|
8
|
+
/** parentId 指向不存在 entry、且自身不在 leafPath 上的 entry id */
|
|
9
|
+
orphans: string[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 沿祖先链找最近的、属于 leafSet 的分叉点。
|
|
14
|
+
* 返回 null:祖先链触不到 leafSet(多 root 独立子树)或检测到环。
|
|
15
|
+
*
|
|
16
|
+
* 计算旁支子树大小:对每个非主链 entry 沿祖先链归到最近 leafSet 节点计数(design §3.5
|
|
17
|
+
* 算法 2 步骤 5「按 forkPoint 聚合子树大小」)。如 A→D→E 旁支(D、E 都挂在 forkPoint A
|
|
18
|
+
* 下)count=2,而非只数直接子节点。
|
|
19
|
+
*/
|
|
20
|
+
function findForkPoint(
|
|
21
|
+
startId: string | null,
|
|
22
|
+
index: Map<string, Entry>,
|
|
23
|
+
leafSet: Set<string>,
|
|
24
|
+
): string | null {
|
|
25
|
+
let cur = startId
|
|
26
|
+
const seen = new Set<string>()
|
|
27
|
+
while (cur !== null && index.has(cur) && !leafSet.has(cur)) {
|
|
28
|
+
if (seen.has(cur)) return null // 环防御(坏数据)
|
|
29
|
+
seen.add(cur)
|
|
30
|
+
cur = index.get(cur)!.parentId
|
|
31
|
+
}
|
|
32
|
+
if (cur !== null && leafSet.has(cur)) return cur
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 按 design §3.5 算法 2 重建 leaf 路径视图。
|
|
38
|
+
*
|
|
39
|
+
* leafId = entries 最后一条的 id(D-2:pi 重开时把 leafId 重置为文件最后 entry,
|
|
40
|
+
* 即用户 resume 看到的对话线)。从 leafId 沿 parentId 回溯到 root,遇 parentId 不在
|
|
41
|
+
* 索引(断点/孤儿 root)即停。
|
|
42
|
+
*
|
|
43
|
+
* orphans 只收集「不在 leafPath 上 + parentId 指向不存在」的 entry;leafPath 根自身
|
|
44
|
+
* 即使 parentId 断(断点处)也不重复计入——它已有归属(主链根),符合「孤儿=无归属」语义。
|
|
45
|
+
* design 步骤 6 字面未排除 leafPath 节点,此处按语义实现。
|
|
46
|
+
*/
|
|
47
|
+
export function buildTreeView(entries: Entry[]): TreeView {
|
|
48
|
+
const leafPath: string[] = []
|
|
49
|
+
const branches = new Map<string, number>()
|
|
50
|
+
const orphans: string[] = []
|
|
51
|
+
|
|
52
|
+
if (entries.length === 0) {
|
|
53
|
+
return { leafPath, branches, orphans }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 1. id→entry 索引(同 id 后写覆盖先写)
|
|
57
|
+
const index = new Map<string, Entry>()
|
|
58
|
+
for (const e of entries) index.set(e.id, e)
|
|
59
|
+
|
|
60
|
+
// 2. leafId = 最后一条 entry 的 id
|
|
61
|
+
const leafId = entries[entries.length - 1].id
|
|
62
|
+
|
|
63
|
+
// 3. 从 leafId 沿 parentId 回溯到 root
|
|
64
|
+
let cur: string | null = leafId
|
|
65
|
+
const backtrackSeen = new Set<string>()
|
|
66
|
+
while (cur !== null && index.has(cur)) {
|
|
67
|
+
if (backtrackSeen.has(cur)) break // 环防御
|
|
68
|
+
backtrackSeen.add(cur)
|
|
69
|
+
leafPath.unshift(cur)
|
|
70
|
+
cur = index.get(cur)!.parentId
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 4. leafSet
|
|
74
|
+
const leafSet = new Set(leafPath)
|
|
75
|
+
|
|
76
|
+
// 5-6. 旁支计数 + 孤儿收集
|
|
77
|
+
for (const e of entries) {
|
|
78
|
+
if (leafSet.has(e.id)) continue // 主链节点
|
|
79
|
+
const pid = e.parentId
|
|
80
|
+
if (pid === null) continue // 独立 root(多 root 边界):不在当前 leaf 视图,不计旁支不计孤儿
|
|
81
|
+
if (!index.has(pid)) {
|
|
82
|
+
orphans.push(e.id)
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
const fp = findForkPoint(pid, index, leafSet)
|
|
86
|
+
if (fp !== null) {
|
|
87
|
+
branches.set(fp, (branches.get(fp) ?? 0) + 1)
|
|
88
|
+
}
|
|
89
|
+
// fp===null:祖先链触不到 leafSet(多 root 独立子树),静默忽略
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { leafPath, branches, orphans }
|
|
93
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Entry } from './parser.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 一轮对话(design §3.5 算法 3 的分段产物,冻结接口)。
|
|
5
|
+
*
|
|
6
|
+
* - user turn:由 user message 开启,userEntry 指向该 user entry,isCompaction=false。
|
|
7
|
+
* - compaction turn:由 compaction entry 开启(语义断点),无 userEntry,isCompaction=true。
|
|
8
|
+
* - 前置 turn(preface):首条 user/compaction 之前出现的 rule-4 entry
|
|
9
|
+
* (assistant / model_change / thinking_level_change / custom / branch_summary)无处并入时
|
|
10
|
+
* 单独成 turn 0,无 userEntry,isCompaction=false。
|
|
11
|
+
*/
|
|
12
|
+
export interface Turn {
|
|
13
|
+
index: number
|
|
14
|
+
startTime?: string
|
|
15
|
+
/** 该 turn 全部 entry(含开启条 user/compaction + 后续并入条) */
|
|
16
|
+
entries: Entry[]
|
|
17
|
+
/** turn 起点;compaction turn 与前置 turn 无 userEntry */
|
|
18
|
+
userEntry?: Entry
|
|
19
|
+
isCompaction: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 按 design §3.5 算法 3 把 leaf 视图 entry 序列分段为 turn。
|
|
24
|
+
*
|
|
25
|
+
* 严格按优先级(先命中先生效):
|
|
26
|
+
* 1. `session` header → 忽略,不计 turn
|
|
27
|
+
* 2. `compaction` → 关闭当前 turn,开新 turn(isCompaction=true,无 userEntry)
|
|
28
|
+
* 3. `message` role=user → 关闭当前 turn,开新 turn(userEntry=user)
|
|
29
|
+
* 4. 其余(assistant/toolResult/custom/model_change/thinking_level_change/branch_summary
|
|
30
|
+
* 等一切非上述类型)→ 并入当前 turn
|
|
31
|
+
* 5. branch 边界:entry.id ∉ leafSet → 直接跳过(归旁支,不计 leaf 视图 turn)
|
|
32
|
+
*
|
|
33
|
+
* 孤儿处理:rule-4 entry 在首条 user/compaction 之前出现(无 current turn)→ 单独成「前置」turn。
|
|
34
|
+
* index 从 0 连续递增。空 entries(或 leafSet 全空)→ 返回 []。
|
|
35
|
+
*/
|
|
36
|
+
export function segmentTurns(entries: Entry[], leafSet: Set<string>): Turn[] {
|
|
37
|
+
const turns: Turn[] = []
|
|
38
|
+
let current: Turn | null = null
|
|
39
|
+
|
|
40
|
+
const closeCurrent = (): void => {
|
|
41
|
+
if (current !== null) {
|
|
42
|
+
turns.push(current)
|
|
43
|
+
current = null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
// 规则 1:session header 忽略
|
|
49
|
+
if (entry.type === 'session') continue
|
|
50
|
+
// 规则 5:branch 边界——不在 leaf 视图,跳过
|
|
51
|
+
if (!leafSet.has(entry.id)) continue
|
|
52
|
+
|
|
53
|
+
if (entry.type === 'compaction') {
|
|
54
|
+
// 规则 2:compaction 是语义断点,关闭当前并开新 compaction turn
|
|
55
|
+
closeCurrent()
|
|
56
|
+
current = {
|
|
57
|
+
index: 0,
|
|
58
|
+
entries: [entry],
|
|
59
|
+
isCompaction: true,
|
|
60
|
+
}
|
|
61
|
+
if (entry.timestamp !== undefined) current.startTime = entry.timestamp
|
|
62
|
+
} else if (
|
|
63
|
+
entry.type === 'message' &&
|
|
64
|
+
entry.message !== undefined &&
|
|
65
|
+
entry.message.role === 'user'
|
|
66
|
+
) {
|
|
67
|
+
// 规则 3:user 开启新 turn
|
|
68
|
+
closeCurrent()
|
|
69
|
+
current = {
|
|
70
|
+
index: 0,
|
|
71
|
+
entries: [entry],
|
|
72
|
+
userEntry: entry,
|
|
73
|
+
isCompaction: false,
|
|
74
|
+
}
|
|
75
|
+
if (entry.timestamp !== undefined) current.startTime = entry.timestamp
|
|
76
|
+
} else {
|
|
77
|
+
// 规则 4:并入当前 turn;无 current(孤儿前置)则单独成 preface turn
|
|
78
|
+
if (current === null) {
|
|
79
|
+
current = {
|
|
80
|
+
index: 0,
|
|
81
|
+
entries: [entry],
|
|
82
|
+
isCompaction: false,
|
|
83
|
+
}
|
|
84
|
+
if (entry.timestamp !== undefined) current.startTime = entry.timestamp
|
|
85
|
+
} else {
|
|
86
|
+
current.entries.push(entry)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
closeCurrent()
|
|
91
|
+
|
|
92
|
+
// index 从 0 连续递增(分段过程中先占位 0,最后统一赋值)
|
|
93
|
+
for (let i = 0; i < turns.length; i++) {
|
|
94
|
+
turns[i].index = i
|
|
95
|
+
}
|
|
96
|
+
return turns
|
|
97
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { createReadStream, type ReadStream } from 'node:fs'
|
|
2
|
+
import { open, type FileHandle } from 'node:fs/promises'
|
|
3
|
+
import { createInterface } from 'node:readline'
|
|
4
|
+
import type { SessionRef } from '../core/family.js'
|
|
5
|
+
import { listMainSessions, type SessionFileMeta } from './roots.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* M2 discovery 发现层:按 query 定位 session(design §3.3 D-3 + §3.4 find action)。
|
|
9
|
+
*
|
|
10
|
+
* 匹配三路(D-3):
|
|
11
|
+
* - uuid 片段子串:sessionId 含 query,或文件路径含 query
|
|
12
|
+
* - "recent" 特殊值:按 mtime 倒序返回最近 N 个(不经片段匹配)
|
|
13
|
+
* - 名称关键词:首消息预览含 query(fallback,仅在 uuid 片段零匹配且 query 非 uuid 特征时
|
|
14
|
+
* 对候选深读首消息——D-5:不为定位付全文解析成本)
|
|
15
|
+
*
|
|
16
|
+
* 首行扫描策略(D-5):先全量首行扫描拿 header(id/cwd/parentSession),不做全文解析;
|
|
17
|
+
* 首消息预览仅在需要时(recent/uuid 匹配的最终结果 + 关键词 fallback)对候选单独深读。
|
|
18
|
+
*
|
|
19
|
+
* agentDir 注入:同 roots.ts,零 pi 依赖(仅 node:fs + 相对 import M1 core)。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface MatchedSession extends SessionRef {
|
|
23
|
+
/** 首条 user message text 截 80 字符(从全文读,不只首行) */
|
|
24
|
+
firstMessagePreview?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_LIMIT = 20
|
|
28
|
+
const PREVIEW_MAX = 80
|
|
29
|
+
/** readFirstLine 单次读取 buffer 上限。session header(id/cwd/parentSession)远小于此。 */
|
|
30
|
+
const HEADER_READ_BYTES = 8192
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 读文件首行(header)。用定长 buffer 一次 read(避免 stream 开销),
|
|
34
|
+
* 空文件/读失败返回 undefined。header 超 8KB 的极端情况会截断致 parse 失败——
|
|
35
|
+
* session header(id+cwd)实测 < 300 字节,8KB 足够 27 倍余量。
|
|
36
|
+
*/
|
|
37
|
+
async function readFirstLine(path: string): Promise<string | undefined> {
|
|
38
|
+
let fh: FileHandle | undefined
|
|
39
|
+
try {
|
|
40
|
+
fh = await open(path, 'r')
|
|
41
|
+
const buf = Buffer.alloc(HEADER_READ_BYTES)
|
|
42
|
+
const { bytesRead } = await fh.read(buf, 0, HEADER_READ_BYTES, 0)
|
|
43
|
+
if (bytesRead === 0) return undefined
|
|
44
|
+
const content = buf.subarray(0, bytesRead).toString('utf8')
|
|
45
|
+
const nl = content.indexOf('\n')
|
|
46
|
+
return nl === -1 ? content : content.slice(0, nl)
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined
|
|
49
|
+
} finally {
|
|
50
|
+
await fh?.close().catch(() => {})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 从单行 JSON 提取 message entry 的 user role 文本,非 user message 行返回 undefined。 */
|
|
55
|
+
function extractUserText(line: string): string | undefined {
|
|
56
|
+
let raw: unknown
|
|
57
|
+
try {
|
|
58
|
+
raw = JSON.parse(line)
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
if (typeof raw !== 'object' || raw === null) return undefined
|
|
63
|
+
const obj = raw as Record<string, unknown>
|
|
64
|
+
if (obj.type !== 'message') return undefined
|
|
65
|
+
const msg = obj.message
|
|
66
|
+
if (typeof msg !== 'object' || msg === null) return undefined
|
|
67
|
+
const m = msg as Record<string, unknown>
|
|
68
|
+
if (m.role !== 'user') return undefined
|
|
69
|
+
return extractTextFromContent(m.content)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 从 message content 提取可读文本。
|
|
74
|
+
* 兼容 pi 两种形态:string content(直接用)与 array content(拼 type:text 项的 text)。
|
|
75
|
+
*/
|
|
76
|
+
function extractTextFromContent(content: unknown): string | undefined {
|
|
77
|
+
if (typeof content === 'string') return content
|
|
78
|
+
if (Array.isArray(content)) {
|
|
79
|
+
const parts: string[] = []
|
|
80
|
+
for (const item of content) {
|
|
81
|
+
if (typeof item === 'object' && item !== null) {
|
|
82
|
+
const it = item as Record<string, unknown>
|
|
83
|
+
if (it.type === 'text' && typeof it.text === 'string') {
|
|
84
|
+
parts.push(it.text)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return parts.length > 0 ? parts.join(' ') : undefined
|
|
89
|
+
}
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 读文件首条 user message 的文本。逐行扫描直到命中 role:user(不读全文,命中即停 stream)。
|
|
95
|
+
* 用于名称关键词匹配 + firstMessagePreview 填充。
|
|
96
|
+
*/
|
|
97
|
+
async function readFirstUserMessageText(path: string): Promise<string | undefined> {
|
|
98
|
+
let stream: ReadStream | undefined
|
|
99
|
+
try {
|
|
100
|
+
stream = createReadStream(path, { encoding: 'utf8' })
|
|
101
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity })
|
|
102
|
+
try {
|
|
103
|
+
for await (const line of rl) {
|
|
104
|
+
const text = extractUserText(line)
|
|
105
|
+
if (text !== undefined) return text
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
rl.close()
|
|
109
|
+
}
|
|
110
|
+
return undefined // 无 user message(如纯 compaction session)
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined
|
|
113
|
+
} finally {
|
|
114
|
+
stream?.destroy()
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface SessionHeader {
|
|
119
|
+
id: string
|
|
120
|
+
cwd?: string
|
|
121
|
+
parentSession?: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 解析 header 首行为 SessionHeader。非 session 行/缺 id → null。 */
|
|
125
|
+
function parseHeader(line: string | undefined): SessionHeader | null {
|
|
126
|
+
if (!line) return null
|
|
127
|
+
let raw: unknown
|
|
128
|
+
try {
|
|
129
|
+
raw = JSON.parse(line)
|
|
130
|
+
} catch {
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
if (typeof raw !== 'object' || raw === null) return null
|
|
134
|
+
const obj = raw as Record<string, unknown>
|
|
135
|
+
if (obj.type !== 'session' || typeof obj.id !== 'string') return null
|
|
136
|
+
const header: SessionHeader = { id: obj.id }
|
|
137
|
+
if (typeof obj.cwd === 'string') header.cwd = obj.cwd
|
|
138
|
+
if (typeof obj.parentSession === 'string') header.parentSession = obj.parentSession
|
|
139
|
+
return header
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* query 是否具备 uuid 片段特征(仅十六进制字符与连字符)。
|
|
144
|
+
* 用于 uuid 片段零匹配时决定是否走名称关键词 fallback:纯十六进制 query(如 e6c96、019fe635)
|
|
145
|
+
* 几乎不会出现在自然语言首消息里,深读首消息徒劳,跳过;含非十六进制字符的 query(如 plugin、
|
|
146
|
+
* 重构)才走 fallback。边界词(如 abc,恰好全十六进制)会被判 uuid 特征不走 fallback——
|
|
147
|
+
* 可接受(abc 作为首消息关键词罕见,且 uuid 片段匹配已先尝试)。
|
|
148
|
+
*/
|
|
149
|
+
function looksLikeUuidFragment(query: string): boolean {
|
|
150
|
+
return /^[0-9a-f-]+$/i.test(query)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface Candidate {
|
|
154
|
+
meta: SessionFileMeta
|
|
155
|
+
ref: SessionRef
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface Matched extends Candidate {
|
|
159
|
+
/** 名称关键词匹配路径已读出的预览;recent/uuid 路径 undefined,后续按需补读 */
|
|
160
|
+
preview?: string
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 按 query 找 session(接口冻结,design §3.4 find action)。
|
|
165
|
+
*
|
|
166
|
+
* 返回按 mtime 倒序,limit 截断(默认 20),truncated 标记是否截断。
|
|
167
|
+
* cwd 过滤:opts.cwd 提供时只留 header.cwd === opts.cwd 的(在匹配前过滤,减少 fallback 深读量)。
|
|
168
|
+
* 匹配为空 → `{ matches: [], truncated: false }`(F1 恢复指引在 M3 tool-adapter 层)。
|
|
169
|
+
*/
|
|
170
|
+
export async function findSessions(
|
|
171
|
+
query: string,
|
|
172
|
+
agentDir: string,
|
|
173
|
+
opts?: { cwd?: string; limit?: number },
|
|
174
|
+
): Promise<{ matches: MatchedSession[]; truncated: boolean }> {
|
|
175
|
+
const limit = opts?.limit ?? DEFAULT_LIMIT
|
|
176
|
+
const cwdFilter = opts?.cwd
|
|
177
|
+
const files = await listMainSessions(agentDir)
|
|
178
|
+
|
|
179
|
+
// 1. 首行扫描所有文件拿 header,建候选 SessionRef(cwd 过滤在此应用)
|
|
180
|
+
const candidates: Candidate[] = []
|
|
181
|
+
for (const meta of files) {
|
|
182
|
+
const headerLine = await readFirstLine(meta.path)
|
|
183
|
+
const header = parseHeader(headerLine)
|
|
184
|
+
if (!header) continue // 非 session 文件/坏 header → 跳过
|
|
185
|
+
if (cwdFilter !== undefined && (header.cwd ?? '') !== cwdFilter) continue
|
|
186
|
+
const ref: SessionRef = {
|
|
187
|
+
sessionId: header.id,
|
|
188
|
+
// 完整绝对路径(与 parentSession 同构,便于 family 按 includes(sid) 反查)
|
|
189
|
+
fileName: meta.path,
|
|
190
|
+
mtime: meta.mtime,
|
|
191
|
+
sizeBytes: meta.size,
|
|
192
|
+
cwd: header.cwd ?? '',
|
|
193
|
+
}
|
|
194
|
+
if (header.parentSession) ref.parentSession = header.parentSession
|
|
195
|
+
candidates.push({ meta, ref })
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 2. 匹配
|
|
199
|
+
let matched: Matched[]
|
|
200
|
+
if (query === 'recent') {
|
|
201
|
+
// recent:不经片段匹配,全部候选按 mtime 倒序后截 limit
|
|
202
|
+
matched = candidates.map((c) => ({ ...c }))
|
|
203
|
+
} else {
|
|
204
|
+
// 先 uuid 片段匹配(sessionId 或文件路径含 query)——cheap,已有 header
|
|
205
|
+
const uuidHits = candidates.filter(
|
|
206
|
+
(c) => c.ref.sessionId.includes(query) || c.meta.path.includes(query),
|
|
207
|
+
)
|
|
208
|
+
if (uuidHits.length > 0) {
|
|
209
|
+
matched = uuidHits.map((c) => ({ ...c }))
|
|
210
|
+
} else if (looksLikeUuidFragment(query)) {
|
|
211
|
+
// query 像 uuid 片段但无匹配 → uuid 写错的可能性高,不对全部候选深读首消息
|
|
212
|
+
matched = []
|
|
213
|
+
} else {
|
|
214
|
+
// 名称关键词 fallback:读所有候选首消息,预览含 query 入选
|
|
215
|
+
const keywordHits: Matched[] = []
|
|
216
|
+
for (const c of candidates) {
|
|
217
|
+
const text = await readFirstUserMessageText(c.meta.path)
|
|
218
|
+
if (text && text.includes(query)) {
|
|
219
|
+
keywordHits.push({ ...c, preview: text.slice(0, PREVIEW_MAX) })
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
matched = keywordHits
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 3. mtime 倒序
|
|
227
|
+
matched.sort((a, b) => b.ref.mtime - a.ref.mtime)
|
|
228
|
+
|
|
229
|
+
// 4. 截断
|
|
230
|
+
const truncated = matched.length > limit
|
|
231
|
+
const sliced = truncated ? matched.slice(0, limit) : matched
|
|
232
|
+
|
|
233
|
+
// 5. 填 firstMessagePreview(recent/uuid 路径未读,这里对最终 limit 个补读——最多 limit 个 IO)
|
|
234
|
+
const result: MatchedSession[] = []
|
|
235
|
+
for (const m of sliced) {
|
|
236
|
+
const out: MatchedSession = { ...m.ref }
|
|
237
|
+
if (m.preview !== undefined) {
|
|
238
|
+
out.firstMessagePreview = m.preview
|
|
239
|
+
} else {
|
|
240
|
+
const text = await readFirstUserMessageText(m.meta.path)
|
|
241
|
+
if (text) out.firstMessagePreview = text.slice(0, PREVIEW_MAX)
|
|
242
|
+
}
|
|
243
|
+
result.push(out)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { matches: result, truncated }
|
|
247
|
+
}
|