@zhushanwen/pi-session-reader 0.5.1 → 0.6.1
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 +6 -3
- package/src/__tests__/cross-package-subagent-core.test.ts +42 -49
- package/src/__tests__/env.test.ts +54 -54
- package/src/__tests__/family.test.ts +0 -7
- package/src/__tests__/find.test.ts +6 -6
- package/src/__tests__/index.test.ts +1 -1
- package/src/__tests__/real-data.ts +23 -10
- package/src/__tests__/render.test.ts +36 -1
- package/src/__tests__/result.test.ts +20 -23
- package/src/__tests__/roots.test.ts +47 -81
- package/src/__tests__/session-command.test.ts +23 -0
- package/src/__tests__/subagents.test.ts +5 -7
- package/src/__tests__/tool-handler.test.ts +481 -216
- package/src/__tests__/workflow.test.ts +1 -7
- package/src/core/execution-tree.ts +1 -0
- package/src/core/family.ts +12 -9
- package/src/core/render.ts +41 -14
- package/src/core/toolcall.ts +1 -2
- package/src/core/units.ts +14 -0
- package/src/core/workflow.ts +7 -20
- package/src/discovery/env.ts +41 -41
- package/src/discovery/find.ts +43 -135
- package/src/discovery/roots.ts +18 -89
- package/src/discovery/session-header.ts +103 -0
- package/src/discovery/subagents.ts +23 -94
- package/src/doctor.ts +12 -143
- package/src/extract.ts +6 -19
- package/src/handler-utils.ts +28 -5
- package/src/index.ts +2 -2
- package/src/no-match.ts +3 -2
- package/src/result-action.ts +29 -34
- package/src/search-across.ts +6 -8
- package/src/tool-handler.ts +151 -176
- package/src/tui/hash-provider.ts +7 -3
- package/src/tui/session-command.ts +22 -10
- package/vitest.config.ts +2 -4
package/src/discovery/find.ts
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import { createReadStream, type ReadStream } from 'node:fs'
|
|
2
|
-
import {
|
|
2
|
+
import { stat } from 'node:fs/promises'
|
|
3
3
|
import { createInterface } from 'node:readline'
|
|
4
4
|
import { basename, dirname } from 'node:path'
|
|
5
5
|
import type { SessionRef } from '../core/family.js'
|
|
6
|
+
import { textBlockParts } from '../core/render.js'
|
|
6
7
|
import {
|
|
7
8
|
resolveSessionRoots,
|
|
8
9
|
type SessionFileMeta,
|
|
9
10
|
type SessionRoot,
|
|
10
11
|
type SessionRootSignals,
|
|
11
12
|
} from './roots.js'
|
|
12
|
-
|
|
13
|
+
// 首行 header 读取/解析在 discovery/session-header.ts(D5 单源)。
|
|
14
|
+
import {
|
|
15
|
+
parseSessionHeader,
|
|
16
|
+
readSessionHeaderFirstLine,
|
|
17
|
+
type SessionHeader,
|
|
18
|
+
} from './session-header.js'
|
|
19
|
+
import {
|
|
20
|
+
listRecordManifests,
|
|
21
|
+
extractSessionIdFromFilename,
|
|
22
|
+
readTailIdentity,
|
|
23
|
+
type RecordManifest,
|
|
24
|
+
} from './subagents.js'
|
|
13
25
|
|
|
14
26
|
/**
|
|
15
27
|
* M2 discovery 发现层:按 query 定位 session(design §3.3 D-3 + §3.4 find action)。
|
|
@@ -59,8 +71,8 @@ export interface SessionMetadataEntry {
|
|
|
59
71
|
cwd: string
|
|
60
72
|
/** 用户标题(session_info entry 的 name);旧 session 缺失 */
|
|
61
73
|
name?: string
|
|
62
|
-
/** pi 返回 Date
|
|
63
|
-
modified: Date
|
|
74
|
+
/** pi 返回 Date(A4 收窄:原 `Date | number` 宽联合只服务测试替身,替身改传 Date) */
|
|
75
|
+
modified: Date
|
|
64
76
|
/** 首消息全文(title 命中时填充 firstMessagePreview,免二次读文件) */
|
|
65
77
|
firstMessage?: string
|
|
66
78
|
}
|
|
@@ -74,30 +86,6 @@ export type SessionMetadataProvider = (dir: string) => Promise<SessionMetadataEn
|
|
|
74
86
|
|
|
75
87
|
const DEFAULT_LIMIT = 20
|
|
76
88
|
const PREVIEW_MAX = 80
|
|
77
|
-
/** readFirstLine 单次读取 buffer 上限。session header(id/cwd/parentSession)远小于此。 */
|
|
78
|
-
const HEADER_READ_BYTES = 8192
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* 读文件首行(header)。用定长 buffer 一次 read(避免 stream 开销),
|
|
82
|
-
* 空文件/读失败返回 undefined。header 超 8KB 的极端情况会截断致 parse 失败——
|
|
83
|
-
* session header(id+cwd)实测 < 300 字节,8KB 足够 27 倍余量。
|
|
84
|
-
*/
|
|
85
|
-
async function readFirstLine(path: string): Promise<string | undefined> {
|
|
86
|
-
let fh: FileHandle | undefined
|
|
87
|
-
try {
|
|
88
|
-
fh = await open(path, 'r')
|
|
89
|
-
const buf = Buffer.alloc(HEADER_READ_BYTES)
|
|
90
|
-
const { bytesRead } = await fh.read(buf, 0, HEADER_READ_BYTES, 0)
|
|
91
|
-
if (bytesRead === 0) return undefined
|
|
92
|
-
const content = buf.subarray(0, bytesRead).toString('utf8')
|
|
93
|
-
const nl = content.indexOf('\n')
|
|
94
|
-
return nl === -1 ? content : content.slice(0, nl)
|
|
95
|
-
} catch {
|
|
96
|
-
return undefined
|
|
97
|
-
} finally {
|
|
98
|
-
await fh?.close().catch(() => {})
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
89
|
|
|
102
90
|
/** 从单行 JSON 提取 message entry 的 user role 文本,非 user message 行返回 undefined。 */
|
|
103
91
|
function extractUserText(line: string): string | undefined {
|
|
@@ -120,22 +108,12 @@ function extractUserText(line: string): string | undefined {
|
|
|
120
108
|
/**
|
|
121
109
|
* 从 message content 提取可读文本。
|
|
122
110
|
* 兼容 pi 两种形态:string content(直接用)与 array content(拼 type:text 项的 text)。
|
|
111
|
+
* E11 归一:共享核 textBlockParts 组合调用,' ' join 与零 text 块返 undefined 的语义
|
|
112
|
+
* 留在本调用点(find 匹配把 undefined 当「无文本」信号,空串会被 includes('') 误吸)。
|
|
123
113
|
*/
|
|
124
114
|
function extractTextFromContent(content: unknown): string | undefined {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const parts: string[] = []
|
|
128
|
-
for (const item of content) {
|
|
129
|
-
if (typeof item === 'object' && item !== null) {
|
|
130
|
-
const it = item as Record<string, unknown>
|
|
131
|
-
if (it.type === 'text' && typeof it.text === 'string') {
|
|
132
|
-
parts.push(it.text)
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
return parts.length > 0 ? parts.join(' ') : undefined
|
|
137
|
-
}
|
|
138
|
-
return undefined
|
|
115
|
+
const parts = textBlockParts(content)
|
|
116
|
+
return parts.length > 0 ? parts.join(' ') : undefined
|
|
139
117
|
}
|
|
140
118
|
|
|
141
119
|
/**
|
|
@@ -163,30 +141,6 @@ async function readFirstUserMessageText(path: string): Promise<string | undefine
|
|
|
163
141
|
}
|
|
164
142
|
}
|
|
165
143
|
|
|
166
|
-
interface SessionHeader {
|
|
167
|
-
id: string
|
|
168
|
-
cwd?: string
|
|
169
|
-
parentSession?: string
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** 解析 header 首行为 SessionHeader。非 session 行/缺 id → null。 */
|
|
173
|
-
function parseHeader(line: string | undefined): SessionHeader | null {
|
|
174
|
-
if (!line) return null
|
|
175
|
-
let raw: unknown
|
|
176
|
-
try {
|
|
177
|
-
raw = JSON.parse(line)
|
|
178
|
-
} catch {
|
|
179
|
-
return null
|
|
180
|
-
}
|
|
181
|
-
if (typeof raw !== 'object' || raw === null) return null
|
|
182
|
-
const obj = raw as Record<string, unknown>
|
|
183
|
-
if (obj.type !== 'session' || typeof obj.id !== 'string') return null
|
|
184
|
-
const header: SessionHeader = { id: obj.id }
|
|
185
|
-
if (typeof obj.cwd === 'string') header.cwd = obj.cwd
|
|
186
|
-
if (typeof obj.parentSession === 'string') header.parentSession = obj.parentSession
|
|
187
|
-
return header
|
|
188
|
-
}
|
|
189
|
-
|
|
190
144
|
/**
|
|
191
145
|
* uuid 归一化键(design 2026-09-10 §6.7 子决策 1):小写 + 去连字符。
|
|
192
146
|
* 修复 §3.3 的两个盲区——大写 uuid(String.includes 大小写敏感)与去连字符 uuid
|
|
@@ -230,59 +184,6 @@ interface Matched extends Candidate {
|
|
|
230
184
|
// U5:subagent task/slug/agentName 匹配(manifest 索引 + P-fallback identity 回退)
|
|
231
185
|
// ============================================================
|
|
232
186
|
|
|
233
|
-
/** P-fallback 尾行 identity 读取窗口(同 subagents.ts,task 文本可达数 KB)。 */
|
|
234
|
-
const TAIL_READ_BYTES = 65536
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* 读 subagent 文件尾部(最后 64KB)找 subagent-identity entry,返回 task/slug/agent。
|
|
238
|
-
*
|
|
239
|
-
* find 的 P-fallback 路径(场景 A:subagent 无 manifest,本机 11.5%):manifest 索引未命中时
|
|
240
|
-
* 读尾行 identity 取 task/slug/agent 做 query 子串匹配。与 subagents.ts 的 readTailIdentity
|
|
241
|
-
* 同源(64KB 窗口 + lastIndexOf 定位),但是 find 专用最小版(只取 task/slug/agent,不要
|
|
242
|
-
* rootSessionId——find 候选已有 header.id)。不导出,不碰 subagents.ts(w3 冻结)。
|
|
243
|
-
*/
|
|
244
|
-
async function readTailIdentityForMatch(
|
|
245
|
-
path: string,
|
|
246
|
-
size: number,
|
|
247
|
-
): Promise<{ task?: string; slug?: string; agent?: string } | undefined> {
|
|
248
|
-
if (size === 0) return undefined
|
|
249
|
-
let fh: FileHandle | undefined
|
|
250
|
-
try {
|
|
251
|
-
fh = await open(path, 'r')
|
|
252
|
-
const len = Math.min(TAIL_READ_BYTES, size)
|
|
253
|
-
const buf = Buffer.alloc(len)
|
|
254
|
-
await fh.read(buf, 0, len, Math.max(0, size - len))
|
|
255
|
-
const text = buf.toString('utf8')
|
|
256
|
-
const idx = text.lastIndexOf('subagent-identity')
|
|
257
|
-
if (idx < 0) return undefined
|
|
258
|
-
const lineStartSearch = text.lastIndexOf('\n', idx)
|
|
259
|
-
if (lineStartSearch < 0 && size > len) return undefined
|
|
260
|
-
const start = lineStartSearch < 0 ? 0 : lineStartSearch + 1
|
|
261
|
-
let end = text.indexOf('\n', idx)
|
|
262
|
-
if (end < 0) end = text.length
|
|
263
|
-
const line = text.slice(start, end)
|
|
264
|
-
let raw: unknown
|
|
265
|
-
try {
|
|
266
|
-
raw = JSON.parse(line)
|
|
267
|
-
} catch {
|
|
268
|
-
return undefined
|
|
269
|
-
}
|
|
270
|
-
const data = (raw as Record<string, unknown> | undefined)?.data as
|
|
271
|
-
| Record<string, unknown>
|
|
272
|
-
| undefined
|
|
273
|
-
if (!data) return undefined
|
|
274
|
-
return {
|
|
275
|
-
task: typeof data.task === 'string' ? data.task : undefined,
|
|
276
|
-
slug: typeof data.slug === 'string' ? data.slug : undefined,
|
|
277
|
-
agent: typeof data.agent === 'string' ? data.agent : undefined,
|
|
278
|
-
}
|
|
279
|
-
} catch {
|
|
280
|
-
return undefined
|
|
281
|
-
} finally {
|
|
282
|
-
await fh?.close().catch(() => {})
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
187
|
/**
|
|
287
188
|
* 建 sessionId→RecordManifest 索引(U5:subagent task/slug/agentName 匹配用)。
|
|
288
189
|
*
|
|
@@ -325,8 +226,11 @@ async function matchSubagentMetadata(
|
|
|
325
226
|
(manifest.agentName?.includes(query) ?? false)
|
|
326
227
|
)
|
|
327
228
|
}
|
|
328
|
-
// P-fallback:manifest 索引未命中 → 读尾行 identity
|
|
329
|
-
|
|
229
|
+
// P-fallback:manifest 索引未命中 → 读尾行 identity 回退。E8(ext-simplify-04):
|
|
230
|
+
// 复用 subagents.ts 的 readTailIdentity 单实现,取 task/slug/agent 子集。行为差异 =
|
|
231
|
+
// 缺 rootSessionId 的畸形 identity 行从「可匹配」变「不匹配」(m0 契约外数据,
|
|
232
|
+
// design §3.3 E8 登记为可接受)。
|
|
233
|
+
const ident = await readTailIdentity(candidate.meta.path, candidate.meta.size)
|
|
330
234
|
if (!ident) return false
|
|
331
235
|
return (
|
|
332
236
|
(ident.task?.includes(query) ?? false) ||
|
|
@@ -362,9 +266,9 @@ function buildCandidate(
|
|
|
362
266
|
*
|
|
363
267
|
* u11 起直接消费 resolveSessionRoots 的根列表(单次实扫,files 与根归属信息同批产出——
|
|
364
268
|
* 标题窄化策略需要「扫描结果无子目录的候选根」这一根级事实,薄包装的扁平列表给不出)。
|
|
365
|
-
* 对只含 agentDir 的信号包,根集合 = [default]+[legacy]+[subagent]
|
|
366
|
-
* listMainSessions/listSubagentSessions
|
|
367
|
-
* realpath 去重语义);传 liveSessionDir 时按 §6.1 追加 [live] 根(realpath 去重保优先级)。
|
|
269
|
+
* 对只含 agentDir 的信号包,根集合 = [default]+[legacy]+[subagent],与已删除薄包装
|
|
270
|
+
* listMainSessions/listSubagentSessions(ext-simplify-04 U4/A3)原并集逐文件一致(含
|
|
271
|
+
* workflow-state 跳过与 realpath 去重语义);传 liveSessionDir 时按 §6.1 追加 [live] 根(realpath 去重保优先级)。
|
|
368
272
|
*/
|
|
369
273
|
async function collectCandidates(
|
|
370
274
|
roots: SessionRoot[],
|
|
@@ -376,7 +280,7 @@ async function collectCandidates(
|
|
|
376
280
|
if (root.dedupedInto !== undefined) continue // 被去重根未实扫(files 恒空),不产候选
|
|
377
281
|
if (sourceFilter !== undefined && root.source !== sourceFilter) continue
|
|
378
282
|
for (const meta of root.files) {
|
|
379
|
-
const header =
|
|
283
|
+
const header = parseSessionHeader(await readSessionHeaderFirstLine(meta.path))
|
|
380
284
|
if (!header) continue // 非 session 文件/坏 header → 跳过
|
|
381
285
|
if (cwdFilter !== undefined && (header.cwd ?? '') !== cwdFilter) continue
|
|
382
286
|
candidates.push(buildCandidate(meta, header, root.source))
|
|
@@ -409,11 +313,6 @@ async function pathExistsDir(path: string): Promise<boolean> {
|
|
|
409
313
|
}
|
|
410
314
|
}
|
|
411
315
|
|
|
412
|
-
/** 条目 modified 的可比数值(pi 返回 Date,测试替身可传 number)。 */
|
|
413
|
-
function modifiedOf(e: SessionMetadataEntry): number {
|
|
414
|
-
return typeof e.modified === 'number' ? e.modified : e.modified.getTime()
|
|
415
|
-
}
|
|
416
|
-
|
|
417
316
|
/**
|
|
418
317
|
* 平铺目录判据(§6.6 调用策略 ②):本根实扫出的全部 .jsonl 都直接位于根本身
|
|
419
318
|
*(即扫描结果无子目录)。listAll(dir) 实装语义是「只扫一层平铺目录」——平铺根意味着
|
|
@@ -428,7 +327,7 @@ function isFlatRoot(root: SessionRoot): boolean {
|
|
|
428
327
|
* 加载标题元数据索引(u11)。仅 keyword 路径调用(惰性触发:uuid 精确/归一化两级零命中
|
|
429
328
|
* 且 query 非纯 hex 才到达此处,§6.6 调用策略 ①)。
|
|
430
329
|
*
|
|
431
|
-
* 窄化目标(策略 ②,按序):未剥层 liveSessionDir 本身(
|
|
330
|
+
* 窄化目标(策略 ②,按序):未剥层 liveSessionDir 本身(taiji 下即平铺主根;纯 pi 下
|
|
432
331
|
* 即当前 cwd 目录,小)+ 实扫结果无子目录的候选根。两条 guard:
|
|
433
332
|
* - 仅对存在的根/目录调用(root.exists / stat liveSessionDir),且永传非空串——pi 的
|
|
434
333
|
* listAll 对空串 falsy 走默认全盘分支(数千项 / 秒级,hash-provider.ts 空参灾难先例);
|
|
@@ -467,7 +366,7 @@ async function loadTitleIndex(ctx: MetadataContext): Promise<Map<string, Session
|
|
|
467
366
|
}
|
|
468
367
|
for (const e of entries) {
|
|
469
368
|
const prev = index.get(e.id)
|
|
470
|
-
if (prev === undefined ||
|
|
369
|
+
if (prev === undefined || e.modified.getTime() > prev.modified.getTime()) index.set(e.id, e)
|
|
471
370
|
}
|
|
472
371
|
}
|
|
473
372
|
return index
|
|
@@ -590,7 +489,7 @@ async function loadRecentTitles(
|
|
|
590
489
|
}
|
|
591
490
|
for (const e of entries) {
|
|
592
491
|
const prev = titles.get(e.id)
|
|
593
|
-
if (prev === undefined ||
|
|
492
|
+
if (prev === undefined || e.modified.getTime() > prev.modified.getTime()) titles.set(e.id, e)
|
|
594
493
|
}
|
|
595
494
|
}
|
|
596
495
|
return titles
|
|
@@ -658,6 +557,12 @@ async function fillFirstMessagePreviews(
|
|
|
658
557
|
* 去重)并作标题窄化目标;缺省 = 现状三根降级行为。
|
|
659
558
|
* - opts.metadataProvider:标题元数据注入(仅 keyword 路径 + recent 截断后补充消费);
|
|
660
559
|
* 缺省 = undefined = 现状行为(标题检索不可用,首条 user 匹配不受影响)。
|
|
560
|
+
*
|
|
561
|
+
* E1 预解析根复用(ext-simplify-04 §3 D1):opts.roots = 调用方预解析的根列表——同一
|
|
562
|
+
* 次 doFind 内多段查询(分组 main/subagent 两路)复用同一次根解析,目录扫描不随查询
|
|
563
|
+
* 段数翻倍。缺省 = 本函数自解析(既有测试与单段调用方零改动)。调用方须保证传入的
|
|
564
|
+
* roots 来自无 options 的 resolveSessionRoots 实扫(预解析是复用同一次实扫——根扫描
|
|
565
|
+
* 无缓存,doctor 缓存机已删除,ext-simplify-04 U3)。
|
|
661
566
|
*/
|
|
662
567
|
export async function findSessions(
|
|
663
568
|
query: string,
|
|
@@ -668,18 +573,21 @@ export async function findSessions(
|
|
|
668
573
|
source?: SessionSource
|
|
669
574
|
liveSessionDir?: string
|
|
670
575
|
metadataProvider?: SessionMetadataProvider
|
|
576
|
+
/** 预解析根列表(E1/D1):提供则跳过内部 resolveSessionRoots,直接消费 */
|
|
577
|
+
roots?: SessionRoot[]
|
|
671
578
|
},
|
|
672
579
|
): Promise<{ matches: MatchedSession[]; truncated: boolean }> {
|
|
673
580
|
const limit = opts?.limit ?? DEFAULT_LIMIT
|
|
674
581
|
const cwdFilter = opts?.cwd
|
|
675
582
|
const sourceFilter = opts?.source
|
|
676
583
|
|
|
677
|
-
// 0. 根解析(单次实扫,无 options
|
|
584
|
+
// 0. 根解析(单次实扫,无 options——根扫描无缓存);
|
|
585
|
+
// E1:调用方已预解析(opts.roots)时复用,不再自扫
|
|
678
586
|
const signals: SessionRootSignals =
|
|
679
587
|
opts?.liveSessionDir !== undefined && opts.liveSessionDir.length > 0
|
|
680
588
|
? { agentDir, liveSessionDir: opts.liveSessionDir }
|
|
681
589
|
: { agentDir }
|
|
682
|
-
const roots = await resolveSessionRoots(signals)
|
|
590
|
+
const roots = opts?.roots ?? (await resolveSessionRoots(signals))
|
|
683
591
|
// 0+1. 逐根首行扫描建候选
|
|
684
592
|
const candidates = await collectCandidates(roots, sourceFilter, cwdFilter)
|
|
685
593
|
// 2. 三路匹配(recent / uuid 片段 / 名称关键词+U5 元数据+u11 标题)
|
package/src/discovery/roots.ts
CHANGED
|
@@ -13,9 +13,9 @@ import { performance } from 'node:perf_hooks'
|
|
|
13
13
|
* U1(design 2026-09-10 §6.1/§7B):新增 `resolveSessionRoots(signals)` —— 信号包 →
|
|
14
14
|
* 带来源标签的候选根列表,逐根扫描、按 realpath 去重(同路径只扫一次,保留最高优先级
|
|
15
15
|
* kind 标签)。B 收缩(design §6.13):布局对齐后无 `[env]` 信号,`[default]` 为主根。
|
|
16
|
-
* 旧签名 `listMainSessions`/`listSubagentSessions`
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* 旧签名 `listMainSessions`/`listSubagentSessions` 薄包装已删除(ext-simplify-04 U4/A3:
|
|
17
|
+
* 内部即「resolveSessionRoots + filter」恒等式,包内原唯一消费者 subagents.ts 改直调;
|
|
18
|
+
* npm 深 import 移除按 minor breaking 在 CHANGELOG 登记)。
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
// ============================================================
|
|
@@ -24,7 +24,7 @@ import { performance } from 'node:perf_hooks'
|
|
|
24
24
|
|
|
25
25
|
/** 发现层信号包(index.ts 采集,全部可降级;本模块只消费不采集) */
|
|
26
26
|
export interface SessionRootSignals {
|
|
27
|
-
/** pi agent 数据目录(两宿主均成立;B 后
|
|
27
|
+
/** pi agent 数据目录(两宿主均成立;B 后 taiji = `<dataDir>/agent`) */
|
|
28
28
|
agentDir: string
|
|
29
29
|
/**
|
|
30
30
|
* 宿主已解析的 live session 目录(`ctx.sessionManager.getSessionDir()`,§6.1 信号 1)。
|
|
@@ -39,9 +39,8 @@ export type SessionRootKind = 'live' | 'default' | 'legacy' | 'subagent'
|
|
|
39
39
|
/** main 源 = 主 session 根(live/default/legacy);subagent 源 = subagent 会话根 */
|
|
40
40
|
export type SessionRootSource = 'main' | 'subagent'
|
|
41
41
|
|
|
42
|
-
/**
|
|
42
|
+
/** 候选根(含扫描结果)。去重后每 kind 至多一个根,kind 即 doctor 表行键。 */
|
|
43
43
|
export interface SessionRoot {
|
|
44
|
-
id: SessionRootKind
|
|
45
44
|
kind: SessionRootKind
|
|
46
45
|
/** 规范化后的绝对路径(`[live]` 已剥 encodeCwd 层) */
|
|
47
46
|
path: string
|
|
@@ -55,17 +54,10 @@ export interface SessionRoot {
|
|
|
55
54
|
/** 扫描所得文件列表(design §7B 要点 8「本就在返回值里」);未扫描时空数组 */
|
|
56
55
|
files: SessionFileMeta[]
|
|
57
56
|
/**
|
|
58
|
-
* realpath 与更高优先级根同路径 → 指向保留根的
|
|
57
|
+
* realpath 与更高优先级根同路径 → 指向保留根的 kind,本根未扫描(doctor 渲染
|
|
59
58
|
* 「与 N 同路径,已去重」注记的依据,§7B 要点 4)。
|
|
60
59
|
*/
|
|
61
60
|
dedupedInto?: SessionRootKind
|
|
62
|
-
/**
|
|
63
|
-
* 本根数据来自调用方注入缓存(resolveSessionRoots options.cache 命中):fileCount/
|
|
64
|
-
* scanMs/exists 取缓存值,files 恒空数组——消费方不得把 cached 根的 files 当实扫
|
|
65
|
-
* 结果(u8 追加:doctor「同一数据源两处渲染」统一走 resolveSessionRoots,缓存语义
|
|
66
|
-
* 由调用方注入,§6.3)。
|
|
67
|
-
*/
|
|
68
|
-
cached?: boolean
|
|
69
61
|
}
|
|
70
62
|
|
|
71
63
|
/** 各根固定子目录名(常量推导,不来自信号——§6.1 信号 5) */
|
|
@@ -74,7 +66,7 @@ const SUBAGENTS_DIRNAME = 'subagents'
|
|
|
74
66
|
|
|
75
67
|
/**
|
|
76
68
|
* `[live]` 规范化(§6.1/§7B 要点 3):`getSessionDir()` 在纯 pi 返回按 cwd 编码的子目录
|
|
77
|
-
*(`<agentDir>/sessions/--Users-x--`),在
|
|
69
|
+
*(`<agentDir>/sessions/--Users-x--`),在 taiji 覆盖态返回根本身。判据:basename
|
|
78
70
|
* 匹配 encodeCwd 形态(`--` 开头 `--` 结尾,实证见 `~/.pi/agent/sessions/` 全部子目录
|
|
79
71
|
* 与 real-data.ts)则取 dirname,否则取自身。
|
|
80
72
|
*/
|
|
@@ -103,8 +95,8 @@ function sessionRootSpecs(signals: SessionRootSignals): SessionRoot[] {
|
|
|
103
95
|
return specs
|
|
104
96
|
}
|
|
105
97
|
|
|
106
|
-
function spec(
|
|
107
|
-
return {
|
|
98
|
+
function spec(kind: SessionRootKind, source: SessionRootSource, path: string): SessionRoot {
|
|
99
|
+
return { kind, source, path, exists: false, files: [] }
|
|
108
100
|
}
|
|
109
101
|
|
|
110
102
|
/** realpath 解析失败(目录不存在等)→ 回退字面路径作去重键(同字面路径仍可去重) */
|
|
@@ -129,37 +121,18 @@ const SKIP_DIRS_NONE = new Set<string>()
|
|
|
129
121
|
|
|
130
122
|
// ============================================================
|
|
131
123
|
// 扫描 options(u8 追加:doctor「同一数据源两处渲染,不重复实现探测」§6.3——
|
|
132
|
-
// doctor 经 options 驱动 subagent
|
|
133
|
-
//
|
|
124
|
+
// doctor 经 options 驱动 subagent 根不扫。原 u8 的进程内缓存注入面已删除
|
|
125
|
+
// (ext-simplify-04 U3):每次调用实扫,本模块不再持有任何缓存语义)
|
|
134
126
|
// ============================================================
|
|
135
127
|
|
|
136
|
-
/** 缓存条目载荷(扫描统计快照;失效判定所需元数据由缓存实现方自持,不进本契约) */
|
|
137
|
-
export interface SessionRootCacheEntry {
|
|
138
|
-
exists: boolean
|
|
139
|
-
fileCount: number
|
|
140
|
-
scanMs: number
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* 根扫描的进程内缓存句柄(调用方注入)。get 返回 undefined = 未命中(含调用方判定
|
|
145
|
-
* TTL 到期/目录 mtime 变化后自行淘汰)→ 本模块实扫并 set 回写;命中 → 直接以缓存值
|
|
146
|
-
* 构造根(cached: true,files 恒空数组)。get/set 允许同步或异步实现。
|
|
147
|
-
*/
|
|
148
|
-
export interface SessionRootCache {
|
|
149
|
-
get(key: string): Promise<SessionRootCacheEntry | undefined> | SessionRootCacheEntry | undefined
|
|
150
|
-
set(key: string, value: SessionRootCacheEntry): void | Promise<void>
|
|
151
|
-
}
|
|
152
|
-
|
|
153
128
|
/** resolveSessionRoots 扫描行为选项(全部可选,缺省 = 既有行为零变化) */
|
|
154
129
|
export interface SessionRootScanOptions {
|
|
155
130
|
/**
|
|
156
131
|
* subagent 根扫描模式:`'scan'`(默认,现行为——递归扫出文件数)| `'stat'`
|
|
157
|
-
*(doctor
|
|
158
|
-
*
|
|
132
|
+
*(doctor 默认形态与 not-found 文案 §6.3——只做存在性检查,fileCount/scanMs
|
|
133
|
+
* 恒 undefined)。subagent 根在纯 pi 下可达数千文件,doctor 可能被反复询问。
|
|
159
134
|
*/
|
|
160
135
|
subagents?: 'scan' | 'stat'
|
|
161
|
-
/** 根扫描缓存句柄(见 SessionRootCache)。缺省 = 每次实扫(find 路径永不传)。 */
|
|
162
|
-
cache?: SessionRootCache
|
|
163
136
|
}
|
|
164
137
|
|
|
165
138
|
/**
|
|
@@ -170,10 +143,9 @@ export interface SessionRootScanOptions {
|
|
|
170
143
|
* 逐根复用 scanJsonlRecursive(main 源跳 workflow-state;subagent 源不跳)。单根失败
|
|
171
144
|
* (不存在/无权限)→ 空结果继续,不抛错(沿用 listXxxSessions 契约)。
|
|
172
145
|
*
|
|
173
|
-
* options(u8 追加,全部缺省安全):`subagents:'stat'` 让 subagent
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* 计数必须取本次实扫,§7B 要点 8 PS-14)。
|
|
146
|
+
* options(u8 追加,全部缺省安全):`subagents:'stat'` 让 subagent 根只做存在性检查。
|
|
147
|
+
* 无缓存——每次调用实扫(原 u8 cache 注入面已删除,ext-simplify-04 U3;find/F1
|
|
148
|
+
* 自检行计数与 doctor 表计数同取本次实扫,§7B 要点 8)。
|
|
177
149
|
*/
|
|
178
150
|
export async function resolveSessionRoots(
|
|
179
151
|
signals: SessionRootSignals,
|
|
@@ -187,36 +159,19 @@ export async function resolveSessionRoots(
|
|
|
187
159
|
const kept = byRealPath.get(key)
|
|
188
160
|
if (kept) {
|
|
189
161
|
// 同 realpath:不扫描,标注归属(exists 与保留根同目录,必然一致)
|
|
190
|
-
out.push({ ...candidate, exists: kept.exists, dedupedInto: kept.
|
|
162
|
+
out.push({ ...candidate, exists: kept.exists, dedupedInto: kept.kind })
|
|
191
163
|
continue
|
|
192
164
|
}
|
|
193
165
|
if (candidate.source === 'subagent' && subagentMode === 'stat') {
|
|
194
|
-
// doctor
|
|
166
|
+
// doctor 默认形态 / not-found 文案(§6.3):只列路径与可扫性,不产文件数
|
|
195
167
|
out.push({ ...candidate, exists: await pathExists(candidate.path) })
|
|
196
168
|
continue
|
|
197
169
|
}
|
|
198
|
-
const cached = options?.cache ? await options.cache.get(candidate.path) : undefined
|
|
199
|
-
if (cached) {
|
|
200
|
-
// 命中:缓存值即数据源(不实扫、不回写);files 恒空——消费方不得当实扫结果
|
|
201
|
-
const root: SessionRoot = {
|
|
202
|
-
...candidate,
|
|
203
|
-
exists: cached.exists,
|
|
204
|
-
fileCount: cached.fileCount,
|
|
205
|
-
scanMs: cached.scanMs,
|
|
206
|
-
cached: true,
|
|
207
|
-
}
|
|
208
|
-
byRealPath.set(key, root)
|
|
209
|
-
out.push(root)
|
|
210
|
-
continue
|
|
211
|
-
}
|
|
212
170
|
const skipDirs = candidate.source === 'main' ? SKIP_DIRS_MAIN : SKIP_DIRS_NONE
|
|
213
171
|
const t0 = performance.now()
|
|
214
172
|
const files = await scanJsonlRecursive(candidate.path, skipDirs)
|
|
215
173
|
const scanMs = performance.now() - t0
|
|
216
174
|
const exists = await pathExists(candidate.path)
|
|
217
|
-
if (options?.cache) {
|
|
218
|
-
await options.cache.set(candidate.path, { exists, fileCount: files.length, scanMs })
|
|
219
|
-
}
|
|
220
175
|
const root: SessionRoot = {
|
|
221
176
|
...candidate,
|
|
222
177
|
exists,
|
|
@@ -295,29 +250,3 @@ async function scanJsonlRecursive(
|
|
|
295
250
|
return results
|
|
296
251
|
}
|
|
297
252
|
|
|
298
|
-
/**
|
|
299
|
-
* 旧签名薄包装(U1,design §7B 要点 7):内部构造只含 agentDir 的信号包,main 源退化为
|
|
300
|
-
* 「`[default]`+`[legacy]`」两根并集。工具运行路径后续单元改走 resolveSessionRoots 新签名,
|
|
301
|
-
* 本包装仅为存量调用(find.ts / subagents.ts)与外部深 import 保持不破。
|
|
302
|
-
*
|
|
303
|
-
* 递归扫描子目录(cwd 编码目录如 --Users-foo--),glob *.jsonl,排除 *.jsonl.finalized
|
|
304
|
-
*(design §3.3 D-7 Q2);跳过 workflow-state 子目录(workflow 运行状态文件,非 session)。
|
|
305
|
-
*/
|
|
306
|
-
export async function listMainSessions(agentDir: string): Promise<SessionFileMeta[]> {
|
|
307
|
-
const roots = await resolveSessionRoots({ agentDir })
|
|
308
|
-
return roots
|
|
309
|
-
.filter((r) => r.source === 'main' && r.dedupedInto === undefined)
|
|
310
|
-
.flatMap((r) => r.files)
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* 旧签名薄包装:agentDir 信号包下 `[subagent]` 根 = `<agentDir>/subagents`(常量推导),
|
|
315
|
-
* 行为与旧实现等价。结构:subagents/<cwd编码>/sessions/*.jsonl。records/ 子目录
|
|
316
|
-
*(.json manifest)无 .jsonl,天然不被误收。
|
|
317
|
-
*/
|
|
318
|
-
export async function listSubagentSessions(agentDir: string): Promise<SessionFileMeta[]> {
|
|
319
|
-
const roots = await resolveSessionRoots({ agentDir })
|
|
320
|
-
const sub = roots.find((r) => r.kind === 'subagent')
|
|
321
|
-
return sub?.files ?? []
|
|
322
|
-
}
|
|
323
|
-
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { open, type FileHandle } from 'node:fs/promises'
|
|
2
|
+
import { closeSync, openSync, readSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* [ext-simplify-18 D5] session 文件首行 header 读取的包内单源(原 subagents.ts / find.ts
|
|
6
|
+
* 两份 async 副本 + tool-handler.ts 一份 sync 副本合一)。不进 shared——跨包第二消费方
|
|
7
|
+
* 不存在(runtime 侧首行读取是另一形态 + 分层禁反向 import,设计负面清单同口径)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** session header(首行 type==='session' entry 的消费子集)。 */
|
|
11
|
+
export interface SessionHeader {
|
|
12
|
+
id: string
|
|
13
|
+
cwd?: string
|
|
14
|
+
parentSession?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 首行读取 buffer 上限。header 超 8KB 的极端情况会截断致 parse 失败(parseSessionHeader
|
|
19
|
+
* 返回 null)——session header(id/cwd/parentSession)实测 < 300 字节,8KB 约 27 倍余量。
|
|
20
|
+
*/
|
|
21
|
+
export const HEADER_READ_BYTES = 8192
|
|
22
|
+
|
|
23
|
+
/** 读文件首行(header)。定长 8KB 一次 read;空文件/读失败返回 undefined。 */
|
|
24
|
+
export async function readSessionHeaderFirstLine(path: string): Promise<string | undefined> {
|
|
25
|
+
let fh: FileHandle | undefined
|
|
26
|
+
try {
|
|
27
|
+
fh = await open(path, 'r')
|
|
28
|
+
const buf = Buffer.alloc(HEADER_READ_BYTES)
|
|
29
|
+
const { bytesRead } = await fh.read(buf, 0, HEADER_READ_BYTES, 0)
|
|
30
|
+
if (bytesRead === 0) return undefined
|
|
31
|
+
const text = buf.subarray(0, bytesRead).toString('utf8')
|
|
32
|
+
const nl = text.indexOf('\n')
|
|
33
|
+
return nl === -1 ? text : text.slice(0, nl)
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined
|
|
36
|
+
} finally {
|
|
37
|
+
await fh?.close().catch(() => {})
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 解析 header 首行为 SessionHeader。非 session 行/缺 id → null。 */
|
|
42
|
+
export function parseSessionHeader(line: string | undefined): SessionHeader | null {
|
|
43
|
+
if (!line) return null
|
|
44
|
+
let raw: unknown
|
|
45
|
+
try {
|
|
46
|
+
raw = JSON.parse(line)
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
if (typeof raw !== 'object' || raw === null) return null
|
|
51
|
+
const o = raw as Record<string, unknown>
|
|
52
|
+
if (o.type !== 'session' || typeof o.id !== 'string') return null
|
|
53
|
+
const h: SessionHeader = { id: o.id }
|
|
54
|
+
if (typeof o.cwd === 'string') h.cwd = o.cwd
|
|
55
|
+
if (typeof o.parentSession === 'string') h.parentSession = o.parentSession
|
|
56
|
+
return h
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** readSessionHeaderIdSync 读首行的 buffer 上限。session header 实测 < 300 字节,4KB 足够。 */
|
|
60
|
+
const SYNC_HEADER_READ_BYTES = 4096
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 同步读 session 文件首行 header,返回 type==='session' 的 id。
|
|
64
|
+
*
|
|
65
|
+
* 任何异常(文件不存在/空文件/解析失败/type 不符)返回 undefined。与 async 版
|
|
66
|
+
* readSessionHeaderFirstLine/parseSessionHeader 同构(定长 buffer 读首行 + JSON.parse +
|
|
67
|
+
* type 校验),但用同步 fs API(resolveSessionId 内仅调用 1 次,同步开销可接受),
|
|
68
|
+
* 窗口取 4KB(仅取 id)——sync 形态与窗口差异是刻意的调用上下文适配,独立常量、
|
|
69
|
+
* 不与 async 版 8KB 强行统一。
|
|
70
|
+
*/
|
|
71
|
+
export function readSessionHeaderIdSync(filePath: string): string | undefined {
|
|
72
|
+
let fd: number | undefined
|
|
73
|
+
try {
|
|
74
|
+
fd = openSync(filePath, 'r')
|
|
75
|
+
const buf = Buffer.alloc(SYNC_HEADER_READ_BYTES)
|
|
76
|
+
const bytesRead = readSync(fd, buf, 0, SYNC_HEADER_READ_BYTES, 0)
|
|
77
|
+
if (bytesRead === 0) return undefined
|
|
78
|
+
const text = buf.subarray(0, bytesRead).toString('utf8')
|
|
79
|
+
const nl = text.indexOf('\n')
|
|
80
|
+
const line = nl === -1 ? text : text.slice(0, nl)
|
|
81
|
+
let raw: unknown
|
|
82
|
+
try {
|
|
83
|
+
raw = JSON.parse(line)
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
if (typeof raw !== 'object' || raw === null) return undefined
|
|
88
|
+
const o = raw as Record<string, unknown>
|
|
89
|
+
if (o.type !== 'session' || typeof o.id !== 'string') return undefined
|
|
90
|
+
return o.id
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined
|
|
93
|
+
} finally {
|
|
94
|
+
if (fd !== undefined) {
|
|
95
|
+
try {
|
|
96
|
+
closeSync(fd)
|
|
97
|
+
} catch {
|
|
98
|
+
// closeSync 失败:fd 可能已无效,header 数据已读取,关闭失败不影响结果(best-effort)
|
|
99
|
+
void fd
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|