@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.
@@ -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
+ }
@@ -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
+ }