@zhushanwen/pi-system-prompt-trace 0.1.5 → 0.1.6
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 +13 -8
- package/package.json +2 -2
- package/src/__tests__/baseline.a12.test.ts +104 -69
- package/src/__tests__/index-wiring.test.ts +69 -25
- package/src/__tests__/trace.a11.test.ts +16 -18
- package/src/baseline.ts +10 -153
- package/src/index.ts +17 -40
- package/src/trace.ts +25 -26
- package/src/types.ts +11 -27
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 |
|
|
34
|
+
| fork / reload | resume | 定案(M0 探针 P2 实测:fork 档读源文件最后留痕,落盘 reason 维持 resume;执行记录见设计 §6.5) |
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
存在 hash 基线(见下)且 prompt 未变时,首个 turn 不写(去重);需写时(hash 已变)恒写 `resume`(无论 session_start reason)。
|
|
37
37
|
|
|
38
|
-
## 跨重启 hash
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.6",
|
|
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,7 @@
|
|
|
33
33
|
"vitest": "^4.1.8"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@zhushanwen/pi-extension-logger": "0.
|
|
36
|
+
"@zhushanwen/pi-extension-logger": "0.6.0"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"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
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
|
43
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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(
|
|
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("
|
|
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(
|
|
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
|
|
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("
|
|
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
|
-
|
|
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
|
|
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("
|
|
208
|
+
it("直读档:session 文件损坏 → 视为无基线,resume 必写一条", () => {
|
|
202
209
|
const h = makeHarness(P1);
|
|
203
|
-
writeFileSync(h.
|
|
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 +
|
|
237
|
+
// 之后用户开了全新 session:新 sessionId + 全新未落盘文件 → stash 被消费但不采用,
|
|
238
|
+
// 直读档 miss(新文件不存在),从 v1 重新计数
|
|
231
239
|
const logic2 = h.newLogic();
|
|
232
|
-
h.
|
|
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
|
|
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
|
|
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
|
|
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")
|
|
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
|
-
* -
|
|
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
|
|
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
|
|
82
|
-
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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)→
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
it("
|
|
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,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* 跨重启基线的文件系统侧实现(三档口径,见 trace.ts onSessionStart 解析优先级:
|
|
3
|
+
* 档 1 stash 直读 / 档 2 fork 事件 previousSessionFile 直读 / 档 3 getSessionFile() 直读)。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
9
|
+
* 所有函数不抛错:读失败返回 null——基线丢失的代价只是
|
|
11
10
|
* 下次 resume 多写一条留痕(设计 D2 已接受),不允许影响 agent 主流程。
|
|
12
11
|
*/
|
|
13
12
|
|
|
14
|
-
import {
|
|
15
|
-
import { dirname } from "node:path";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
16
14
|
|
|
17
|
-
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
18
|
-
|
|
19
|
-
const logger = getLogger("pi-system-prompt-trace");
|
|
20
15
|
import { isRecord, isSystemPromptTraceEntryData, SYSTEM_PROMPT_CUSTOM_TYPE } from "./types.js";
|
|
21
16
|
import type { PromptBaseline } from "./types.js";
|
|
22
17
|
|
|
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
18
|
/**
|
|
41
19
|
* 解析单行 session JSONL 为留痕 entry data(运行时 guard;任何形状不符 / JSON 损坏返回 null)。
|
|
42
20
|
* pi 落盘形状:{"type":"custom","customType":"xyz:system-prompt","data":{...},...}
|
|
43
21
|
* (session-manager.ts:1122 appendCustomEntry)。
|
|
44
22
|
*/
|
|
45
|
-
|
|
23
|
+
function parseTraceEntryData(line: string): { hash: string; version: number; fullText: string } | null {
|
|
46
24
|
let parsed: unknown;
|
|
47
25
|
try {
|
|
48
26
|
parsed = JSON.parse(line);
|
|
@@ -60,10 +38,7 @@ export function parseTraceEntryData(line: string): { hash: string; version: numb
|
|
|
60
38
|
* 倒序扫描 session JSONL,取最后一条留痕 entry 作基线。
|
|
61
39
|
* 文件缺失 / 全部损坏 / 无留痕 entry(旧 session 先于本 extension)→ null。
|
|
62
40
|
*/
|
|
63
|
-
export function readLastPromptFromSessionFile(
|
|
64
|
-
sessionFilePath: string,
|
|
65
|
-
source: "target-file" | "previous-session-file",
|
|
66
|
-
): PromptBaseline | null {
|
|
41
|
+
export function readLastPromptFromSessionFile(sessionFilePath: string): PromptBaseline | null {
|
|
67
42
|
let content: string;
|
|
68
43
|
try {
|
|
69
44
|
content = readFileSync(sessionFilePath, "utf-8");
|
|
@@ -76,126 +51,8 @@ export function readLastPromptFromSessionFile(
|
|
|
76
51
|
if (line === "") continue;
|
|
77
52
|
const parsed = parseTraceEntryData(line);
|
|
78
53
|
if (parsed !== null) {
|
|
79
|
-
return { hash: parsed.hash, version: parsed.version, fullText: parsed.fullText
|
|
54
|
+
return { hash: parsed.hash, version: parsed.version, fullText: parsed.fullText };
|
|
80
55
|
}
|
|
81
56
|
}
|
|
82
57
|
return null;
|
|
83
58
|
}
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
92
|
+
sessionStartReason = reason;
|
|
94
93
|
current = null;
|
|
95
|
-
// 基线解析(设计 D2
|
|
96
|
-
// 1. session_before_switch 直读目标文件(进程内 resume;stash 为模块级单例,跨 runtime 传递)
|
|
97
|
-
// 2. fork
|
|
98
|
-
//
|
|
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"
|
|
106
|
-
|
|
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
|
-
|
|
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,11 @@
|
|
|
5
5
|
* 数据形状见 SystemPromptTraceEntryData。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { SessionStartEvent } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
8
10
|
/** 留痕 entry 的 customType(xyz: 前缀 = xyz-agent 自定义命名空间)。 */
|
|
9
11
|
export const SYSTEM_PROMPT_CUSTOM_TYPE = "xyz:system-prompt";
|
|
10
12
|
|
|
11
|
-
/** pi SessionStartEvent.reason 原生 5 值(pi 源码 core/extensions/types.ts:565)。 */
|
|
12
|
-
export type SessionStartReason = "startup" | "reload" | "new" | "resume" | "fork";
|
|
13
|
-
|
|
14
13
|
/** 落盘 reason 枚举(initial/resume/change,对齐 DSH request/header 语义,设计 D2)。 */
|
|
15
14
|
export type TraceReason = "initial" | "resume" | "change";
|
|
16
15
|
|
|
@@ -25,18 +24,16 @@ export interface SystemPromptTraceEntryData {
|
|
|
25
24
|
fullText: string;
|
|
26
25
|
/** fullText.length(UTF-16 码元数)。 */
|
|
27
26
|
charCount: number;
|
|
28
|
-
/** 与上一版的行级 diff
|
|
27
|
+
/** 与上一版的行级 diff 摘要;首条留痕(无 parent)时缺省。 */
|
|
29
28
|
parentVersionDiffSummary?: string;
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
/** 跨重启恢复的 hash
|
|
31
|
+
/** 跨重启恢复的 hash 基线(三档解析统一产自 session JSONL 留痕 entry 直读)。 */
|
|
33
32
|
export interface PromptBaseline {
|
|
34
33
|
hash: string;
|
|
35
34
|
version: number;
|
|
36
|
-
/**
|
|
35
|
+
/** 留痕 entry 直读恒有值(可生成 diff 摘要)。 */
|
|
37
36
|
fullText?: string;
|
|
38
|
-
/** 基线来源(四路径口径,见 trace.ts onSessionStart 的解析优先级)。 */
|
|
39
|
-
source: "target-file" | "previous-session-file" | "persisted";
|
|
40
37
|
}
|
|
41
38
|
|
|
42
39
|
/**
|
|
@@ -72,32 +69,19 @@ export function isSystemPromptTraceEntryData(value: unknown): value is SystemPro
|
|
|
72
69
|
);
|
|
73
70
|
}
|
|
74
71
|
|
|
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
72
|
/**
|
|
89
73
|
* 无基线时 SessionStartEvent.reason → 落盘 reason 的映射(A11)。
|
|
90
74
|
*
|
|
91
75
|
* - startup / new → initial(新 session 首建快照)
|
|
92
76
|
* - resume → resume(重开快照)
|
|
93
|
-
* - fork / reload → resume
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* 两者语义上都更接近「重开」而非「首建」。
|
|
77
|
+
* - fork / reload → resume(设计 D2 v5 定案):fork 基线取源文件最后留痕(previousSessionFile,
|
|
78
|
+
* 缺失/未落盘/读取失败 → null 走本映射兜底);reload 是同 session 的 extension 运行时重建——
|
|
79
|
+
* 两者语义上都是「重开」而非「首建」。
|
|
97
80
|
*
|
|
98
|
-
*
|
|
81
|
+
* 注意:基线恢复且 hash 未变 → 不写(去重);需写时(hash 已变)恒为 resume,不走本映射
|
|
82
|
+
* (见 trace.ts onTurnStart)。
|
|
99
83
|
*/
|
|
100
|
-
export function mapReasonForFirstWrite(reason:
|
|
84
|
+
export function mapReasonForFirstWrite(reason: SessionStartEvent["reason"]): TraceReason {
|
|
101
85
|
switch (reason) {
|
|
102
86
|
case "startup":
|
|
103
87
|
case "new":
|