@zhushanwen/pi-system-prompt-trace 0.1.5 → 0.1.7

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/README.md CHANGED
@@ -31,19 +31,24 @@ appendEntry("xyz:system-prompt", {
31
31
  |---|---|---|
32
32
  | startup / new | initial | 定案 |
33
33
  | resume | resume | 定案 |
34
- | fork / reload | resume | 暂定,待 P2 探针实测定(A13) |
34
+ | fork / reload | resume | 定案(M0 探针 P2 实测:fork 档读源文件最后留痕,落盘 reason 维持 resume;执行记录见设计 §6.5) |
35
35
 
36
- 存在任一 hash 基线(见下)时,首个 turn 写 `resume`(无论 session_start reason)。
36
+ 存在 hash 基线(见下)且 prompt 未变时,首个 turn 不写(去重);需写时(hash 已变)恒写 `resume`(无论 session_start reason)。
37
37
 
38
- ## 跨重启 hash 基线四路径(优先级从高到低,权威实现见 `src/trace.ts` onSessionStart)
38
+ ## 跨重启 hash 基线三档(优先级从高到低,权威实现见 `src/trace.ts` onSessionStart)
39
39
 
40
40
  1. **进程内 resume(stash)**:`session_before_switch.targetSessionFile` 直读目标 session 文件,取最后一条留痕
41
41
  entry 的 hash/version/fullText。switch 会 teardown 并重建 extension runtime,该基线经模块级 stash 跨 runtime 传递。
42
- 2. **fork**:`session_start.previousSessionFile` 直读源 session 文件(暂定语义,待 P2 探针实测定)。
43
- 3. **自持久化小文件**:`<agentDir>/system-prompt-trace-baseline.json`(sessionId → hash/version),原子写入,
44
- 保留最近 64 个 session。app 重启直 spawn resume / reload 等没有 switch 事件的链路由此恢复基线。
45
- 4. **兜底必写**:三路都 miss(无任何基线)时,首个 turn 无论 session_start reason 必写一条——
46
- startup/new → `initial`,resume/fork/reload → `resume`(宁可多写不可漏记)。
42
+ 2. **fork**:`session_start.previousSessionFile` 直读源 session 文件,取源文件最后一条留痕(D2 v5 定案——
43
+ 常态 /fork 时点 fork 新文件未落盘,不可直读新文件;源文件缺失/未落盘/读取失败 → 视为无基线)。
44
+ 3. **直读当前 session 文件**:`ctx.sessionManager.getSessionFile()` 直读 JSONL 最后一条留痕,覆盖 reload /
45
+ 重启直 spawn resume / new 兜底等没有 switch 事件的链路(文件不存在或无留痕 → 视为无基线)。
46
+
47
+ 原第三档「自持久化小文件 system-prompt-trace-baseline.json」已随 ext-simplify-02 删除——终态唯一持久化源 =
48
+ session JSONL 自身;存量文件成为无读写方孤儿,可安全手动删除(含 `*.tmp_*` 残留)。
49
+
50
+ 三档都 miss(无任何基线)时兜底必写:首个 turn 无论 session_start reason 必写一条——
51
+ startup/new → `initial`,resume/fork/reload → `resume`(宁可多写不可漏记)。
47
52
 
48
53
  ## 注册状态
49
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-system-prompt-trace",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "System prompt trace extension: appends xyz:system-prompt entries to the session JSONL whenever the effective system prompt is established or changes",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -33,7 +33,8 @@
33
33
  "vitest": "^4.1.8"
34
34
  },
35
35
  "dependencies": {
36
- "@zhushanwen/pi-extension-logger": "0.4.1"
36
+ "@zhushanwen/pi-ext-guards": "0.4.0",
37
+ "@zhushanwen/pi-extension-logger": "0.6.0"
37
38
  },
38
39
  "scripts": {
39
40
  "typecheck": "npx tsc --noEmit",
@@ -1,33 +1,35 @@
1
1
  /**
2
2
  * A12 hash 基线跨重启恢复(spec:.cw-specs/trace-ext.json;设计 D2 复审 N2 / plan §2.1)。
3
3
  *
4
- * 四路径(优先级从高到低,口径同 trace.ts onSessionStart):
4
+ * 三档(优先级从高到低,口径同 trace.ts onSessionStart,设计 D1/D2 v5):
5
5
  * 1. 进程内 resume:session_before_switch.targetSessionFile 直读目标文件取上一版 hash
6
6
  * (switch 重建 extension runtime,基线经模块级 stash 传递——用「新闭包 + 共享 stash」模拟)
7
- * 2. fork:previousSessionFile 直读源 session 文件(暂定语义,本文件未覆盖)
8
- * 3. app 重启直 spawn resume:agentDir 自持久化小文件(此链路无 switch 事件、reason=startup)
9
- * 4. 兜底:三路都读不到 → 任意 reason 必写一条(startup/new→initial,resume/fork/reload→resume)
7
+ * 2. fork:previousSessionFile 直读源 session 文件最后留痕(设计 D2 v5 定案——常态 /fork
8
+ * 时点 fork 新文件未落盘,直读不可靠)
9
+ * 3. 直读档:ctx.getSessionFile() 直读当前 session 文件(app 重启直 spawn resume / reload——
10
+ * 此链路无 switch 事件、reason=startup;文件未落盘 / 无留痕 → null)
11
+ * 4. 兜底:三档都读不到 → 任意 reason 必写一条(startup/new→initial,resume/fork/reload→resume)
10
12
  *
11
13
  * 用真实临时目录 + 真实 fs 函数(非 mock 投影);appendEntry 同步模拟 pi appendCustomEntry
12
14
  * 的落盘形状(session-manager.ts)。
13
15
  */
14
16
  import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
+ import { createHash } from "node:crypto";
15
18
  import { tmpdir } from "node:os";
16
19
  import { join } from "node:path";
17
20
 
18
21
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
19
22
 
20
- import {
21
- BASELINE_FILENAME,
22
- readLastPromptFromSessionFile,
23
- readPersistedBaseline,
24
- writePersistedBaseline,
25
- } from "../baseline.js";
26
- import { computePromptHash, createSystemPromptTrace } from "../trace.js";
23
+ import { readLastPromptFromSessionFile } from "../baseline.js";
24
+ import { createSystemPromptTrace } from "../trace.js";
27
25
  import type { SystemPromptTrace, TraceContext, TraceEnv } from "../trace.js";
28
26
  import { isSystemPromptTraceEntryData, SYSTEM_PROMPT_CUSTOM_TYPE } from "../types.js";
29
27
  import type { SystemPromptTraceEntryData, SwitchStash } from "../types.js";
30
28
 
29
+ // trace.ts 的 computePromptHash 已收敛为包内私有(无外部消费方);测试本地同款实现计算期望值。
30
+ const computePromptHash = (text: string): string =>
31
+ createHash("sha256").update(text, "utf-8").digest("hex");
32
+
31
33
  const P1 = "base prompt\nline-1";
32
34
  const P2 = "base prompt\nline-1\nline-2-added";
33
35
 
@@ -36,14 +38,13 @@ interface FsHarness {
36
38
  ctx: TraceContext;
37
39
  stash: SwitchStash;
38
40
  sessionFile: string;
39
- baselineFile: string;
40
41
  entries: SystemPromptTraceEntryData[];
41
42
  setPrompt(text: string): void;
42
- /** 真实链路中 new/fork 的新 session 有全新 id(fake ctx 不能固定 id,否则旧基线误命中) */
43
- setSessionId(id: string): void;
43
+ /** 真实链路中 new/fork 的新 session 有全新 id 与全新(未落盘的)文件;切换 ctx 指向并返回新文件路径 */
44
+ openNewSession(id: string): string;
44
45
  /** 模拟 extension runtime 重建(switchSession teardown + createRuntime 重跑 factory:新闭包,共享 stash 与文件) */
45
46
  newLogic(): SystemPromptTrace;
46
- /** 读 sessionFile 的非空行(模拟 pi 落盘结果核对) */
47
+ /** 读当前 session 文件的非空行(模拟 pi 落盘结果核对) */
47
48
  sessionLines(): string[];
48
49
  }
49
50
 
@@ -54,45 +55,49 @@ beforeEach(() => {
54
55
  });
55
56
 
56
57
  afterEach(() => {
57
- rmSync(rootDir, { recursive: true, force: true });
58
+ rmSync(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
58
59
  });
59
60
 
60
- function makeHarness(initialPrompt: string): FsHarness {
61
+ function makeHarness(initialPrompt: string, opts?: { noSessionFile?: boolean }): FsHarness {
61
62
  const dir = join(rootDir, `sess-${Math.random().toString(36).slice(2)}`);
62
63
  mkdirSync(dir, { recursive: true });
63
64
  const sessionFile = join(dir, "session.jsonl");
64
- const baselineFile = join(dir, BASELINE_FILENAME);
65
65
  const entries: SystemPromptTraceEntryData[] = [];
66
66
  let prompt = initialPrompt;
67
67
  let sessionId = "sess-a12";
68
+ // 真实链路中 getSessionFile() 指向当前 session 的文件(首条 assistant 前 pi 未 flush,
69
+ // 文件可能不存在——appendEntry 的 append 模式会自建,与 pi 的延迟落盘行为等价可接受)
70
+ let currentSessionFile: string | undefined = opts?.noSessionFile === true ? undefined : sessionFile;
68
71
  const stash: SwitchStash = { pending: null };
69
72
 
70
73
  const env: TraceEnv = {
71
- readLastPromptFromFile: (filePath) => readLastPromptFromSessionFile(filePath, "target-file"),
72
- readPersistedBaseline: (sessionId) => readPersistedBaseline(baselineFile, sessionId),
73
- writePersistedBaseline: (sessionId, hash, version) =>
74
- writePersistedBaseline(baselineFile, sessionId, hash, version),
74
+ readLastPromptFromFile: (filePath) => readLastPromptFromSessionFile(filePath),
75
75
  };
76
76
 
77
77
  const ctx: TraceContext = {
78
78
  getSystemPrompt: () => prompt,
79
79
  getSessionId: () => sessionId,
80
+ getSessionFile: () => currentSessionFile,
80
81
  appendEntry: (customType, data) => {
81
82
  if (!isSystemPromptTraceEntryData(data)) {
82
83
  throw new Error(`entry data shape invalid: ${JSON.stringify(data)}`);
83
84
  }
84
- // 模拟 pi appendCustomEntry 落盘形状(session-manager.ts)
85
- appendFileSync(
86
- sessionFile,
87
- JSON.stringify({
88
- type: "custom",
89
- customType,
90
- data,
91
- id: `e${entries.length + 1}`,
92
- parentId: null,
93
- timestamp: new Date().toISOString(),
94
- }) + "\n",
95
- );
85
+ // 纯内存 session(getSessionFile() undefined,pi 首条 assistant 前 _persist 未 flush):
86
+ // entry 只进内存,不落文件——与 pi appendCustomEntry 内存先行、延迟落盘的行为等价
87
+ if (currentSessionFile !== undefined) {
88
+ // 模拟 pi appendCustomEntry 落盘形状(session-manager.ts)
89
+ appendFileSync(
90
+ currentSessionFile,
91
+ JSON.stringify({
92
+ type: "custom",
93
+ customType,
94
+ data,
95
+ id: `e${entries.length + 1}`,
96
+ parentId: null,
97
+ timestamp: new Date().toISOString(),
98
+ }) + "\n",
99
+ );
100
+ }
96
101
  entries.push(data);
97
102
  },
98
103
  };
@@ -104,18 +109,21 @@ function makeHarness(initialPrompt: string): FsHarness {
104
109
  ctx,
105
110
  stash,
106
111
  sessionFile,
107
- baselineFile,
108
112
  entries,
109
113
  setPrompt: (text) => {
110
114
  prompt = text;
111
115
  },
112
- setSessionId: (id) => {
116
+ openNewSession: (id) => {
117
+ const file = join(dir, `${id}.jsonl`);
113
118
  sessionId = id;
119
+ currentSessionFile = file;
120
+ return file;
114
121
  },
115
122
  newLogic: makeLogic,
116
123
  sessionLines: () => {
124
+ if (currentSessionFile === undefined) return [];
117
125
  try {
118
- return readFileSync(sessionFile, "utf-8").split("\n").filter((l) => l.trim() !== "");
126
+ return readFileSync(currentSessionFile, "utf-8").split("\n").filter((l) => l.trim() !== "");
119
127
  } catch {
120
128
  return [];
121
129
  }
@@ -158,24 +166,22 @@ describe("A12 hash 基线跨重启恢复", () => {
158
166
  expect(h.entries[1]?.parentVersionDiffSummary).toContain("+1 -0 lines");
159
167
  });
160
168
 
161
- it("路径 3(app 重启直 spawn):自持久化小文件命中且 hash 未变 → 不写(reason=startup、无 switch 事件)", () => {
169
+ it("直读档(app 重启直 spawn resume):getSessionFile() 目标文件命中且 hash 未变 → 不写(reason=startup、无 switch 事件)", () => {
162
170
  const h = makeHarness(P1);
163
171
  h.logic.onSessionStart("startup", undefined, h.ctx);
164
- h.logic.onTurnStart(h.ctx); // v1 initial + 基线小文件已写
165
- expect(readPersistedBaseline(h.baselineFile, "sess-a12")).toMatchObject({
166
- hash: computePromptHash(P1),
167
- version: 1,
168
- });
172
+ h.logic.onTurnStart(h.ctx); // v1 initial(落 sessionFile)
173
+ expect(h.entries).toHaveLength(1);
169
174
 
170
- // app 重启:全新闭包 + 空 stash(无 before_switch 可用)
175
+ // app 重启直启:全新闭包 + 空 stash(无 before_switch 可用);getSessionFile() 指向同一文件
176
+ //(pi 直启 resume 时 sessionManager open 的目标文件),直读最后留痕命中
171
177
  const logic2 = h.newLogic();
172
- expect(logic2).toBeDefined();
173
178
  logic2.onSessionStart("startup", undefined, h.ctx);
174
179
  logic2.onTurnStart(h.ctx);
175
180
  expect(h.entries).toHaveLength(1);
181
+ expect(h.sessionLines()).toHaveLength(1);
176
182
  });
177
183
 
178
- it("路径 3(app 重启直 spawn):基线命中但 prompt 已变 → 写 resume v2(无 parent 全文,diff 摘要缺省)", () => {
184
+ it("直读档(app 重启直 spawn resume):基线命中但 prompt 已变 → 写 resume v2,diff 摘要来自直读留痕 fullText(persisted 时代的数据层缺口已修复)", () => {
179
185
  const h = makeHarness(P1);
180
186
  h.logic.onSessionStart("startup", undefined, h.ctx);
181
187
  h.logic.onTurnStart(h.ctx); // v1
@@ -186,22 +192,23 @@ describe("A12 hash 基线跨重启恢复", () => {
186
192
  logic2.onTurnStart(h.ctx);
187
193
  expect(h.entries).toHaveLength(2);
188
194
  // 基线存在 → 该 session 已有历史版本 → resume(而非 initial 重新计数)
189
- expect(h.entries[1]).toMatchObject({ version: 2, reason: "resume" });
190
- expect(h.entries[1]?.parentVersionDiffSummary).toBeUndefined();
195
+ expect(h.entries[1]).toMatchObject({ version: 2, reason: "resume", hash: computePromptHash(P2) });
196
+ // 直读档基线含 fullText → parentVersionDiffSummary 不再缺省(F1 修复)
197
+ expect(h.entries[1]?.parentVersionDiffSummary).toContain("+1 -0 lines");
191
198
  });
192
199
 
193
- it("路径 4(兜底):三路基线都读不到且 reason=resume → 必写一条", () => {
194
- const h = makeHarness(P1);
195
- h.logic.onSessionStart("resume", undefined, h.ctx); // 无 stash、无小文件
200
+ it("路径 4(兜底):三档基线都读不到(getSessionFile 未落盘返回 undefined)且 reason=resume → 必写一条", () => {
201
+ const h = makeHarness(P1, { noSessionFile: true });
202
+ h.logic.onSessionStart("resume", undefined, h.ctx); // 无 stash、getSessionFile() undefined
196
203
  h.logic.onTurnStart(h.ctx);
197
204
  expect(h.entries).toHaveLength(1);
198
205
  expect(h.entries[0]).toMatchObject({ version: 1, reason: "resume" });
199
206
  });
200
207
 
201
- it("基线小文件损坏 → 视为无基线,resume 必写一条", () => {
208
+ it("直读档:session 文件损坏 → 视为无基线,resume 必写一条", () => {
202
209
  const h = makeHarness(P1);
203
- writeFileSync(h.baselineFile, "{ not valid json");
204
- h.logic.onSessionStart("resume", undefined, h.ctx);
210
+ writeFileSync(h.sessionFile, "{ not valid json\n");
211
+ h.logic.onSessionStart("resume", undefined, h.ctx); // 无 stash;直读损坏行全部跳过 → null
205
212
  h.logic.onTurnStart(h.ctx);
206
213
  expect(h.entries).toHaveLength(1);
207
214
  expect(h.entries[0]).toMatchObject({ version: 1, reason: "resume" });
@@ -227,28 +234,64 @@ describe("A12 hash 基线跨重启恢复", () => {
227
234
  h.logic.onSessionBeforeSwitch("resume", h.sessionFile); // switch 随后被取消,无 session_start 消费
228
235
  expect(h.stash.pending).not.toBeNull();
229
236
 
230
- // 之后用户开了全新 session:新 sessionId + reason=new → stash 被消费但不采用,从 v1 重新计数
237
+ // 之后用户开了全新 session:新 sessionId + 全新未落盘文件 → stash 被消费但不采用,
238
+ // 直读档 miss(新文件不存在),从 v1 重新计数
231
239
  const logic2 = h.newLogic();
232
- h.setSessionId("sess-a12-new");
240
+ h.openNewSession("sess-a12-new");
233
241
  logic2.onSessionStart("new", undefined, h.ctx);
234
242
  logic2.onTurnStart(h.ctx);
235
243
  expect(h.entries).toHaveLength(2);
236
244
  expect(h.entries[1]).toMatchObject({ version: 1, reason: "initial" });
237
245
  });
238
246
 
239
- it("fork(暂定语义,待 P2 实测定):previousSessionFile 直读作基线,hash 未变不写", () => {
247
+ it("fork(设计 D2 v5 定案):previousSessionFile 直读源文件最后留痕作基线,hash 未变不写", () => {
240
248
  const h = makeHarness(P1);
241
249
  h.logic.onSessionStart("startup", undefined, h.ctx);
242
250
  h.logic.onTurnStart(h.ctx); // v1(落进 h.sessionFile)
243
251
 
244
- // pi 原生 fork 不经 session_before_switch:基线来自 session_start.previousSessionFile(新 sessionId 无持久化基线)
252
+ // pi 原生 fork 不经 session_before_switch:基线来自 session_start.previousSessionFile;
253
+ // fork 新 session 是全新 id 与未落盘的新文件(常态 /fork 时点新文件不 flush,M0 探针实证)
254
+ h.openNewSession("sess-a12-fork");
245
255
  const logic2 = h.newLogic();
246
- h.setSessionId("sess-a12-fork");
247
256
  logic2.onSessionStart("fork", h.sessionFile, h.ctx);
248
257
  logic2.onTurnStart(h.ctx);
249
258
  expect(h.entries).toHaveLength(1);
250
259
  });
251
260
 
261
+ it("fork(V6 第二步):fork 基线命中确立 current 后配置回变 → 写 change,version 自源留痕续接 +1,diff 相对源留痕 fullText", () => {
262
+ // 源 session 预置两条留痕:v1(配置 A prompt)→ v2(配置 B prompt)
263
+ const h = makeHarness(P1);
264
+ h.logic.onSessionStart("startup", undefined, h.ctx);
265
+ h.logic.onTurnStart(h.ctx); // v1 initial(A)
266
+ h.setPrompt(P2);
267
+ h.logic.onTurnStart(h.ctx); // v2 change(B)
268
+ expect(h.entries).toHaveLength(2);
269
+
270
+ // fork 新 session:新闭包 + 空 stash → fork 档直读源文件最后留痕 v2 作基线
271
+ h.openNewSession("sess-a12-fork-change");
272
+ const logic2 = h.newLogic();
273
+ logic2.onSessionStart("fork", h.sessionFile, h.ctx);
274
+ logic2.onTurnStart(h.ctx); // prompt 仍 B:hash 命中源留痕 → 不写,仅确立 current(沿用上一用例语义)
275
+ expect(h.entries).toHaveLength(2);
276
+ expect(h.sessionLines()).toHaveLength(0); // fork 新文件零留痕
277
+
278
+ // 配置回变(B → A):current 已由 fork 基线命中确立 → 后续 turn 变化走 change 分支
279
+ //(而非 resume——resume 仅用于 session_start 后基线重确立的首 turn)
280
+ h.setPrompt(P1);
281
+ logic2.onTurnStart(h.ctx);
282
+ expect(h.entries).toHaveLength(3);
283
+ expect(h.entries[2]).toMatchObject({
284
+ version: 3, // 源最后留痕 v2 + 1
285
+ reason: "change", // 非 resume / 非 initial
286
+ hash: computePromptHash(P1),
287
+ fullText: P1,
288
+ });
289
+ // diff 相对源留痕 v2 的 fullText(B):P2 → P1 为删 1 行("+N -M lines" 头形态)
290
+ expect(h.entries[2]?.parentVersionDiffSummary).toContain("+0 -1 lines");
291
+ // fork 新文件恰好落盘这一条留痕
292
+ expect(h.sessionLines()).toHaveLength(1);
293
+ });
294
+
252
295
  it("readLastPromptFromSessionFile:损坏行跳过、取最后一条有效留痕;文件缺失 → null", () => {
253
296
  const scanDir = mkdtempSync(join(rootDir, "scan-"));
254
297
  const file = join(scanDir, "s.jsonl");
@@ -263,19 +306,11 @@ describe("A12 hash 基线跨重启恢复", () => {
263
306
  data: { version: 2, hash: "hash-v2", reason: "change", fullText: "new text", charCount: 8 },
264
307
  };
265
308
  writeFileSync(file, [JSON.stringify(entryV1), "{ broken json", JSON.stringify(entryV2), ""].join("\n"));
266
- expect(readLastPromptFromSessionFile(file, "target-file")).toMatchObject({
309
+ expect(readLastPromptFromSessionFile(file)).toMatchObject({
267
310
  hash: "hash-v2",
268
311
  version: 2,
269
312
  fullText: "new text",
270
- source: "target-file",
271
313
  });
272
- expect(readLastPromptFromSessionFile(join(scanDir, "missing.jsonl"), "target-file")).toBeNull();
273
- });
274
-
275
- it("自持久化小文件读写回路:write 后 read 命中同 session;其他 session → null", () => {
276
- const file = join(rootDir, "baseline.json");
277
- writePersistedBaseline(file, "sid-1", "hash-x", 3);
278
- expect(readPersistedBaseline(file, "sid-1")).toEqual({ hash: "hash-x", version: 3, source: "persisted" });
279
- expect(readPersistedBaseline(file, "sid-other")).toBeNull();
314
+ expect(readLastPromptFromSessionFile(join(scanDir, "missing.jsonl"))).toBeNull();
280
315
  });
281
316
  });
@@ -2,15 +2,16 @@
2
2
  * index.ts wiring SDK 契约测试(Gate-1.6 覆盖缺口:wiring 层 0%;round1 review「wiring 层无 SDK 契约测试」)。
3
3
  *
4
4
  * mock 边界(对齐 subagent-workflow index 测试先例):
5
- * - @earendil-works/pi-coding-agent 只 mock getAgentDir(指向临时目录)——baseline.ts 走真实 fs,
6
- * 从而验证 wiring 把基线小文件真的落在 getAgentDir() 下
5
+ * - baseline.ts 走真实 fs(previousSessionFile / getSessionFile 直读路径用真实临时文件)
7
6
  * - pi 用 Proxy 假体:捕获 on 注册的 handler 与 appendEntry 落点;handler 以 SDK 双参契约
8
- * (event, ctx) 驱动;ctx 只需 index.ts 实际消费的字段(getSystemPrompt + sessionManager.getSessionId)
7
+ * (event, ctx) 驱动;ctx 只需 index.ts 实际消费的字段(getSystemPrompt + sessionManager 的
8
+ * getSessionId / getSessionFile)
9
9
  * - @zhushanwen/pi-extension-logger mock 三导出(getLogger / createLogger / setPiHandle),
10
10
  * setPiHandle 捕获 factory 注入(D3 接线契约),logger 方法收集供吞错断言
11
11
  * - switchStash 是模块级单例:beforeEach vi.resetModules 隔离用例;同 it 内二次 dynamic import
12
12
  * 模拟「switch 重建 extension runtime + 同进程模块缓存延续」的真实链路
13
13
  */
14
+ import { createHash } from "node:crypto";
14
15
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
15
16
  import { tmpdir } from "node:os";
16
17
  import { join } from "node:path";
@@ -19,11 +20,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
20
 
20
21
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
22
 
22
- import { BASELINE_FILENAME, readPersistedBaseline } from "../baseline.js";
23
- import { computePromptHash } from "../trace.js";
24
23
  import { isSystemPromptTraceEntryData, SYSTEM_PROMPT_CUSTOM_TYPE } from "../types.js";
25
24
  import type { SystemPromptTraceEntryData } from "../types.js";
26
25
 
26
+ // trace.ts 的 computePromptHash 已收敛为包内私有(无外部消费方);测试本地同款实现计算期望值。
27
+ const computePromptHash = (text: string): string =>
28
+ createHash("sha256").update(text, "utf-8").digest("hex");
29
+
27
30
  const P1 = "wiring prompt\nline-1";
28
31
  const P2 = "wiring prompt\nline-1\nline-2-added";
29
32
 
@@ -37,12 +40,6 @@ vi.mock("@zhushanwen/pi-extension-logger", () => ({
37
40
  setPiHandle: setPiHandleMock,
38
41
  }))
39
42
 
40
- const agentDirRef = vi.hoisted(() => ({ current: "" }));
41
-
42
- vi.mock("@earendil-works/pi-coding-agent", () => ({
43
- getAgentDir: () => agentDirRef.current,
44
- }));
45
-
46
43
  type RecordedHandler = (event: unknown, ctx: unknown) => Promise<void> | void;
47
44
 
48
45
  interface RecordedEntry {
@@ -78,12 +75,20 @@ function createWiringHarness(): WiringHarness {
78
75
  return { pi, handlers, entries };
79
76
  }
80
77
 
81
- /** ctx 最小形状(index.ts 实际消费:getSystemPrompt + sessionManager.getSessionId;cwd 是 SDK 契约字段)。 */
82
- function createCtx(getPrompt: () => string, sessionId: string): Record<string, unknown> {
78
+ /** ctx 最小形状(index.ts 实际消费:getSystemPrompt + sessionManager 的 getSessionId/getSessionFile;cwd 是 SDK 契约字段)。
79
+ * getSessionFile 缺省 undefined(新 session 未落盘);需要指向真实文件时传 getSessionFileOverride。 */
80
+ function createCtx(
81
+ getPrompt: () => string,
82
+ sessionId: string,
83
+ getSessionFileOverride?: () => string | undefined,
84
+ ): Record<string, unknown> {
83
85
  return {
84
86
  cwd: "/home/user/project",
85
87
  getSystemPrompt: getPrompt,
86
- sessionManager: { getSessionId: () => sessionId },
88
+ sessionManager: {
89
+ getSessionId: () => sessionId,
90
+ getSessionFile: getSessionFileOverride ?? ((): string | undefined => undefined),
91
+ },
87
92
  };
88
93
  }
89
94
 
@@ -121,12 +126,11 @@ let rootDir = "";
121
126
 
122
127
  beforeEach(() => {
123
128
  rootDir = mkdtempSync(join(tmpdir(), "spt-wiring-"));
124
- agentDirRef.current = rootDir;
125
129
  vi.resetModules();
126
130
  });
127
131
 
128
132
  afterEach(() => {
129
- rmSync(rootDir, { recursive: true, force: true });
133
+ rmSync(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
130
134
  });
131
135
 
132
136
  describe("index.ts wiring SDK 契约", () => {
@@ -153,7 +157,7 @@ describe("index.ts wiring SDK 契约", () => {
153
157
  expect(injected).toBe(h.pi);
154
158
  });
155
159
 
156
- it("startup(无 previousSessionFile)→ 首 turn 写 initial v1(appendEntry 形状 + 基线落 getAgentDir);prompt 变化写 change v2 带 diff 摘要", async () => {
160
+ it("startup(无 previousSessionFile、session 未落盘)→ 首 turn 写 initial v1(appendEntry 形状);prompt 变化写 change v2 带 diff 摘要", async () => {
157
161
  const ext = await loadExtension();
158
162
  const h = createWiringHarness();
159
163
  ext(h.pi);
@@ -171,10 +175,7 @@ describe("index.ts wiring SDK 契约", () => {
171
175
  charCount: P1.length,
172
176
  hash: computePromptHash(P1),
173
177
  });
174
- expect(readPersistedBaseline(join(agentDirRef.current, BASELINE_FILENAME), "sess-w1")).toMatchObject({
175
- hash: computePromptHash(P1),
176
- version: 1,
177
- });
178
+ // 自持久化基线小文件已随 persisted 子系统删除(设计目标 2):无 agentDir 落盘面,仅 session JSONL
178
179
 
179
180
  prompt = P2;
180
181
  await emit(h, "turn_start", { type: "turn_start", turnIndex: 1, timestamp: 0 }, ctx);
@@ -184,7 +185,7 @@ describe("index.ts wiring SDK 契约", () => {
184
185
  expect(change.parentVersionDiffSummary).toContain("+1 -0 lines");
185
186
  });
186
187
 
187
- it("fork(previousSessionFile 为 string)→ 直读该文件作基线;hash 未变不写、仅刷新自持久化基线版本", async () => {
188
+ it("fork(previousSessionFile 为 string)→ 直读该文件最后留痕作基线(D2 v5 定案);hash 未变不写", async () => {
188
189
  const prevFile = join(rootDir, "prev-session.jsonl");
189
190
  writeSessionEntry(prevFile, {
190
191
  version: 3,
@@ -207,9 +208,52 @@ describe("index.ts wiring SDK 契约", () => {
207
208
  await emit(h, "turn_start", { type: "turn_start", turnIndex: 0, timestamp: 0 }, ctx);
208
209
 
209
210
  expect(h.entries).toHaveLength(0);
210
- expect(
211
- readPersistedBaseline(join(agentDirRef.current, BASELINE_FILENAME), "sess-w-fork"),
212
- ).toMatchObject({ version: 3 });
211
+ });
212
+
213
+ it("fork 的 previousSessionFile 三态防御:字段缺失 / 源文件未落盘 / 读取失败 → 基线 null → resume v1 兜底", async () => {
214
+ const ext = await loadExtension();
215
+ const h = createWiringHarness();
216
+ ext(h.pi);
217
+ // 态 1 判据强化(R5-F2):getSessionFile() 可读且带留痕(version 5,hash = P1)——
218
+ // 若 fork 缺失 previousSessionFile 时 fall-through 直读档,基线命中 P1 → 首 turn
219
+ // 不写 entry(下方三 entry 断言即失败);D2 语义(fork 只认 previousSessionFile,
220
+ // 缺失即 null,不落直读档)下必写 resume v1。
221
+ const readableFile = join(rootDir, "readable-session.jsonl");
222
+ writeSessionEntry(readableFile, {
223
+ version: 5,
224
+ hash: computePromptHash(P1),
225
+ reason: "change",
226
+ fullText: P1,
227
+ charCount: P1.length,
228
+ });
229
+ const ctxFallbackReadable = createCtx(() => P1, "sess-w-fork-def", () => readableFile);
230
+ const ctx = createCtx(() => P1, "sess-w-fork-def");
231
+
232
+ // 态 1:字段缺失(in-memory fork 且源 sessionFile 为 undefined)——即便
233
+ // getSessionFile() 可读带留痕,fork 档也不落直读档
234
+ await emit(h, "session_start", { type: "session_start", reason: "fork" }, ctxFallbackReadable);
235
+ await emit(h, "turn_start", { type: "turn_start", turnIndex: 0, timestamp: 0 }, ctxFallbackReadable);
236
+ // 态 2:源文件未落盘(源会话尚无 assistant 即 fork,首条 assistant 前 pi _persist 不落盘)
237
+ await emit(
238
+ h,
239
+ "session_start",
240
+ { type: "session_start", reason: "fork", previousSessionFile: join(rootDir, "not-flushed.jsonl") },
241
+ ctx,
242
+ );
243
+ await emit(h, "turn_start", { type: "turn_start", turnIndex: 1, timestamp: 0 }, ctx);
244
+ // 态 3:读取失败(路径指向目录,readFileSync EISDIR)
245
+ await emit(
246
+ h,
247
+ "session_start",
248
+ { type: "session_start", reason: "fork", previousSessionFile: rootDir },
249
+ ctx,
250
+ );
251
+ await emit(h, "turn_start", { type: "turn_start", turnIndex: 2, timestamp: 0 }, ctx);
252
+
253
+ expect(h.entries).toHaveLength(3);
254
+ expect(entryData(h, 0)).toMatchObject({ version: 1, reason: "resume", hash: computePromptHash(P1) });
255
+ expect(entryData(h, 1)).toMatchObject({ version: 1, reason: "resume" });
256
+ expect(entryData(h, 2)).toMatchObject({ version: 1, reason: "resume" });
213
257
  });
214
258
 
215
259
  it("session_before_switch(targetSessionFile 为 string)→ 模块级 stash 跨 runtime 传递;新 runtime resume + hash 未变 → 不写", async () => {
@@ -6,17 +6,24 @@
6
6
  * 首个 turn_start 写 initial/resume
7
7
  * - hash 去重:相同不重写;变化写 change 且 parentVersionDiffSummary 生成
8
8
  * - SessionStartEvent.reason 原生 5 值(startup/reload/new/resume/fork)的落盘映射:
9
- * initial←startup/new、resume←resume 定案;fork/reload 暂按 resume(待 P2 实测定,A13 探针固化后更新)
9
+ * initial←startup/new、resume←resume/fork/reload(设计 D2 v5 定案:fork 基线取源文件
10
+ * 最后留痕、reload 为同 session 运行时重建,均属重开语义)
10
11
  *
11
12
  * 本文件用内存 fake env(文件系统路径的跨重启恢复归 A12)。
12
13
  */
14
+ import { createHash } from "node:crypto";
15
+
13
16
  import { describe, expect, it } from "vitest";
14
17
 
15
- import { computePromptHash, createSystemPromptTrace } from "../trace.js";
18
+ import { createSystemPromptTrace } from "../trace.js";
16
19
  import type { SystemPromptTrace, TraceContext, TraceEnv } from "../trace.js";
17
20
  import { isSystemPromptTraceEntryData, SYSTEM_PROMPT_CUSTOM_TYPE } from "../types.js";
18
21
  import type { SystemPromptTraceEntryData, SwitchStash } from "../types.js";
19
22
 
23
+ // trace.ts 的 computePromptHash 已收敛为包内私有(无外部消费方);测试本地同款实现计算期望值。
24
+ const computePromptHash = (text: string): string =>
25
+ createHash("sha256").update(text, "utf-8").digest("hex");
26
+
20
27
  const P1 = "You are a coding agent.\nFollow AGENTS.md.";
21
28
  const P2 = "You are a coding agent.\nFollow AGENTS.md.\n[Available Models] glm-5.1 / ds-flash";
22
29
 
@@ -34,15 +41,14 @@ function makeHarness(initialPrompt: string): Harness {
34
41
  const entries: SystemPromptTraceEntryData[] = [];
35
42
  let prompt = initialPrompt;
36
43
  const stash: SwitchStash = { pending: null };
37
- // A11 不涉文件路径:三路基线全部 miss,隔离验证时机/去重/映射逻辑
44
+ // A11 不涉文件路径:三档基线全部 miss(直读 / fork prev 文件路径均不可读),隔离验证时机/去重/映射逻辑
38
45
  const env: TraceEnv = {
39
46
  readLastPromptFromFile: () => null,
40
- readPersistedBaseline: () => null,
41
- writePersistedBaseline: () => {},
42
47
  };
43
48
  const ctx: TraceContext = {
44
49
  getSystemPrompt: () => prompt,
45
50
  getSessionId: () => "sess-a11",
51
+ getSessionFile: () => undefined,
46
52
  appendEntry: (customType, data) => {
47
53
  expect(customType).toBe(SYSTEM_PROMPT_CUSTOM_TYPE);
48
54
  if (!isSystemPromptTraceEntryData(data)) {
@@ -140,7 +146,7 @@ describe("A11 留痕时机与去重", () => {
140
146
  expect(h.entries[2]).toMatchObject({ version: 3, reason: "change", hash: computePromptHash(P1) });
141
147
  });
142
148
 
143
- describe("reason 5 值映射(fork/reload 待 P2 实测定)", () => {
149
+ describe("reason 5 值映射(设计 D2 v5 定案)", () => {
144
150
  it("定案映射:startup/new → initial;resume → resume", () => {
145
151
  const hStartup = makeHarness(P1);
146
152
  hStartup.logic.onSessionStart("startup", undefined, hStartup.ctx);
@@ -156,10 +162,10 @@ describe("A11 留痕时机与去重", () => {
156
162
  expect(hResume.entries[0]?.reason).toBe("resume");
157
163
  });
158
164
 
159
- // 【待 P2 实测定】fork/reload 的落盘 reason 暂按 resume(fork 新文件携带源 session 历史
160
- // entry、版本链延续;reload 是同 session 的 extension 运行时重建——语义上都更接近「重开」)。
161
- // A13 探针(pi CLI 实测 resume 链路 reason 值)固化后更新本断言与 mapReasonForFirstWrite。
162
- it("暂定映射:fork/reload → resume(P2 实测后固化,届时同步更新此断言)", () => {
165
+ // 定案(设计 D2 v5,M0 探针回炉后):fork/reload → resume。fork 基线取源文件最后留痕
166
+ // (previousSessionFile 缺失/不可读 → null 走本映射兜底);reload 是同 session 的
167
+ // extension 运行时重建——两者语义上都是「重开」而非「首建」。
168
+ it("定案映射:fork/reload → resume(previousSessionFile 存在但 env 全 miss 时同样兜底)", () => {
163
169
  const hFork = makeHarness(P1);
164
170
  hFork.logic.onSessionStart("fork", "/prev/session.jsonl", hFork.ctx);
165
171
  hFork.logic.onTurnStart(hFork.ctx);
@@ -169,13 +175,5 @@ describe("A11 留痕时机与去重", () => {
169
175
  expect(hFork.entries[0]?.reason).toBe("resume");
170
176
  expect(hReload.entries[0]?.reason).toBe("resume");
171
177
  });
172
-
173
- it("未知 reason(untyped extension 场景)按 startup → initial", () => {
174
- const h = makeHarness(P1);
175
- h.logic.onSessionStart("garbage-value", undefined, h.ctx);
176
- h.logic.onTurnStart(h.ctx);
177
- expect(h.entries).toHaveLength(1);
178
- expect(h.entries[0]?.reason).toBe("initial");
179
- });
180
178
  });
181
179
  });
package/src/baseline.ts CHANGED
@@ -1,48 +1,28 @@
1
1
  /**
2
- * 跨重启基线的文件系统侧实现(四路径口径,见 trace.ts onSessionStart 解析优先级:
3
- * 路径 1 stash 直读 / 路径 2 fork prev 直读的读,与路径 3 自持久化小文件的读和写)。
2
+ * 跨重启基线的文件系统侧实现(三档口径,见 trace.ts onSessionStart 解析优先级:
3
+ * 档 1 stash 直读 / 档 2 fork 事件 previousSessionFile 直读 / 档 3 getSessionFile() 直读)。
4
4
  *
5
- * - readLastPromptFromSessionFile:直读 session JSONL(进程内 resume 的 targetSessionFile、
6
- * fork 暂定的 previousSessionFile),倒序找最后一条 xyz:system-prompt 留痕 entry。
7
- * - readPersistedBaseline / writePersistedBaseline:agentDir 自持久化小文件
8
- * (app 重启直 spawn resume / reload 等无 switch 事件链路的唯一基线来源),原子写入。
5
+ * readLastPromptFromSessionFile 是三档统一的读取入口:直读 session JSONL
6
+ * (进程内 resume 的 targetSessionFile、fork 的 previousSessionFile、reload / 直启 resume
7
+ * 的 getSessionFile() 目标文件),倒序找最后一条 xyz:system-prompt 留痕 entry。
9
8
  *
10
- * 所有函数不抛错:读失败返回 null、写失败 logger.error 后静默——基线丢失的代价只是
9
+ * 所有函数不抛错:读失败返回 null——基线丢失的代价只是
11
10
  * 下次 resume 多写一条留痕(设计 D2 已接受),不允许影响 agent 主流程。
12
11
  */
13
12
 
14
- import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
15
- import { dirname } from "node:path";
13
+ import { readFileSync } from "node:fs";
16
14
 
17
- import { getLogger } from "@zhushanwen/pi-extension-logger";
15
+ import { isRecord } from "@zhushanwen/pi-ext-guards";
18
16
 
19
- const logger = getLogger("pi-system-prompt-trace");
20
- import { isRecord, isSystemPromptTraceEntryData, SYSTEM_PROMPT_CUSTOM_TYPE } from "./types.js";
17
+ import { isSystemPromptTraceEntryData, SYSTEM_PROMPT_CUSTOM_TYPE } from "./types.js";
21
18
  import type { PromptBaseline } from "./types.js";
22
19
 
23
- /** 自持久化基线文件名(位于 pi agentDir 下)。 */
24
- export const BASELINE_FILENAME = "system-prompt-trace-baseline.json";
25
-
26
- /** 基线 map 保留的 session 数上限(按 updatedAt 保留最近 N 个,防多 session 长期使用无限增长)。 */
27
- const MAX_BASELINE_SESSIONS = 64;
28
-
29
- interface PersistedBaselineEntry {
30
- hash: string;
31
- version: number;
32
- updatedAt: string;
33
- }
34
-
35
- interface PersistedBaselineFile {
36
- schemaVersion: 1;
37
- sessions: Record<string, PersistedBaselineEntry>;
38
- }
39
-
40
20
  /**
41
21
  * 解析单行 session JSONL 为留痕 entry data(运行时 guard;任何形状不符 / JSON 损坏返回 null)。
42
22
  * pi 落盘形状:{"type":"custom","customType":"xyz:system-prompt","data":{...},...}
43
23
  * (session-manager.ts:1122 appendCustomEntry)。
44
24
  */
45
- export function parseTraceEntryData(line: string): { hash: string; version: number; fullText: string } | null {
25
+ function parseTraceEntryData(line: string): { hash: string; version: number; fullText: string } | null {
46
26
  let parsed: unknown;
47
27
  try {
48
28
  parsed = JSON.parse(line);
@@ -60,10 +40,7 @@ export function parseTraceEntryData(line: string): { hash: string; version: numb
60
40
  * 倒序扫描 session JSONL,取最后一条留痕 entry 作基线。
61
41
  * 文件缺失 / 全部损坏 / 无留痕 entry(旧 session 先于本 extension)→ null。
62
42
  */
63
- export function readLastPromptFromSessionFile(
64
- sessionFilePath: string,
65
- source: "target-file" | "previous-session-file",
66
- ): PromptBaseline | null {
43
+ export function readLastPromptFromSessionFile(sessionFilePath: string): PromptBaseline | null {
67
44
  let content: string;
68
45
  try {
69
46
  content = readFileSync(sessionFilePath, "utf-8");
@@ -76,126 +53,8 @@ export function readLastPromptFromSessionFile(
76
53
  if (line === "") continue;
77
54
  const parsed = parseTraceEntryData(line);
78
55
  if (parsed !== null) {
79
- return { hash: parsed.hash, version: parsed.version, fullText: parsed.fullText, source };
56
+ return { hash: parsed.hash, version: parsed.version, fullText: parsed.fullText };
80
57
  }
81
58
  }
82
59
  return null;
83
60
  }
84
-
85
- /** 读自持久化基线小文件。文件缺失 / JSON 损坏 / 形状不符 → null(视为无基线)。 */
86
- export function readPersistedBaseline(baselineFilePath: string, sessionId: string): PromptBaseline | null {
87
- let raw: string;
88
- try {
89
- raw = readFileSync(baselineFilePath, "utf-8");
90
- } catch {
91
- return null;
92
- }
93
- let parsed: unknown;
94
- try {
95
- parsed = JSON.parse(raw);
96
- } catch {
97
- return null;
98
- }
99
- if (!isRecord(parsed)) return null;
100
- const sessions = parsed["sessions"];
101
- if (!isRecord(sessions)) return null;
102
- if (!Object.hasOwn(sessions, sessionId)) return null;
103
- const entry = sessions[sessionId];
104
- if (!isRecord(entry)) return null;
105
- const hash = entry["hash"];
106
- const version = entry["version"];
107
- if (typeof hash !== "string" || typeof version !== "number") return null;
108
- return { hash, version, source: "persisted" };
109
- }
110
-
111
- /**
112
- * 写自持久化基线(read-modify-write + 临时文件原子 rename)。
113
- *
114
- * 并发语义:session pool 下多个 pi 进程共享同一 agentDir,RMW 竞态按 last-writer-wins
115
- * 容忍(丢的是某 session 的基线 → 下次 resume 走兜底多写一条,设计 D2 已接受);
116
- * 原子 rename 保证读方永远不会看到半截 JSON。跨进程共享豁免锁论证 + tmp 唯一化登记于
117
- * data-source-registry.md §6(PR #186 MF2)。
118
- */
119
-
120
- // ── 原子写 tmp 唯一化(对齐 quota-providers cache.ts / ext-config W4 同款)──
121
- // 固定名 `<path>.tmp` 在多 pi 进程并发写时可碰撞互相截断;后缀 = pid + 36 进制随机段,
122
- // 保证写方间名字空间不相交。
123
- const TMP_RANDOM_BASE = 36;
124
- const TMP_RANDOM_SLICE_START = 2; // 跳过 Math.random 字符串的 "0." 前缀
125
- const TMP_RANDOM_SLICE_END = 10;
126
- function uniqueTmpPath(filePath: string): string {
127
- return `${filePath}.tmp_${process.pid}_${Math.random()
128
- .toString(TMP_RANDOM_BASE)
129
- .slice(TMP_RANDOM_SLICE_START, TMP_RANDOM_SLICE_END)}`;
130
- }
131
-
132
- export function writePersistedBaseline(
133
- baselineFilePath: string,
134
- sessionId: string,
135
- hash: string,
136
- version: number,
137
- ): void {
138
- try {
139
- const file = loadBaselineFileForWrite(baselineFilePath);
140
- file.sessions[sessionId] = { hash, version, updatedAt: new Date().toISOString() };
141
- pruneSessions(file.sessions);
142
- mkdirSync(dirname(baselineFilePath), { recursive: true });
143
- const tmpPath = uniqueTmpPath(baselineFilePath);
144
- try {
145
- writeFileSync(tmpPath, JSON.stringify(file, null, "\t") + "\n");
146
- renameSync(tmpPath, baselineFilePath);
147
- } catch (err) {
148
- // 唯一名不自覆盖:写/rename 抛错的残留 tmp 须显式清理后重抛(registry §6 本文件
149
- // 条目;对齐 quota-providers atomicWriteJson);清理失败不掩盖原错误
150
- try {
151
- if (existsSync(tmpPath)) unlinkSync(tmpPath);
152
- } catch (cleanupErr) {
153
- // 清理失败仅残留一个小文件,原错误优先上抛
154
- void cleanupErr;
155
- }
156
- throw err;
157
- }
158
- } catch (e) {
159
- // best-effort 降级:基线写失败只影响下次 app 重启 resume 的去重(多写一条留痕,
160
- // 设计 D2 已接受),不阻断 agent 主流程;logger.error → appendEntry 持久化供排查
161
- logger.error("write baseline failed", { detail: String(e) });
162
- }
163
- }
164
-
165
- /** 读现有基线文件供改写;读不到 / 损坏 → 空文件重开(逐 entry 校验,垃圾 entry 直接丢弃)。 */
166
- function loadBaselineFileForWrite(baselineFilePath: string): PersistedBaselineFile {
167
- try {
168
- const parsed: unknown = JSON.parse(readFileSync(baselineFilePath, "utf-8"));
169
- if (!isRecord(parsed)) return emptyBaselineFile();
170
- const sessions = parsed["sessions"];
171
- if (!isRecord(sessions)) return emptyBaselineFile();
172
- const clean: Record<string, PersistedBaselineEntry> = {};
173
- for (const key of Object.keys(sessions)) {
174
- const v = sessions[key];
175
- if (!isRecord(v)) continue;
176
- const hash = v["hash"];
177
- const version = v["version"];
178
- const updatedAt = v["updatedAt"];
179
- if (typeof hash === "string" && typeof version === "number" && typeof updatedAt === "string") {
180
- clean[key] = { hash, version, updatedAt };
181
- }
182
- }
183
- return { schemaVersion: 1, sessions: clean };
184
- } catch {
185
- return emptyBaselineFile();
186
- }
187
- }
188
-
189
- function emptyBaselineFile(): PersistedBaselineFile {
190
- return { schemaVersion: 1, sessions: {} };
191
- }
192
-
193
- /** 超出上限时按 updatedAt 保留最近的 session(原地裁剪)。 */
194
- function pruneSessions(sessions: Record<string, PersistedBaselineEntry>): void {
195
- const keys = Object.keys(sessions);
196
- if (keys.length <= MAX_BASELINE_SESSIONS) return;
197
- keys.sort((a, b) => sessions[b].updatedAt.localeCompare(sessions[a].updatedAt));
198
- for (const key of keys.slice(MAX_BASELINE_SESSIONS)) {
199
- delete sessions[key];
200
- }
201
- }
package/src/index.ts CHANGED
@@ -1,45 +1,26 @@
1
1
  /**
2
2
  * pi extension wiring:把 ExtensionContext 适配进 trace.ts 状态机并订阅三个事件。
3
3
  *
4
- * 事件宽松类型(参考 rename-session TurnEndLikeEvent 先例):pi 的 on() 重载对严格事件类型
5
- * 做参数逆变匹配,收窄字段的本地接口更稳;字段在逻辑侧运行时归一化(normalizeSessionStartReason)。
4
+ * 事件直接标注 SDK 具名类型(与同组 msg-id-mapper / system-prompt 的既有风格一致):
5
+ * pi 的 on() 按事件名重载,handler 参数即 SDK 事件类型。SDK reason 枚举演进时
6
+ * mapReasonForFirstWrite 的 switch 在编译期报 non-exhaustive(负防腐),不再有运行时静默归一。
6
7
  */
7
8
 
8
- import { join } from "node:path";
9
-
10
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
11
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import type {
10
+ ExtensionAPI,
11
+ ExtensionContext,
12
+ SessionBeforeSwitchEvent,
13
+ SessionStartEvent,
14
+ TurnStartEvent,
15
+ } from "@earendil-works/pi-coding-agent";
12
16
 
13
17
  import { setPiHandle } from "@zhushanwen/pi-extension-logger";
14
18
 
15
- import {
16
- BASELINE_FILENAME,
17
- readLastPromptFromSessionFile,
18
- readPersistedBaseline,
19
- writePersistedBaseline,
20
- } from "./baseline.js";
19
+ import { readLastPromptFromSessionFile } from "./baseline.js";
21
20
  import { createSystemPromptTrace } from "./trace.js";
22
21
  import type { TraceContext, TraceEnv } from "./trace.js";
23
22
  import type { SwitchStash } from "./types.js";
24
23
 
25
- interface SessionStartLikeEvent {
26
- type: "session_start";
27
- reason: string;
28
- previousSessionFile?: string;
29
- }
30
-
31
- interface SessionBeforeSwitchLikeEvent {
32
- type: "session_before_switch";
33
- reason: string;
34
- targetSessionFile?: string;
35
- }
36
-
37
- interface TurnStartLikeEvent {
38
- type: "turn_start";
39
- turnIndex: number;
40
- timestamp: number;
41
- }
42
-
43
24
  // 模块级单例:session_before_switch(旧 runtime)→ session_start(新 runtime)之间传递
44
25
  // targetSessionFile 直读基线。switch 会 teardown 并重建 extension runtime,闭包状态不跨
45
26
  // runtime 存活(见 types.ts SwitchStash 注释),只有模块缓存在进程内延续。
@@ -51,28 +32,24 @@ function toTraceContext(pi: ExtensionAPI, ctx: ExtensionContext): TraceContext {
51
32
  getSystemPrompt: () => ctx.getSystemPrompt(),
52
33
  appendEntry: (customType, data) => pi.appendEntry(customType, data),
53
34
  getSessionId: () => ctx.sessionManager.getSessionId(),
35
+ getSessionFile: () => ctx.sessionManager.getSessionFile(),
54
36
  };
55
37
  }
56
38
 
57
39
  export default function systemPromptTraceExtension(pi: ExtensionAPI): void {
58
40
  // 日志通道注入(extension-logger 两阶段初始化:工厂拿 pi → setPiHandle)。缺此注入时
59
- // trace/baseline 中 logger.error 的 appendEntry 通道是 no-op,生产默认完全静默——
41
+ // trace 中 logger.error 的 appendEntry 通道是 no-op,生产默认完全静默——
60
42
  // 此前全包从未注入(文本-实现漂移审查 D3 补接线,恢复注释声称的持久化语义)。
61
43
  // 测试环境无真实 globalPi 消费方时调用本身安全(存引用,不触发 appendEntry)。
62
44
  setPiHandle(pi);
63
45
 
64
- const baselineFilePath = join(getAgentDir(), BASELINE_FILENAME);
65
-
66
46
  const env: TraceEnv = {
67
- readLastPromptFromFile: (filePath) => readLastPromptFromSessionFile(filePath, "target-file"),
68
- readPersistedBaseline: (sessionId) => readPersistedBaseline(baselineFilePath, sessionId),
69
- writePersistedBaseline: (sessionId, hash, version) =>
70
- writePersistedBaseline(baselineFilePath, sessionId, hash, version),
47
+ readLastPromptFromFile: (filePath) => readLastPromptFromSessionFile(filePath),
71
48
  };
72
49
 
73
50
  const logic = createSystemPromptTrace(env, switchStash);
74
51
 
75
- pi.on("session_start", async (event: SessionStartLikeEvent, ctx: ExtensionContext) => {
52
+ pi.on("session_start", async (event: SessionStartEvent, ctx: ExtensionContext) => {
76
53
  logic.onSessionStart(
77
54
  event.reason,
78
55
  typeof event.previousSessionFile === "string" ? event.previousSessionFile : undefined,
@@ -80,14 +57,14 @@ export default function systemPromptTraceExtension(pi: ExtensionAPI): void {
80
57
  );
81
58
  });
82
59
 
83
- pi.on("session_before_switch", async (event: SessionBeforeSwitchLikeEvent) => {
60
+ pi.on("session_before_switch", async (event: SessionBeforeSwitchEvent) => {
84
61
  logic.onSessionBeforeSwitch(
85
62
  event.reason,
86
63
  typeof event.targetSessionFile === "string" ? event.targetSessionFile : undefined,
87
64
  );
88
65
  });
89
66
 
90
- pi.on("turn_start", async (_event: TurnStartLikeEvent, ctx: ExtensionContext) => {
67
+ pi.on("turn_start", async (_event: TurnStartEvent, ctx: ExtensionContext) => {
91
68
  logic.onTurnStart(toTraceContext(pi, ctx));
92
69
  });
93
70
  }
package/src/trace.ts CHANGED
@@ -12,18 +12,15 @@
12
12
 
13
13
  import { createHash } from "node:crypto";
14
14
 
15
+ import type { SessionStartEvent } from "@earendil-works/pi-coding-agent";
16
+
15
17
  import { getLogger } from "@zhushanwen/pi-extension-logger";
16
18
 
17
19
  const logger = getLogger("pi-system-prompt-trace");
18
20
  import { summarizePromptDiff } from "./diff.js";
19
- import {
20
- mapReasonForFirstWrite,
21
- normalizeSessionStartReason,
22
- SYSTEM_PROMPT_CUSTOM_TYPE,
23
- } from "./types.js";
21
+ import { mapReasonForFirstWrite, SYSTEM_PROMPT_CUSTOM_TYPE } from "./types.js";
24
22
  import type {
25
23
  PromptBaseline,
26
- SessionStartReason,
27
24
  SwitchStash,
28
25
  SystemPromptTraceEntryData,
29
26
  TraceReason,
@@ -34,17 +31,21 @@ export interface TraceContext {
34
31
  getSystemPrompt(): string;
35
32
  appendEntry(customType: string, data: unknown): void;
36
33
  getSessionId(): string;
34
+ /** 当前 session 文件路径(pi sessionManager.getSessionFile();首条 assistant 落盘前可能 undefined)。 */
35
+ getSessionFile(): string | undefined;
37
36
  }
38
37
 
39
- /** 文件系统侧依赖(wiring 用真实 fs + agentDir;测试注入临时目录实现)。 */
38
+ /** 文件系统侧依赖(wiring 用真实 fs;测试注入临时目录实现)。 */
40
39
  export interface TraceEnv {
41
40
  readLastPromptFromFile(filePath: string): PromptBaseline | null;
42
- readPersistedBaseline(sessionId: string): PromptBaseline | null;
43
- writePersistedBaseline(sessionId: string, hash: string, version: number): void;
44
41
  }
45
42
 
46
43
  export interface SystemPromptTrace {
47
- onSessionStart(reason: string, previousSessionFile: string | undefined, ctx: TraceContext): void;
44
+ onSessionStart(
45
+ reason: SessionStartEvent["reason"],
46
+ previousSessionFile: string | undefined,
47
+ ctx: TraceContext,
48
+ ): void;
48
49
  onSessionBeforeSwitch(reason: string, targetSessionFile: string | undefined): void;
49
50
  onTurnStart(ctx: TraceContext): void;
50
51
  }
@@ -56,12 +57,12 @@ interface CurrentPrompt {
56
57
  fullText: string;
57
58
  }
58
59
 
59
- export function computePromptHash(text: string): string {
60
+ function computePromptHash(text: string): string {
60
61
  return createHash("sha256").update(text, "utf-8").digest("hex");
61
62
  }
62
63
 
63
64
  export function createSystemPromptTrace(env: TraceEnv, stash: SwitchStash): SystemPromptTrace {
64
- let sessionStartReason: SessionStartReason | null = null;
65
+ let sessionStartReason: SessionStartEvent["reason"] | null = null;
65
66
  let baseline: PromptBaseline | null = null;
66
67
  let current: CurrentPrompt | null = null;
67
68
 
@@ -84,29 +85,29 @@ export function createSystemPromptTrace(env: TraceEnv, stash: SwitchStash): Syst
84
85
  data.parentVersionDiffSummary = summarizePromptDiff(parentFullText, text);
85
86
  }
86
87
  ctx.appendEntry(SYSTEM_PROMPT_CUSTOM_TYPE, data);
87
- // 落盘成功后才刷新自持久化基线(app 重启直 spawn resume / reload 的唯一基线来源,设计 D2 路径 3)
88
- env.writePersistedBaseline(ctx.getSessionId(), hash, version);
89
88
  };
90
89
 
91
90
  return {
92
91
  onSessionStart(reason, previousSessionFile, ctx) {
93
- sessionStartReason = normalizeSessionStartReason(reason);
92
+ sessionStartReason = reason;
94
93
  current = null;
95
- // 基线解析(设计 D2 跨重启四路径,优先级从高到低):
96
- // 1. session_before_switch 直读目标文件(进程内 resume;stash 为模块级单例,跨 runtime 传递)
97
- // 2. fork 的 previousSessionFile 直读【暂定语义,待 P2 实测定】
98
- // 3. agentDir 自持久化小文件(app 重启直 spawn resume / reload——这两种链路没有 switch 事件)
94
+ // 基线解析(设计 D1/D2 v5 三档,优先级从高到低):
95
+ // 1. stash:session_before_switch 直读目标文件(进程内 resume;stash 为模块级单例,跨 runtime 传递)
96
+ // 2. fork 档:读事件 previousSessionFile(源 session 文件)最后留痕——常态 /fork 时点
97
+ // fork 新文件未落盘(M0 探针实证),直读不可靠;缺失/未落盘/读取失败 → null
98
+ // 3. 直读档:ctx.getSessionFile() 指向当前 session 文件(覆盖 reload / 直启 resume /
99
+ // new 兜底;文件不存在或无留痕 → null)
99
100
  // 4. 全 miss → null:首个 turn 按 reason 映射写 initial/resume(resume 必写 = 兜底路径)
100
101
  // stash 无论是否采用都消费:cancelled switch 的残留基线不允许污染下一次 session_start
101
102
  const stashed = stash.pending;
102
103
  stash.pending = null;
103
104
  if (stashed !== null && sessionStartReason === "resume") {
104
105
  baseline = stashed;
105
- } else if (sessionStartReason === "fork" && previousSessionFile !== undefined) {
106
- const fromPrev = env.readLastPromptFromFile(previousSessionFile);
107
- baseline = fromPrev === null ? null : { ...fromPrev, source: "previous-session-file" };
106
+ } else if (sessionStartReason === "fork") {
107
+ baseline = previousSessionFile !== undefined ? env.readLastPromptFromFile(previousSessionFile) : null;
108
108
  } else {
109
- baseline = env.readPersistedBaseline(ctx.getSessionId());
109
+ const sessionFile = ctx.getSessionFile();
110
+ baseline = sessionFile === undefined ? null : env.readLastPromptFromFile(sessionFile);
110
111
  }
111
112
  },
112
113
 
@@ -133,10 +134,8 @@ export function createSystemPromptTrace(env: TraceEnv, stash: SwitchStash): Syst
133
134
  }
134
135
  // 本 session_start 周期的首个 turn_start
135
136
  if (baseline !== null && baseline.hash === hash) {
136
- // 跨重启基线命中且未变化:不写,但确立 current 供后续 turn 继续对比;
137
- // 顺带刷新自持久化基线(updatedAt 续命 + 小文件丢失时自愈)
137
+ // 跨重启基线命中且未变化:不写,但确立 current 供后续 turn 继续对比
138
138
  current = { version: baseline.version, hash, fullText: text };
139
- env.writePersistedBaseline(ctx.getSessionId(), hash, baseline.version);
140
139
  return;
141
140
  }
142
141
  // 必写:有基线 → resume(该 session 已有历史版本,这是重开点的快照,version 续接);
package/src/types.ts CHANGED
@@ -5,12 +5,12 @@
5
5
  * 数据形状见 SystemPromptTraceEntryData。
6
6
  */
7
7
 
8
+ import { isRecord } from "@zhushanwen/pi-ext-guards";
9
+ import type { SessionStartEvent } from "@earendil-works/pi-coding-agent";
10
+
8
11
  /** 留痕 entry 的 customType(xyz: 前缀 = xyz-agent 自定义命名空间)。 */
9
12
  export const SYSTEM_PROMPT_CUSTOM_TYPE = "xyz:system-prompt";
10
13
 
11
- /** pi SessionStartEvent.reason 原生 5 值(pi 源码 core/extensions/types.ts:565)。 */
12
- export type SessionStartReason = "startup" | "reload" | "new" | "resume" | "fork";
13
-
14
14
  /** 落盘 reason 枚举(initial/resume/change,对齐 DSH request/header 语义,设计 D2)。 */
15
15
  export type TraceReason = "initial" | "resume" | "change";
16
16
 
@@ -25,18 +25,16 @@ export interface SystemPromptTraceEntryData {
25
25
  fullText: string;
26
26
  /** fullText.length(UTF-16 码元数)。 */
27
27
  charCount: number;
28
- /** 与上一版的行级 diff 摘要;无 parent 全文(自持久化基线只有 hash/首条)时缺省。 */
28
+ /** 与上一版的行级 diff 摘要;首条留痕(无 parent)时缺省。 */
29
29
  parentVersionDiffSummary?: string;
30
30
  }
31
31
 
32
- /** 跨重启恢复的 hash 基线。 */
32
+ /** 跨重启恢复的 hash 基线(三档解析统一产自 session JSONL 留痕 entry 直读)。 */
33
33
  export interface PromptBaseline {
34
34
  hash: string;
35
35
  version: number;
36
- /** 从 session 文件留痕 entry 直读时有值(可生成 diff 摘要);自持久化小文件只有 hash+version。 */
36
+ /** 留痕 entry 直读恒有值(可生成 diff 摘要)。 */
37
37
  fullText?: string;
38
- /** 基线来源(四路径口径,见 trace.ts onSessionStart 的解析优先级)。 */
39
- source: "target-file" | "previous-session-file" | "persisted";
40
38
  }
41
39
 
42
40
  /**
@@ -49,11 +47,6 @@ export interface SwitchStash {
49
47
  pending: PromptBaseline | null;
50
48
  }
51
49
 
52
- /** 运行时类型 guard(taste/no-unsafe-cast:断言必须有运行时 guard,这里干脆不用断言)。 */
53
- export function isRecord(value: unknown): value is Record<string, unknown> {
54
- return typeof value === "object" && value !== null;
55
- }
56
-
57
50
  /** 留痕 entry data 的运行时 guard(读 JSONL / 测试断言复用)。 */
58
51
  export function isSystemPromptTraceEntryData(value: unknown): value is SystemPromptTraceEntryData {
59
52
  if (!isRecord(value)) return false;
@@ -72,32 +65,19 @@ export function isSystemPromptTraceEntryData(value: unknown): value is SystemPro
72
65
  );
73
66
  }
74
67
 
75
- const SESSION_START_REASONS: readonly SessionStartReason[] = [
76
- "startup",
77
- "reload",
78
- "new",
79
- "resume",
80
- "fork",
81
- ];
82
-
83
- /** 事件侧 reason 归一化:untyped extension 传入非 5 值时按 startup 处理(最保守:无基线则 initial)。 */
84
- export function normalizeSessionStartReason(raw: string): SessionStartReason {
85
- return SESSION_START_REASONS.find((r) => r === raw) ?? "startup";
86
- }
87
-
88
68
  /**
89
69
  * 无基线时 SessionStartEvent.reason → 落盘 reason 的映射(A11)。
90
70
  *
91
71
  * - startup / new → initial(新 session 首建快照)
92
72
  * - resume → resume(重开快照)
93
- * - fork / reload → resume【暂定,待 P2 实测定(A13 探针),测试显式标注】:
94
- * fork 的新文件携带源 session 的历史 entry(版本链延续,且 xyz-agent 的 fork 实际经
95
- * switchSession 走 resume 链路),reload 是同 session 的 extension 运行时重建——
96
- * 两者语义上都更接近「重开」而非「首建」。
73
+ * - fork / reload → resume(设计 D2 v5 定案):fork 基线取源文件最后留痕(previousSessionFile,
74
+ * 缺失/未落盘/读取失败 → null 走本映射兜底);reload 是同 session 的 extension 运行时重建——
75
+ * 两者语义上都是「重开」而非「首建」。
97
76
  *
98
- * 注意:只要恢复了任一 hash 基线,首个 turn 一律写 resume(见 trace.ts),不走本映射。
77
+ * 注意:基线恢复且 hash 未变 → 不写(去重);需写时(hash 已变)恒为 resume,不走本映射
78
+ * (见 trace.ts onTurnStart)。
99
79
  */
100
- export function mapReasonForFirstWrite(reason: SessionStartReason): TraceReason {
80
+ export function mapReasonForFirstWrite(reason: SessionStartEvent["reason"]): TraceReason {
101
81
  switch (reason) {
102
82
  case "startup":
103
83
  case "new":