@zhushanwen/pi-session-reader 0.9.1 → 0.10.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-session-reader",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "taiji": {
@@ -16,15 +16,15 @@
16
16
  "pi-package"
17
17
  ],
18
18
  "dependencies": {
19
- "@zhushanwen/pi-extension-logger": "0.6.1",
20
- "@zhushanwen/pi-ext-guards": "0.4.2",
21
- "@zhushanwen/zcode-session-source": "0.2.4",
22
- "@zhushanwen/session-core": "0.11.0"
19
+ "@zhushanwen/pi-extension-logger": "0.6.2",
20
+ "@zhushanwen/pi-ext-guards": "0.4.3",
21
+ "@zhushanwen/zcode-session-source": "0.2.5",
22
+ "@zhushanwen/session-core": "0.12.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@vitest/coverage-v8": "^4.1.9",
26
26
  "vitest": "^4.1.8",
27
- "@zhushanwen/subagent-core": "1.2.1"
27
+ "@zhushanwen/subagent-core": "1.3.0"
28
28
  },
29
29
  "files": [
30
30
  "index.ts",
@@ -32,10 +32,10 @@
32
32
  "vitest.config.ts"
33
33
  ],
34
34
  "peerDependencies": {
35
- "@earendil-works/pi-coding-agent": "^0.84.4",
36
- "@earendil-works/pi-ai": "^0.84.4",
37
- "@earendil-works/pi-tui": "^0.84.4",
38
- "typebox": "*"
35
+ "@earendil-works/pi-coding-agent": "^1.0.0",
36
+ "@earendil-works/pi-ai": "^1.0.0",
37
+ "@earendil-works/pi-tui": "^1.0.0",
38
+ "typebox": "1.3.27"
39
39
  },
40
40
  "peerDependenciesMeta": {
41
41
  "@earendil-works/pi-coding-agent": {
@@ -145,8 +145,9 @@ describe('parseSessionContent', () => {
145
145
  })
146
146
 
147
147
  it('message 缺/非法 role → 丢弃 message 字段但保留 entry', () => {
148
+ // role 样本用词表外的值('bot')——'system' 已随 pi 1.0 四值词表放行(session-core D9①)
148
149
  const content = [
149
- line({ type: 'message', id: 'm1', parentId: null, message: { role: 'system', content: 'x' } }),
150
+ line({ type: 'message', id: 'm1', parentId: null, message: { role: 'bot', content: 'x' } }),
150
151
  line({ type: 'message', id: 'm2', parentId: null, message: { content: 'x' } }),
151
152
  ].join('\n')
152
153
 
@@ -226,6 +226,81 @@ describe('renderOutline', () => {
226
226
  // totalEntries 近似(leaf+branch+orphan)不含 session header(segmentTurns 规则1 跳过)
227
227
  expect(result.stats.totalEntries).toBeGreaterThan(0)
228
228
  })
229
+
230
+ // ---- D9② 占位行折叠:纯记录条目 turn 不产 outline 行、不占轮次 ----
231
+
232
+ function recEntry(id: string, type: string): Entry {
233
+ // pi 1.0 纯记录条目形态(usage/session_info/model_change 等,无 message 字段)
234
+ return { type, id, parentId: null }
235
+ }
236
+
237
+ function sysEntry(id: string): Entry {
238
+ // pi 1.0 system 消息(role=system,工具集变更高频追加)——非对话轴,随折叠
239
+ return { type: 'message', id, parentId: null, message: { role: 'system', content: 'sys' } }
240
+ }
241
+
242
+ it('9. D9② 纯记录条目 turn 折叠:不产行、totalTurns 只数对话轮,对话 turn 的 T 序号保留原 index', () => {
243
+ const turns = [
244
+ turn(0, [recEntry('MC', 'model_change'), recEntry('TL', 'thinking_level_change')]),
245
+ turn(1, [uEntry('U1', 'first question')], { userEntry: uEntry('U1', 'first question') }),
246
+ turn(2, [recEntry('US', 'usage')]),
247
+ turn(3, [uEntry('U2', 'second question')], { userEntry: uEntry('U2', 'second question') }),
248
+ ]
249
+ const result = renderOutline(turns, emptyTree(), { budget: 2000 })
250
+
251
+ // 记录 turn(0/2)折叠:行只剩两个对话 turn
252
+ expect(result.turns).toHaveLength(2)
253
+ expect(result.lines).toHaveLength(2)
254
+ expect(result.stats.totalTurns).toBe(2)
255
+ // T 序号 = 原 turn.index(expand/detail 的入口地址稳定,折叠不重编号)
256
+ expect(result.turns.map((b) => b.index)).toEqual([1, 3])
257
+ // 字节/条目统计保持全量口径(折叠是展示层行为,不丢数据面):2+1+1+1 = 5
258
+ expect(result.stats.totalEntries).toBe(5)
259
+ })
260
+
261
+ it('10. D9② system 消息与 custom 审计条目同折叠;custom_message 参与对话流保留', () => {
262
+ const turns = [
263
+ turn(0, [sysEntry('S1'), recEntry('C1', 'custom')]),
264
+ turn(1, [uEntry('U1', 'q')], { userEntry: uEntry('U1', 'q') }),
265
+ ]
266
+ const result = renderOutline(turns, emptyTree(), { budget: 2000 })
267
+ expect(result.turns).toHaveLength(1)
268
+ expect(result.stats.totalTurns).toBe(1)
269
+
270
+ const cm: Entry = { type: 'custom_message', id: 'CM', parentId: null, customType: 'plan-context', content: 'prompt' }
271
+ const withCm = renderOutline([turn(0, [cm]), turn(1, [uEntry('U1', 'q')], { userEntry: uEntry('U1', 'q') })], emptyTree(), { budget: 2000 })
272
+ expect(withCm.turns).toHaveLength(2)
273
+ })
274
+
275
+ it('11. D9② entry 粒度同谓词:纯记录条目不产行,entryIdx 只数对话条目', () => {
276
+ const turns = [
277
+ turn(0, [recEntry('MC', 'model_change'), uEntry('U0', 'a')], { userEntry: uEntry('U0', 'a') }),
278
+ turn(1, [recEntry('US', 'usage')]),
279
+ ]
280
+ const result = renderOutline(turns, emptyTree(), { granularity: 'entry', budget: 2000 })
281
+ expect(result.turns).toHaveLength(1)
282
+ expect(result.turns[0]?.userBrief).toBe('a')
283
+ })
284
+
285
+ it('12. D9② branch_summary 归纯记录折叠:前置 turn 成 turn 不产行(pi 1.0 纯记录类)', () => {
286
+ // pi 1.0 dist branchWithSummary 落盘形态:{type:'branch_summary', id, parentId, fromId,
287
+ // summary, details, usage, fromHook},无 message 字段。归类 = 纯记录(分叉操作痕迹,
288
+ // 非当前路径对话内容;taiji 无消息编辑入口不产生该形态)
289
+ const bs: Entry = { type: 'branch_summary', id: 'BS', parentId: null, summary: 'original path digest' }
290
+ const turns = [
291
+ turn(0, [bs]),
292
+ turn(1, [uEntry('U1', 'new branch question')], { userEntry: uEntry('U1', 'new branch question') }),
293
+ ]
294
+ const result = renderOutline(turns, emptyTree(), { budget: 2000 })
295
+ // 折叠:前置 turn 不产行,outline 只剩新路径对话 turn
296
+ expect(result.turns).toHaveLength(1)
297
+ expect(result.lines).toHaveLength(1)
298
+ expect(result.stats.totalTurns).toBe(1)
299
+ // T 序号保留原 turn.index(expand/detail 入口地址稳定)
300
+ expect(result.turns.map((b) => b.index)).toEqual([1])
301
+ // 字节/条目统计保持全量口径:BS + U1 = 2
302
+ expect(result.stats.totalEntries).toBe(2)
303
+ })
229
304
  })
230
305
 
231
306
  describe('renderExpand', () => {
@@ -198,7 +198,7 @@ describe('createSessionCommand - handler select 流程', () => {
198
198
  listAllSpy.mockRestore()
199
199
  })
200
200
 
201
- it('MF-2 回归:同 cwd 同预览同 age 桶 → label 带短 uuid 后缀消歧,选第 2 条插第 2 条 uuid', async () => {
201
+ it('MF-2 回归:同 cwd 同预览同 age 桶 → label 带短 uuid 后缀消歧,选中哪条插哪条 uuid', async () => {
202
202
  // 两 session:相同首消息 + 相同 timestamp(同 age 桶)→ toCandidate label 完全相同
203
203
  const ID1 = '019e6c96-0a0c-74b8-a73f-d1854d88e2a7'
204
204
  const ID2 = '019fffff-1111-2222-3333-444455556666'
@@ -207,15 +207,24 @@ describe('createSessionCommand - handler select 流程', () => {
207
207
 
208
208
  const cmd = createSessionCommand(() => cwdSessionDir)
209
209
  const { ctx, select, setEditorText } = makeFakeCtx()
210
- // 用户选第 2 条(label 数组下标 1)
211
- select.mockImplementation(async (_title: string, options: string[]) => options[1])
210
+ // 用户选中 ID2 那一项。pi 1.0.0 起 listSessionsFromDir 对文件名倒序预排序
211
+ // (files.sort((a,b) => b.localeCompare(a)),session-manager.js),同 modified 下
212
+ // a.jsonl/b.jsonl 的顺序与 0.84.4(readdir 字母序、无预排序)相反——listAll 本身
213
+ // 仍保序(mapWithConcurrency 按 index 写回)。不硬编码下标,按 label 中的短 uuid
214
+ // 定位目标项(消歧后缀保证了可定位性,这正是 MF-2 的承重点)
215
+ select.mockImplementation(async (_title: string, options: string[]) => {
216
+ const target = options.find((o) => o.includes(ID2.slice(0, 8)))
217
+ if (target === undefined) throw new Error(`labels 未含 ID2 短 uuid:${JSON.stringify(options)}`)
218
+ return target
219
+ })
212
220
  await cmd.handler('', ctx)
213
221
 
214
222
  // select 收到的 labels 必须两两不同(uuid 后缀消歧生效)
215
223
  const labelsArg = select.mock.calls[0][1] as string[]
216
224
  expect(labelsArg.length).toBe(2)
217
225
  expect(new Set(labelsArg).size).toBe(2)
218
- // 插入的是第 2 条 session 的完整 uuid(旧实现 indexOf(label) 会错插第 1 条);尾随空格与 applyCompletion spacer 语义一致(S-2)
226
+ // 插入的是被选中的第 2 条 session 完整 uuid(旧实现 indexOf(原始 label) 会错插第 1 条);
227
+ // 尾随空格与 applyCompletion spacer 语义一致(S-2)
219
228
  expect(setEditorText).toHaveBeenCalledWith(`#${ID2} `)
220
229
  })
221
230
  })
@@ -310,13 +310,16 @@ describe('变体 M:manifest 主路径(instrument:manifest 锚与 entry 锚
310
310
  writeRootSessionFile()
311
311
  })
312
312
 
313
- it('outline:读到 manifest 锚的 sess_bbb(ZBSENTINEL 在,ZASENTINEL 不在),两轮 T000/T001', async () => {
313
+ it('outline:读到 manifest 锚的 sess_bbb(ZBSENTINEL 在,ZASENTINEL 不在),两轮 T001/T002', async () => {
314
314
  const r = await handleSessionRead({ action: 'outline', session: SA_M }, signals())
315
315
  const text = r.content[0]?.text ?? ''
316
316
  expect(text).toContain('ZBSENTINEL')
317
317
  expect(text).not.toContain('ZASENTINEL')
318
- expect(text).toContain('T000')
318
+ // D9② 占位行折叠:T000 = converter 注入的 session_info 前置条目(纯记录,非对话),
319
+ // 折叠后不产行;对话轮保留原 turn.index(T001/T002),totalTurns 仍 = 2
319
320
  expect(text).toContain('T001')
321
+ expect(text).toContain('T002')
322
+ expect(text).not.toMatch(/\bT000\b/)
320
323
  // outline 的 isCompaction 恒 false 属已登记形态(§4 已接受代价)——无独立压缩 turn
321
324
  expect(text).not.toContain('compaction')
322
325
  })
@@ -343,6 +343,37 @@ function formatLine(b: TurnBrief, level: LineLevel, branchSize?: number): string
343
343
  // renderOutline
344
344
  // ---------------------------------------------------------------------------
345
345
 
346
+ /**
347
+ * 对话语义条目判定(outline 折叠谓词,D9②)。
348
+ *
349
+ * pi 1.0 session 落盘 entry 十一类(session-manager.js appendXxx 家族实装)中,参与
350
+ * 对话流的仅三类:message(role=user/assistant/toolResult)、compaction、
351
+ * custom_message(appendCustomMessageEntry——参与 LLM 上下文的注入消息)。其余
352
+ * ——usage(模型记账,appendUsage 明言不参与 LLM 上下文)/ session_info /
353
+ * model_change / thinking_level_change / context_edit / custom(扩展审计痕迹,
354
+ * appendCustomEntry)/ label(entry 书签/标记,appendLabelChange 落盘,targetId
355
+ * 指向被标记条目,无 message 字段,与 usage 同归折叠侧)/ branch_summary
356
+ * (branchWithSummary 落盘的分叉痕迹:编辑历史
357
+ * 消息产生分支时记录被放弃路径的摘要;summary 虽经 sessionEntryToContextMessages
358
+ * 转 createBranchSummaryMessage 参与 LLM 上下文重放,但语义是被放弃路径的终点而非
359
+ * 当前路径的对话内容,且 taiji 无消息编辑入口不产生该形态)——是纯记录条目:
360
+ * 单独成 turn 时(首条 user 前的孤儿堆、
361
+ * compaction 边界与下一 user 之间的记账堆)outline 渲染出只有 T 序号头的占位行,
362
+ * 轮次计数被记录条目膨胀。折叠 = 这类条目不产 outline 行;expand/detail 不受影响
363
+ * (仍按 T 序号完整可达),字节统计(leafParsedBytes/totalEntries)保持全量口径。
364
+ */
365
+ function isConversationEntry(e: Entry): boolean {
366
+ if (e.type === 'compaction' || e.type === 'custom_message') return true
367
+ const msg = e.message
368
+ if (msg === undefined) return false
369
+ return msg.role === 'user' || msg.role === 'assistant' || msg.role === 'toolResult'
370
+ }
371
+
372
+ /** 对话 turn:含任一对话语义条目(纯记录条目 turn 被 outline 折叠,不占轮次)。 */
373
+ function isConversationTurn(t: Turn): boolean {
374
+ return t.entries.some(isConversationEntry)
375
+ }
376
+
346
377
  function sumBranchEntries(tree: TreeView): number {
347
378
  let s = 0
348
379
  for (const n of tree.branches.values()) s += n
@@ -423,36 +454,40 @@ export function renderOutline(
423
454
  const allBranches = options?.allBranches ?? false
424
455
  const granularity = options?.granularity ?? 'turn'
425
456
 
457
+ // 字节/条目统计保持全量口径(折叠是展示层行为,不丢数据面);行与轮次计数走对话集
426
458
  const parsedBytes = leafParsedBytes(turns)
427
459
  const leafEntryCount = turns.reduce((s, t) => s + t.entries.length, 0)
428
460
  // totalEntries = leaf + 旁支子树 + 孤儿;totalBytes 无原始文件字节数(签名不含 ParseResult),
429
461
  // 以 leaf entry JSON 字节近似,精确值由 M2 工具层用 ParseResult.totalBytes 覆盖。
430
462
  const totalEntriesAll = leafEntryCount + sumBranchEntries(tree) + tree.orphans.length
463
+ // D9② 占位行折叠:纯记录条目 turn 不产 outline 行、不计入轮次(totalTurns = 对话轮数);
464
+ // 保留原 turn.index 作 T 序号——outline 行是 expand/detail 的入口地址,重编号会切断对齐。
465
+ const dialogTurns = turns.filter(isConversationTurn)
431
466
  const stats: OutlineResult['stats'] = {
432
- totalTurns: turns.length,
467
+ totalTurns: dialogTurns.length,
433
468
  totalEntries: totalEntriesAll,
434
469
  totalBytes: parsedBytes,
435
470
  parsedBytes,
436
471
  skippedLines: 0,
437
472
  }
438
473
 
439
- if (turns.length === 0) {
474
+ if (dialogTurns.length === 0) {
440
475
  return { turns: [], lines: [], stats, tokenEstimate: 0 }
441
476
  }
442
477
 
443
478
  // granularity:entry —— 每 entry 一行,不聚合 turn(D-1 兜底)
444
479
  if (granularity === 'entry') {
445
- return renderEntryGranularity(turns, tree, budget, allBranches, stats)
480
+ return renderEntryGranularity(dialogTurns, tree, budget, allBranches, stats)
446
481
  }
447
482
 
448
483
  // 1. 全量 brief
449
- const briefs: TurnBrief[] = turns.map(computeBrief)
484
+ const briefs: TurnBrief[] = dialogTurns.map(computeBrief)
450
485
 
451
486
  // allBranches:标注 forkPoint
452
487
  if (allBranches) applyBranchLabels(briefs, turns, tree)
453
488
 
454
- // 2. perTurnBudget(token → chars×4)
455
- const perTurnCharBudget = (budget / turns.length) * CHARS_PER_TOKEN
489
+ // 2. perTurnBudget(token → chars×4;分母 = 对话轮数——折叠行不参与预算分配)
490
+ const perTurnCharBudget = (budget / dialogTurns.length) * CHARS_PER_TOKEN
456
491
 
457
492
  // 3. 降级序渲染:level 0 全有 → level 1 砍 assistantBrief → level 2 骨架
458
493
  const lineCache = renderDegradingLines(briefs, tree, perTurnCharBudget)
@@ -476,6 +511,8 @@ function renderEntryGranularity(
476
511
  let entryIdx = 0
477
512
  for (const t of turns) {
478
513
  for (const e of t.entries) {
514
+ // D9②:纯记录条目不产 outline 行(与 turn 粒度同谓词,entryIdx 只数对话条目)
515
+ if (!isConversationEntry(e)) continue
479
516
  const msg = e.message
480
517
  let text = ''
481
518
  if (msg !== undefined && (msg.role === 'user' || msg.role === 'assistant')) {
package/src/core/turns.ts CHANGED
@@ -8,6 +8,11 @@ import type { Entry } from '@zhushanwen/session-core'
8
8
  * - 前置 turn(preface):首条 user/compaction 之前出现的 rule-4 entry
9
9
  * (assistant / model_change / thinking_level_change / custom / branch_summary)无处并入时
10
10
  * 单独成 turn 0,无 userEntry,isCompaction=false。
11
+ *
12
+ * 分段与折叠分工:本函数把全部非 header/compaction/user 条目归入 turn(纯记录条目
13
+ * 也成 turn);outline 是否渲染行由 render.ts isConversationEntry 二次裁决——纯记录
14
+ * 条目(无 message 字段,含 branch_summary)的 turn 不产 outline 行。两处对条目形态
15
+ * 的认知以 pi dist 实装为同一事实源(entry 全集十一类,见 render.ts 注释枚举)。
11
16
  */
12
17
  export interface Turn {
13
18
  index: number
@@ -20,8 +20,9 @@ export const SESSION_ID_PREFIX_LEN = 8
20
20
  export const pad = (n: number): string => String(n).padStart(TURN_INDEX_WIDTH, '0')
21
21
 
22
22
  /** 构造带 👉 恢复指引的 Error。契约:handler 直接 throw,execute 不 catch、原样传播给 pi;
23
- * pi 外层(pi-agent-core agent-loop.js executePreparedToolCall catch,:466-471)统一转
24
- * isError:true 的 error tool result——返回值上的 isError 字段会被丢弃(错误被标成功)。 */
23
+ * pi 外层(pi-agent-core agent-loop.js executePreparedToolCall catch,pi 1.0.0 :581-588)
24
+ * 统一转 isError:true 的 error tool result——统一 throw 为纪律选择(单一错误路径),
25
+ * 非「返回值 isError 失效」(1.0.0 起返回值标记同样被尊重〔:579〕,双轨写法仍禁用)。 */
25
26
  export function err(message: string): Error {
26
27
  return new Error(message)
27
28
  }
package/src/index.ts CHANGED
@@ -14,9 +14,9 @@ import { createSessionCommand } from './tui/session-command.js'
14
14
  * 分层(同 scheduler/cw-tool):
15
15
  * - tool-handler.ts:纯逻辑 handler,agentDir 注入,零 pi 依赖,可单测
16
16
  * - index.ts(本文件):pi 依赖层,registerTool + getAgentDir() 调用 + execute 闭包
17
- * (错误直接 throw 给 pi——pi-agent-core agent-loop 只对 execute throw 置
18
- * isError:true,返回值里的 isError 字段被丢弃;W4 修复,锚点
19
- * agent-loop.js:453-483/525-547,pi 自带 bash 工具同范式)
17
+ * (错误直接 throw 给 pi——pi-agent-core agent-loop 对 execute throw 置 isError:true,
18
+ * 锚点 agent-loop.js:581-588;1.0.0 起返回值 isError:true 同样被尊重(:579),
19
+ * 本工具恒走 throw 路径两版语义等价,pi 自带 bash 工具同范式)
20
20
  */
21
21
 
22
22
  // ---- TypeBox 参数 schema(design §3.4 14 字段)----
@@ -208,10 +208,9 @@ export default function sessionReaderExtension(pi: ExtensionAPI): void {
208
208
  _onUpdate: unknown,
209
209
  ctx: ExtensionContext | undefined,
210
210
  ) {
211
- // 错误路径直接 throw:pi 契约只有 throw 才置 isError:true(tool_execution_end /
212
- // ToolResultMessage),handler 抛的 Error 文案(含 👉 恢复提示)原样成为
213
- // toolResult content,模型仍可读到。曾用 return {isError:true}——被 agent-loop
214
- // 丢弃,错误轮被标成功(W4 修复)。
211
+ // 错误路径直接 throw:pi 契约 throw 置 isError:true(tool_execution_end /
212
+ // ToolResultMessage;1.0.0 起返回值 isError:true 也被尊重,本工具恒走 throw),
213
+ // handler 抛的 Error 文案(含 👉 恢复提示)原样成为 toolResult content,模型仍可读到。
215
214
  // signal 仅 search 消费(MF-5:长扫描可中断,Esc 不再挂死);其余 action 有界不接
216
215
  // 信号包采集(design §7B):可选链逐层降级——ctx===undefined(存量单测五参形态)、
217
216
  // sessionManager 缺字段、getSessionDir 方法缺失,任一层不成立即 liveSessionDir=undefined