@zhushanwen/pi-system-prompt-trace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,293 @@
1
+ /**
2
+ * index.ts wiring SDK 契约测试(Gate-1.6 覆盖缺口:wiring 层 0%;round1 review「wiring 层无 SDK 契约测试」)。
3
+ *
4
+ * mock 边界(对齐 subagent-workflow index 测试先例):
5
+ * - @earendil-works/pi-coding-agent 只 mock getAgentDir(指向临时目录)——baseline.ts 走真实 fs,
6
+ * 从而验证 wiring 把基线小文件真的落在 getAgentDir() 下
7
+ * - pi 用 Proxy 假体:捕获 on 注册的 handler 与 appendEntry 落点;handler 以 SDK 双参契约
8
+ * (event, ctx) 驱动;ctx 只需 index.ts 实际消费的字段(getSystemPrompt + sessionManager.getSessionId)
9
+ * - switchStash 是模块级单例:beforeEach vi.resetModules 隔离用例;同 it 内二次 dynamic import
10
+ * 模拟「switch 重建 extension runtime + 同进程模块缓存延续」的真实链路
11
+ */
12
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
17
+
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+
20
+ import { BASELINE_FILENAME, readPersistedBaseline } from "../baseline.js";
21
+ import { computePromptHash } from "../trace.js";
22
+ import { isSystemPromptTraceEntryData, SYSTEM_PROMPT_CUSTOM_TYPE } from "../types.js";
23
+ import type { SystemPromptTraceEntryData } from "../types.js";
24
+
25
+ const P1 = "wiring prompt\nline-1";
26
+ const P2 = "wiring prompt\nline-1\nline-2-added";
27
+
28
+ const agentDirRef = vi.hoisted(() => ({ current: "" }));
29
+
30
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
31
+ getAgentDir: () => agentDirRef.current,
32
+ }));
33
+
34
+ type RecordedHandler = (event: unknown, ctx: unknown) => Promise<void> | void;
35
+
36
+ interface RecordedEntry {
37
+ customType: string;
38
+ data: unknown;
39
+ }
40
+
41
+ interface WiringHarness {
42
+ pi: ExtensionAPI;
43
+ handlers: Map<string, RecordedHandler>;
44
+ entries: RecordedEntry[];
45
+ }
46
+
47
+ /** Proxy 假体 pi:捕获 on 注册的 handler 与 appendEntry 落点(其余成员 no-op)。 */
48
+ function createWiringHarness(): WiringHarness {
49
+ const handlers = new Map<string, RecordedHandler>();
50
+ const entries: RecordedEntry[] = [];
51
+ const pi = new Proxy<ExtensionAPI>({} as ExtensionAPI, {
52
+ get(_target: unknown, prop: string | symbol): unknown {
53
+ if (prop === "on") {
54
+ return (event: string, handler: RecordedHandler): void => {
55
+ handlers.set(event, handler);
56
+ };
57
+ }
58
+ if (prop === "appendEntry") {
59
+ return (customType: string, data?: unknown): void => {
60
+ entries.push({ customType, data });
61
+ };
62
+ }
63
+ return (): void => undefined;
64
+ },
65
+ });
66
+ return { pi, handlers, entries };
67
+ }
68
+
69
+ /** ctx 最小形状(index.ts 实际消费:getSystemPrompt + sessionManager.getSessionId;cwd 是 SDK 契约字段)。 */
70
+ function createCtx(getPrompt: () => string, sessionId: string): Record<string, unknown> {
71
+ return {
72
+ cwd: "/home/user/project",
73
+ getSystemPrompt: getPrompt,
74
+ sessionManager: { getSessionId: () => sessionId },
75
+ };
76
+ }
77
+
78
+ /** 以 SDK 双参契约 (event, ctx) 驱动已注册 handler。 */
79
+ async function emit(h: WiringHarness, event: string, payload: unknown, ctx: unknown): Promise<void> {
80
+ const handler = h.handlers.get(event);
81
+ if (handler === undefined) throw new Error(`handler for "${event}" not registered`);
82
+ await handler(payload, ctx);
83
+ }
84
+
85
+ /** 模拟 pi 落盘形状的留痕 entry(供 previousSessionFile / targetSessionFile 直读路径)。 */
86
+ function writeSessionEntry(filePath: string, data: SystemPromptTraceEntryData): void {
87
+ writeFileSync(
88
+ filePath,
89
+ JSON.stringify({ type: "custom", customType: SYSTEM_PROMPT_CUSTOM_TYPE, data }) + "\n",
90
+ );
91
+ }
92
+
93
+ /** resetModules 后重新加载 index.ts;同 it 内二次调用拿同一模块实例(switchStash 共享)。 */
94
+ async function loadExtension(): Promise<(pi: ExtensionAPI) => void> {
95
+ const mod = await import("../index.js");
96
+ return mod.default;
97
+ }
98
+
99
+ /** 取第 index 条 entry data(运行时 guard,拒绝 wiring 产出畸形 entry)。 */
100
+ function entryData(h: WiringHarness, index: number): SystemPromptTraceEntryData {
101
+ const data = h.entries[index]?.data;
102
+ if (!isSystemPromptTraceEntryData(data)) {
103
+ throw new Error(`entry data shape invalid: ${JSON.stringify(data)}`);
104
+ }
105
+ return data;
106
+ }
107
+
108
+ let rootDir = "";
109
+
110
+ beforeEach(() => {
111
+ rootDir = mkdtempSync(join(tmpdir(), "spt-wiring-"));
112
+ agentDirRef.current = rootDir;
113
+ vi.resetModules();
114
+ });
115
+
116
+ afterEach(() => {
117
+ rmSync(rootDir, { recursive: true, force: true });
118
+ });
119
+
120
+ describe("index.ts wiring SDK 契约", () => {
121
+ it("注册恰好三个事件 handler(session_start / session_before_switch / turn_start)", async () => {
122
+ const ext = await loadExtension();
123
+ const h = createWiringHarness();
124
+ ext(h.pi);
125
+ expect([...h.handlers.keys()].sort()).toEqual([
126
+ "session_before_switch",
127
+ "session_start",
128
+ "turn_start",
129
+ ]);
130
+ });
131
+
132
+ it("startup(无 previousSessionFile)→ 首 turn 写 initial v1(appendEntry 形状 + 基线落 getAgentDir);prompt 变化写 change v2 带 diff 摘要", async () => {
133
+ const ext = await loadExtension();
134
+ const h = createWiringHarness();
135
+ ext(h.pi);
136
+ let prompt = P1;
137
+ const ctx = createCtx(() => prompt, "sess-w1");
138
+
139
+ await emit(h, "session_start", { type: "session_start", reason: "startup" }, ctx);
140
+ await emit(h, "turn_start", { type: "turn_start", turnIndex: 0, timestamp: 0 }, ctx);
141
+ expect(h.entries).toHaveLength(1);
142
+ expect(h.entries[0]?.customType).toBe(SYSTEM_PROMPT_CUSTOM_TYPE);
143
+ expect(entryData(h, 0)).toMatchObject({
144
+ version: 1,
145
+ reason: "initial",
146
+ fullText: P1,
147
+ charCount: P1.length,
148
+ hash: computePromptHash(P1),
149
+ });
150
+ expect(readPersistedBaseline(join(agentDirRef.current, BASELINE_FILENAME), "sess-w1")).toMatchObject({
151
+ hash: computePromptHash(P1),
152
+ version: 1,
153
+ });
154
+
155
+ prompt = P2;
156
+ await emit(h, "turn_start", { type: "turn_start", turnIndex: 1, timestamp: 0 }, ctx);
157
+ expect(h.entries).toHaveLength(2);
158
+ const change = entryData(h, 1);
159
+ expect(change).toMatchObject({ version: 2, reason: "change", hash: computePromptHash(P2) });
160
+ expect(change.parentVersionDiffSummary).toContain("+1 -0 lines");
161
+ });
162
+
163
+ it("fork(previousSessionFile 为 string)→ 直读该文件作基线;hash 未变不写、仅刷新自持久化基线版本", async () => {
164
+ const prevFile = join(rootDir, "prev-session.jsonl");
165
+ writeSessionEntry(prevFile, {
166
+ version: 3,
167
+ hash: computePromptHash(P1),
168
+ reason: "change",
169
+ fullText: P1,
170
+ charCount: P1.length,
171
+ });
172
+
173
+ const ext = await loadExtension();
174
+ const h = createWiringHarness();
175
+ ext(h.pi);
176
+ const ctx = createCtx(() => P1, "sess-w-fork");
177
+ await emit(
178
+ h,
179
+ "session_start",
180
+ { type: "session_start", reason: "fork", previousSessionFile: prevFile },
181
+ ctx,
182
+ );
183
+ await emit(h, "turn_start", { type: "turn_start", turnIndex: 0, timestamp: 0 }, ctx);
184
+
185
+ expect(h.entries).toHaveLength(0);
186
+ expect(
187
+ readPersistedBaseline(join(agentDirRef.current, BASELINE_FILENAME), "sess-w-fork"),
188
+ ).toMatchObject({ version: 3 });
189
+ });
190
+
191
+ it("session_before_switch(targetSessionFile 为 string)→ 模块级 stash 跨 runtime 传递;新 runtime resume + hash 未变 → 不写", async () => {
192
+ const targetFile = join(rootDir, "target-session.jsonl");
193
+ writeSessionEntry(targetFile, {
194
+ version: 2,
195
+ hash: computePromptHash(P1),
196
+ reason: "resume",
197
+ fullText: P1,
198
+ charCount: P1.length,
199
+ });
200
+
201
+ // 旧 runtime:before_switch 直读目标文件 → stash(该 handler 只消费 event)
202
+ const oldExt = await loadExtension();
203
+ const oldH = createWiringHarness();
204
+ oldExt(oldH.pi);
205
+ await emit(
206
+ oldH,
207
+ "session_before_switch",
208
+ { type: "session_before_switch", reason: "resume", targetSessionFile: targetFile },
209
+ undefined,
210
+ );
211
+
212
+ // switch 重建 runtime:同进程模块缓存延续 → switchStash 传递基线
213
+ const newExt = await loadExtension();
214
+ const newH = createWiringHarness();
215
+ newExt(newH.pi);
216
+ const ctx = createCtx(() => P1, "sess-w-switch");
217
+ await emit(newH, "session_start", { type: "session_start", reason: "resume" }, ctx);
218
+ await emit(newH, "turn_start", { type: "turn_start", turnIndex: 0, timestamp: 0 }, ctx);
219
+ expect(newH.entries).toHaveLength(0);
220
+ });
221
+
222
+ it("stash 基线 hash 与当前不同 → 新 runtime 写 resume 续接版本,diff 摘要 parent 全文来自目标文件", async () => {
223
+ const targetFile = join(rootDir, "target-session.jsonl");
224
+ writeSessionEntry(targetFile, {
225
+ version: 2,
226
+ hash: computePromptHash(P1),
227
+ reason: "resume",
228
+ fullText: P1,
229
+ charCount: P1.length,
230
+ });
231
+
232
+ const oldExt = await loadExtension();
233
+ const oldH = createWiringHarness();
234
+ oldExt(oldH.pi);
235
+ await emit(
236
+ oldH,
237
+ "session_before_switch",
238
+ { type: "session_before_switch", reason: "resume", targetSessionFile: targetFile },
239
+ undefined,
240
+ );
241
+
242
+ const newExt = await loadExtension();
243
+ const newH = createWiringHarness();
244
+ newExt(newH.pi);
245
+ const ctx = createCtx(() => P2, "sess-w-switch2");
246
+ await emit(newH, "session_start", { type: "session_start", reason: "resume" }, ctx);
247
+ await emit(newH, "turn_start", { type: "turn_start", turnIndex: 0, timestamp: 0 }, ctx);
248
+
249
+ expect(newH.entries).toHaveLength(1);
250
+ const entry = entryData(newH, 0);
251
+ expect(entry).toMatchObject({ version: 3, reason: "resume", hash: computePromptHash(P2) });
252
+ expect(entry.parentVersionDiffSummary).toContain("+1 -0 lines");
253
+ });
254
+
255
+ it("session_before_switch 无 targetSessionFile → 不读文件;resume 兜底必写 v1", async () => {
256
+ const ext = await loadExtension();
257
+ const h = createWiringHarness();
258
+ ext(h.pi);
259
+ await emit(h, "session_before_switch", { type: "session_before_switch", reason: "new" }, undefined);
260
+ const ctx = createCtx(() => P1, "sess-w6");
261
+ await emit(h, "session_start", { type: "session_start", reason: "resume" }, ctx);
262
+ await emit(h, "turn_start", { type: "turn_start", turnIndex: 0, timestamp: 0 }, ctx);
263
+
264
+ expect(h.entries).toHaveLength(1);
265
+ expect(entryData(h, 0)).toMatchObject({
266
+ version: 1,
267
+ reason: "resume",
268
+ hash: computePromptHash(P1),
269
+ });
270
+ });
271
+
272
+ it("getSystemPrompt 抛错 → handler 吞掉不写 entry(留痕是诊断旁路,不影响 agent 主流程)", async () => {
273
+ const ext = await loadExtension();
274
+ const h = createWiringHarness();
275
+ ext(h.pi);
276
+ const boomCtx = createCtx(() => {
277
+ throw new Error("prompt boom");
278
+ }, "sess-w7");
279
+
280
+ let logged = 0;
281
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {
282
+ logged++;
283
+ });
284
+ try {
285
+ await emit(h, "session_start", { type: "session_start", reason: "startup" }, boomCtx);
286
+ await emit(h, "turn_start", { type: "turn_start", turnIndex: 0, timestamp: 0 }, boomCtx);
287
+ } finally {
288
+ errSpy.mockRestore();
289
+ }
290
+ expect(h.entries).toHaveLength(0);
291
+ expect(logged).toBe(1);
292
+ });
293
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * A11 留痕时机与去重(spec:.cw-specs/trace-ext.json;设计 D2 / plan §2.1)。
3
+ *
4
+ * 覆盖:
5
+ * - 写入时机:session_start 不写(emit 早于 resources_discover 的 prompt 重建,必误报——设计 D2 校正),
6
+ * 首个 turn_start 写 initial/resume
7
+ * - hash 去重:相同不重写;变化写 change 且 parentVersionDiffSummary 生成
8
+ * - SessionStartEvent.reason 原生 5 值(startup/reload/new/resume/fork)的落盘映射:
9
+ * initial←startup/new、resume←resume 定案;fork/reload 暂按 resume(待 P2 实测定,A13 探针固化后更新)
10
+ *
11
+ * 本文件用内存 fake env(文件系统路径的跨重启恢复归 A12)。
12
+ */
13
+ import { describe, expect, it } from "vitest";
14
+
15
+ import { computePromptHash, createSystemPromptTrace } from "../trace.js";
16
+ import type { SystemPromptTrace, TraceContext, TraceEnv } from "../trace.js";
17
+ import { isSystemPromptTraceEntryData, SYSTEM_PROMPT_CUSTOM_TYPE } from "../types.js";
18
+ import type { SystemPromptTraceEntryData, SwitchStash } from "../types.js";
19
+
20
+ const P1 = "You are a coding agent.\nFollow AGENTS.md.";
21
+ const P2 = "You are a coding agent.\nFollow AGENTS.md.\n[Available Models] glm-5.1 / ds-flash";
22
+
23
+ interface Harness {
24
+ logic: SystemPromptTrace;
25
+ ctx: TraceContext;
26
+ stash: SwitchStash;
27
+ entries: SystemPromptTraceEntryData[];
28
+ setPrompt(text: string): void;
29
+ /** 模拟 switchSession 后 extension runtime 重建(新闭包,共享模块级 stash) */
30
+ newLogic(): SystemPromptTrace;
31
+ }
32
+
33
+ function makeHarness(initialPrompt: string): Harness {
34
+ const entries: SystemPromptTraceEntryData[] = [];
35
+ let prompt = initialPrompt;
36
+ const stash: SwitchStash = { pending: null };
37
+ // A11 不涉文件路径:三路基线全部 miss,隔离验证时机/去重/映射逻辑
38
+ const env: TraceEnv = {
39
+ readLastPromptFromFile: () => null,
40
+ readPersistedBaseline: () => null,
41
+ writePersistedBaseline: () => {},
42
+ };
43
+ const ctx: TraceContext = {
44
+ getSystemPrompt: () => prompt,
45
+ getSessionId: () => "sess-a11",
46
+ appendEntry: (customType, data) => {
47
+ expect(customType).toBe(SYSTEM_PROMPT_CUSTOM_TYPE);
48
+ if (!isSystemPromptTraceEntryData(data)) {
49
+ throw new Error(`entry data shape invalid: ${JSON.stringify(data)}`);
50
+ }
51
+ entries.push(data);
52
+ },
53
+ };
54
+ const makeLogic = (): SystemPromptTrace => createSystemPromptTrace(env, stash);
55
+ return {
56
+ logic: makeLogic(),
57
+ ctx,
58
+ stash,
59
+ entries,
60
+ setPrompt: (text) => {
61
+ prompt = text;
62
+ },
63
+ newLogic: makeLogic,
64
+ };
65
+ }
66
+
67
+ describe("A11 留痕时机与去重", () => {
68
+ it("session_start 不写(emit 早于 prompt 重建);首个 turn_start 写 initial v1(hash/fullText/charCount 齐全)", () => {
69
+ const h = makeHarness(P1);
70
+ h.logic.onSessionStart("startup", undefined, h.ctx);
71
+ expect(h.entries).toHaveLength(0);
72
+ h.logic.onTurnStart(h.ctx);
73
+ expect(h.entries).toHaveLength(1);
74
+ const entry = h.entries[0];
75
+ if (entry === undefined) throw new Error("entry missing");
76
+ expect(entry).toMatchObject({
77
+ version: 1,
78
+ reason: "initial",
79
+ fullText: P1,
80
+ charCount: P1.length,
81
+ hash: computePromptHash(P1),
82
+ });
83
+ expect(entry.parentVersionDiffSummary).toBeUndefined();
84
+ });
85
+
86
+ it("new 与 startup 同映射 initial(previousSessionFile 存在也不误作基线)", () => {
87
+ const h = makeHarness(P1);
88
+ h.logic.onSessionStart("new", "/old/session.jsonl", h.ctx);
89
+ h.logic.onTurnStart(h.ctx);
90
+ expect(h.entries).toHaveLength(1);
91
+ expect(h.entries[0]?.reason).toBe("initial");
92
+ expect(h.entries[0]?.version).toBe(1);
93
+ });
94
+
95
+ it("resume 无任何基线时兜底必写一条 reason=resume", () => {
96
+ const h = makeHarness(P1);
97
+ h.logic.onSessionStart("resume", undefined, h.ctx);
98
+ h.logic.onTurnStart(h.ctx);
99
+ expect(h.entries).toHaveLength(1);
100
+ expect(h.entries[0]).toMatchObject({ version: 1, reason: "resume" });
101
+ });
102
+
103
+ it("hash 相同的后续 turn_start 不重写", () => {
104
+ const h = makeHarness(P1);
105
+ h.logic.onSessionStart("startup", undefined, h.ctx);
106
+ h.logic.onTurnStart(h.ctx);
107
+ h.logic.onTurnStart(h.ctx);
108
+ h.logic.onTurnStart(h.ctx);
109
+ expect(h.entries).toHaveLength(1);
110
+ });
111
+
112
+ it("prompt 变化写 change v2 且 parentVersionDiffSummary 生成", () => {
113
+ const h = makeHarness(P1);
114
+ h.logic.onSessionStart("startup", undefined, h.ctx);
115
+ h.logic.onTurnStart(h.ctx);
116
+ h.setPrompt(P2);
117
+ h.logic.onTurnStart(h.ctx);
118
+ expect(h.entries).toHaveLength(2);
119
+ const entry = h.entries[1];
120
+ if (entry === undefined) throw new Error("entry missing");
121
+ expect(entry).toMatchObject({
122
+ version: 2,
123
+ reason: "change",
124
+ hash: computePromptHash(P2),
125
+ charCount: P2.length,
126
+ });
127
+ expect(entry.parentVersionDiffSummary).toContain("+1 -0 lines");
128
+ expect(entry.parentVersionDiffSummary).toContain("+ [Available Models]");
129
+ });
130
+
131
+ it("prompt 变回旧值再写 change v3(时间线保留真实历史,只对当前版本去重)", () => {
132
+ const h = makeHarness(P1);
133
+ h.logic.onSessionStart("startup", undefined, h.ctx);
134
+ h.logic.onTurnStart(h.ctx); // v1 initial (P1)
135
+ h.setPrompt(P2);
136
+ h.logic.onTurnStart(h.ctx); // v2 change (P2)
137
+ h.setPrompt(P1);
138
+ h.logic.onTurnStart(h.ctx); // v3 change (P1)
139
+ expect(h.entries).toHaveLength(3);
140
+ expect(h.entries[2]).toMatchObject({ version: 3, reason: "change", hash: computePromptHash(P1) });
141
+ });
142
+
143
+ describe("reason 5 值映射(fork/reload 待 P2 实测定)", () => {
144
+ it("定案映射:startup/new → initial;resume → resume", () => {
145
+ const hStartup = makeHarness(P1);
146
+ hStartup.logic.onSessionStart("startup", undefined, hStartup.ctx);
147
+ hStartup.logic.onTurnStart(hStartup.ctx);
148
+ const hNew = makeHarness(P1);
149
+ hNew.logic.onSessionStart("new", undefined, hNew.ctx);
150
+ hNew.logic.onTurnStart(hNew.ctx);
151
+ const hResume = makeHarness(P1);
152
+ hResume.logic.onSessionStart("resume", undefined, hResume.ctx);
153
+ hResume.logic.onTurnStart(hResume.ctx);
154
+ expect(hStartup.entries[0]?.reason).toBe("initial");
155
+ expect(hNew.entries[0]?.reason).toBe("initial");
156
+ expect(hResume.entries[0]?.reason).toBe("resume");
157
+ });
158
+
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 实测后固化,届时同步更新此断言)", () => {
163
+ const hFork = makeHarness(P1);
164
+ hFork.logic.onSessionStart("fork", "/prev/session.jsonl", hFork.ctx);
165
+ hFork.logic.onTurnStart(hFork.ctx);
166
+ const hReload = makeHarness(P1);
167
+ hReload.logic.onSessionStart("reload", undefined, hReload.ctx);
168
+ hReload.logic.onTurnStart(hReload.ctx);
169
+ expect(hFork.entries[0]?.reason).toBe("resume");
170
+ expect(hReload.entries[0]?.reason).toBe("resume");
171
+ });
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
+ });
181
+ });
@@ -0,0 +1,196 @@
1
+ /**
2
+ * 跨重启基线的文件系统侧实现(设计 D2 三路径中的路径 1/2 的读与路径 2 的写)。
3
+ *
4
+ * - readLastPromptFromSessionFile:直读 session JSONL(进程内 resume 的 targetSessionFile、
5
+ * fork 暂定的 previousSessionFile),倒序找最后一条 xyz:system-prompt 留痕 entry。
6
+ * - readPersistedBaseline / writePersistedBaseline:dataDir 自持久化小文件
7
+ * (app 重启直 spawn resume 时唯一可用的基线来源),原子写入。
8
+ *
9
+ * 所有函数不抛错:读失败返回 null、写失败 console.error 后静默——基线丢失的代价只是
10
+ * 下次 resume 多写一条留痕(设计 D2 已接受),不允许影响 agent 主流程。
11
+ */
12
+
13
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
14
+ import { dirname } from "node:path";
15
+
16
+ import { isRecord, isSystemPromptTraceEntryData, SYSTEM_PROMPT_CUSTOM_TYPE } from "./types.js";
17
+ import type { PromptBaseline } from "./types.js";
18
+
19
+ /** 自持久化基线文件名(位于 pi agentDir 下)。 */
20
+ export const BASELINE_FILENAME = "system-prompt-trace-baseline.json";
21
+
22
+ /** 基线 map 保留的 session 数上限(按 updatedAt 保留最近 N 个,防多 session 长期使用无限增长)。 */
23
+ const MAX_BASELINE_SESSIONS = 64;
24
+
25
+ interface PersistedBaselineEntry {
26
+ hash: string;
27
+ version: number;
28
+ updatedAt: string;
29
+ }
30
+
31
+ interface PersistedBaselineFile {
32
+ schemaVersion: 1;
33
+ sessions: Record<string, PersistedBaselineEntry>;
34
+ }
35
+
36
+ /**
37
+ * 解析单行 session JSONL 为留痕 entry data(运行时 guard;任何形状不符 / JSON 损坏返回 null)。
38
+ * pi 落盘形状:{"type":"custom","customType":"xyz:system-prompt","data":{...},...}
39
+ * (session-manager.ts:1122 appendCustomEntry)。
40
+ */
41
+ export function parseTraceEntryData(line: string): { hash: string; version: number; fullText: string } | null {
42
+ let parsed: unknown;
43
+ try {
44
+ parsed = JSON.parse(line);
45
+ } catch {
46
+ return null;
47
+ }
48
+ if (!isRecord(parsed)) return null;
49
+ if (parsed["type"] !== "custom" || parsed["customType"] !== SYSTEM_PROMPT_CUSTOM_TYPE) return null;
50
+ const data = parsed["data"];
51
+ if (!isSystemPromptTraceEntryData(data)) return null;
52
+ return { hash: data.hash, version: data.version, fullText: data.fullText };
53
+ }
54
+
55
+ /**
56
+ * 倒序扫描 session JSONL,取最后一条留痕 entry 作基线。
57
+ * 文件缺失 / 全部损坏 / 无留痕 entry(旧 session 先于本 extension)→ null。
58
+ */
59
+ export function readLastPromptFromSessionFile(
60
+ sessionFilePath: string,
61
+ source: "target-file" | "previous-session-file",
62
+ ): PromptBaseline | null {
63
+ let content: string;
64
+ try {
65
+ content = readFileSync(sessionFilePath, "utf-8");
66
+ } catch {
67
+ return null;
68
+ }
69
+ const lines = content.split("\n");
70
+ for (let i = lines.length - 1; i >= 0; i--) {
71
+ const line = lines[i].trim();
72
+ if (line === "") continue;
73
+ const parsed = parseTraceEntryData(line);
74
+ if (parsed !== null) {
75
+ return { hash: parsed.hash, version: parsed.version, fullText: parsed.fullText, source };
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /** 读自持久化基线小文件。文件缺失 / JSON 损坏 / 形状不符 → null(视为无基线)。 */
82
+ export function readPersistedBaseline(baselineFilePath: string, sessionId: string): PromptBaseline | null {
83
+ let raw: string;
84
+ try {
85
+ raw = readFileSync(baselineFilePath, "utf-8");
86
+ } catch {
87
+ return null;
88
+ }
89
+ let parsed: unknown;
90
+ try {
91
+ parsed = JSON.parse(raw);
92
+ } catch {
93
+ return null;
94
+ }
95
+ if (!isRecord(parsed)) return null;
96
+ const sessions = parsed["sessions"];
97
+ if (!isRecord(sessions)) return null;
98
+ if (!Object.hasOwn(sessions, sessionId)) return null;
99
+ const entry = sessions[sessionId];
100
+ if (!isRecord(entry)) return null;
101
+ const hash = entry["hash"];
102
+ const version = entry["version"];
103
+ if (typeof hash !== "string" || typeof version !== "number") return null;
104
+ return { hash, version, source: "persisted" };
105
+ }
106
+
107
+ /**
108
+ * 写自持久化基线(read-modify-write + 临时文件原子 rename)。
109
+ *
110
+ * 并发语义:session pool 下多个 pi 进程共享同一 agentDir,RMW 竞态按 last-writer-wins
111
+ * 容忍(丢的是某 session 的基线 → 下次 resume 走兜底多写一条,设计 D2 已接受);
112
+ * 原子 rename 保证读方永远不会看到半截 JSON。跨进程共享豁免锁论证 + tmp 唯一化登记于
113
+ * data-source-registry.md §6(PR #186 MF2)。
114
+ */
115
+
116
+ // ── 原子写 tmp 唯一化(对齐 quota-providers cache.ts / ext-config W4 同款)──
117
+ // 固定名 `<path>.tmp` 在多 pi 进程并发写时可碰撞互相截断;后缀 = pid + 36 进制随机段,
118
+ // 保证写方间名字空间不相交。
119
+ const TMP_RANDOM_BASE = 36;
120
+ const TMP_RANDOM_SLICE_START = 2; // 跳过 Math.random 字符串的 "0." 前缀
121
+ const TMP_RANDOM_SLICE_END = 10;
122
+ function uniqueTmpPath(filePath: string): string {
123
+ return `${filePath}.tmp_${process.pid}_${Math.random()
124
+ .toString(TMP_RANDOM_BASE)
125
+ .slice(TMP_RANDOM_SLICE_START, TMP_RANDOM_SLICE_END)}`;
126
+ }
127
+
128
+ export function writePersistedBaseline(
129
+ baselineFilePath: string,
130
+ sessionId: string,
131
+ hash: string,
132
+ version: number,
133
+ ): void {
134
+ try {
135
+ const file = loadBaselineFileForWrite(baselineFilePath);
136
+ file.sessions[sessionId] = { hash, version, updatedAt: new Date().toISOString() };
137
+ pruneSessions(file.sessions);
138
+ mkdirSync(dirname(baselineFilePath), { recursive: true });
139
+ const tmpPath = uniqueTmpPath(baselineFilePath);
140
+ try {
141
+ writeFileSync(tmpPath, JSON.stringify(file, null, "\t") + "\n");
142
+ renameSync(tmpPath, baselineFilePath);
143
+ } catch (err) {
144
+ // 唯一名不自覆盖:写/rename 抛错的残留 tmp 须显式清理后重抛(registry §6 本文件
145
+ // 条目;对齐 quota-providers atomicWriteJson);清理失败不掩盖原错误
146
+ try {
147
+ if (existsSync(tmpPath)) unlinkSync(tmpPath);
148
+ } catch {
149
+ // 清理失败仅残留一个小文件,原错误优先上抛
150
+ }
151
+ throw err;
152
+ }
153
+ } catch (e) {
154
+ // best-effort 降级:基线写失败只影响下次 app 重启 resume 的去重(多写一条留痕,
155
+ // 设计 D2 已接受),不阻断 agent 主流程;错误进 pi stdout 随日志落盘供排查
156
+ console.error("[pi-system-prompt-trace] write baseline failed:", e);
157
+ }
158
+ }
159
+
160
+ /** 读现有基线文件供改写;读不到 / 损坏 → 空文件重开(逐 entry 校验,垃圾 entry 直接丢弃)。 */
161
+ function loadBaselineFileForWrite(baselineFilePath: string): PersistedBaselineFile {
162
+ try {
163
+ const parsed: unknown = JSON.parse(readFileSync(baselineFilePath, "utf-8"));
164
+ if (!isRecord(parsed)) return emptyBaselineFile();
165
+ const sessions = parsed["sessions"];
166
+ if (!isRecord(sessions)) return emptyBaselineFile();
167
+ const clean: Record<string, PersistedBaselineEntry> = {};
168
+ for (const key of Object.keys(sessions)) {
169
+ const v = sessions[key];
170
+ if (!isRecord(v)) continue;
171
+ const hash = v["hash"];
172
+ const version = v["version"];
173
+ const updatedAt = v["updatedAt"];
174
+ if (typeof hash === "string" && typeof version === "number" && typeof updatedAt === "string") {
175
+ clean[key] = { hash, version, updatedAt };
176
+ }
177
+ }
178
+ return { schemaVersion: 1, sessions: clean };
179
+ } catch {
180
+ return emptyBaselineFile();
181
+ }
182
+ }
183
+
184
+ function emptyBaselineFile(): PersistedBaselineFile {
185
+ return { schemaVersion: 1, sessions: {} };
186
+ }
187
+
188
+ /** 超出上限时按 updatedAt 保留最近的 session(原地裁剪)。 */
189
+ function pruneSessions(sessions: Record<string, PersistedBaselineEntry>): void {
190
+ const keys = Object.keys(sessions);
191
+ if (keys.length <= MAX_BASELINE_SESSIONS) return;
192
+ keys.sort((a, b) => sessions[b].updatedAt.localeCompare(sessions[a].updatedAt));
193
+ for (const key of keys.slice(MAX_BASELINE_SESSIONS)) {
194
+ delete sessions[key];
195
+ }
196
+ }