@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.
package/src/diff.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 行级 diff 摘要生成器(parentVersionDiffSummary 字段)。
3
+ *
4
+ * 设计定位:entry 内的摘要是对用户友好的「一眼看出改了什么」;Trace 视图 inspector 在渲染时
5
+ * 会用相邻两条留痕 entry 的 fullText 重算完整 diff,这里只需轻量摘要,不追求 patch 级精确。
6
+ */
7
+
8
+ /** 摘要采样行数上限(防巨型 prompt 差异把 entry 撑爆)。 */
9
+ const MAX_SAMPLE_LINES = 8;
10
+ /** 单行截断长度(字符)。 */
11
+ const MAX_SAMPLE_CHARS = 80;
12
+ /** LCS DP 面积上限:超过退化为 multiset 计数(O(n·m) → O(n+m),防巨型 prompt 爆内存)。 */
13
+ const MAX_LCS_CELLS = 4_000_000;
14
+
15
+ /** 生成 oldText → newText 的行级 diff 摘要:"+A -R lines" 头 + 最多 8 条采样行。 */
16
+ export function summarizePromptDiff(oldText: string, newText: string): string {
17
+ const oldLines = oldText.split("\n");
18
+ const newLines = newText.split("\n");
19
+ const { removed, added } =
20
+ oldLines.length * newLines.length <= MAX_LCS_CELLS
21
+ ? lcsLineDiff(oldLines, newLines)
22
+ : multisetLineDiff(oldLines, newLines);
23
+
24
+ const header = `+${added.length} -${removed.length} lines`;
25
+ const samples: string[] = [];
26
+ // 交替取样(added 优先):注入类变化(只增不减)时全部展示新增行,混合变化时两类都可见
27
+ let ai = 0;
28
+ let ri = 0;
29
+ while (samples.length < MAX_SAMPLE_LINES && (ai < added.length || ri < removed.length)) {
30
+ if (ai < added.length) {
31
+ samples.push(`+ ${truncate(added[ai])}`);
32
+ ai++;
33
+ }
34
+ if (samples.length < MAX_SAMPLE_LINES && ri < removed.length) {
35
+ samples.push(`- ${truncate(removed[ri])}`);
36
+ ri++;
37
+ }
38
+ }
39
+ return samples.length === 0 ? header : [header, ...samples].join("\n");
40
+ }
41
+
42
+ /** LCS 回溯行 diff:产出与文本顺序一致的 removed/added 序列(采样时更有可读性)。 */
43
+ function lcsLineDiff(a: readonly string[], b: readonly string[]): { removed: string[]; added: string[] } {
44
+ const m = a.length;
45
+ const n = b.length;
46
+ const width = n + 1;
47
+ const dp = new Uint32Array((m + 1) * width);
48
+ for (let i = m - 1; i >= 0; i--) {
49
+ for (let j = n - 1; j >= 0; j--) {
50
+ dp[i * width + j] =
51
+ a[i] === b[j]
52
+ ? dp[(i + 1) * width + j + 1] + 1
53
+ : Math.max(dp[(i + 1) * width + j], dp[i * width + j + 1]);
54
+ }
55
+ }
56
+ const removed: string[] = [];
57
+ const added: string[] = [];
58
+ let i = 0;
59
+ let j = 0;
60
+ while (i < m && j < n) {
61
+ if (a[i] === b[j]) {
62
+ i++;
63
+ j++;
64
+ } else if (dp[(i + 1) * width + j] >= dp[i * width + j + 1]) {
65
+ removed.push(a[i]);
66
+ i++;
67
+ } else {
68
+ added.push(b[j]);
69
+ j++;
70
+ }
71
+ }
72
+ while (i < m) {
73
+ removed.push(a[i]);
74
+ i++;
75
+ }
76
+ while (j < n) {
77
+ added.push(b[j]);
78
+ j++;
79
+ }
80
+ return { removed, added };
81
+ }
82
+
83
+ /** multiset 行计数 diff(LCS 面积超限的降级路径):行内容匹配抵消,剩余计入 added/removed。 */
84
+ function multisetLineDiff(a: readonly string[], b: readonly string[]): { removed: string[]; added: string[] } {
85
+ const counts = new Map<string, number>();
86
+ for (const line of a) {
87
+ counts.set(line, (counts.get(line) ?? 0) + 1);
88
+ }
89
+ const added: string[] = [];
90
+ for (const line of b) {
91
+ const c = counts.get(line) ?? 0;
92
+ if (c > 0) {
93
+ counts.set(line, c - 1);
94
+ } else {
95
+ added.push(line);
96
+ }
97
+ }
98
+ const removed: string[] = [];
99
+ for (const [line, c] of counts) {
100
+ for (let k = 0; k < c; k++) {
101
+ removed.push(line);
102
+ }
103
+ }
104
+ return { removed, added };
105
+ }
106
+
107
+ function truncate(line: string): string {
108
+ return line.length > MAX_SAMPLE_CHARS ? line.slice(0, MAX_SAMPLE_CHARS - 1) + "…" : line;
109
+ }
package/src/index.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * pi extension wiring:把 ExtensionContext 适配进 trace.ts 状态机并订阅三个事件。
3
+ *
4
+ * 事件宽松类型(参考 rename-session TurnEndLikeEvent 先例):pi 的 on() 重载对严格事件类型
5
+ * 做参数逆变匹配,收窄字段的本地接口更稳;字段在逻辑侧运行时归一化(normalizeSessionStartReason)。
6
+ */
7
+
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";
12
+
13
+ import {
14
+ BASELINE_FILENAME,
15
+ readLastPromptFromSessionFile,
16
+ readPersistedBaseline,
17
+ writePersistedBaseline,
18
+ } from "./baseline.js";
19
+ import { createSystemPromptTrace } from "./trace.js";
20
+ import type { TraceContext, TraceEnv } from "./trace.js";
21
+ import type { SwitchStash } from "./types.js";
22
+
23
+ interface SessionStartLikeEvent {
24
+ type: "session_start";
25
+ reason: string;
26
+ previousSessionFile?: string;
27
+ }
28
+
29
+ interface SessionBeforeSwitchLikeEvent {
30
+ type: "session_before_switch";
31
+ reason: string;
32
+ targetSessionFile?: string;
33
+ }
34
+
35
+ interface TurnStartLikeEvent {
36
+ type: "turn_start";
37
+ turnIndex: number;
38
+ timestamp: number;
39
+ }
40
+
41
+ // 模块级单例:session_before_switch(旧 runtime)→ session_start(新 runtime)之间传递
42
+ // targetSessionFile 直读基线。switch 会 teardown 并重建 extension runtime,闭包状态不跨
43
+ // runtime 存活(见 types.ts SwitchStash 注释),只有模块缓存在进程内延续。
44
+ const switchStash: SwitchStash = { pending: null };
45
+
46
+ function toTraceContext(pi: ExtensionAPI, ctx: ExtensionContext): TraceContext {
47
+ // appendEntry 在 ExtensionAPI(pi 对象)上;getSystemPrompt/sessionManager 在 ExtensionContext 上
48
+ return {
49
+ getSystemPrompt: () => ctx.getSystemPrompt(),
50
+ appendEntry: (customType, data) => pi.appendEntry(customType, data),
51
+ getSessionId: () => ctx.sessionManager.getSessionId(),
52
+ };
53
+ }
54
+
55
+ export default function systemPromptTraceExtension(pi: ExtensionAPI): void {
56
+ const baselineFilePath = join(getAgentDir(), BASELINE_FILENAME);
57
+
58
+ const env: TraceEnv = {
59
+ readLastPromptFromFile: (filePath) => readLastPromptFromSessionFile(filePath, "target-file"),
60
+ readPersistedBaseline: (sessionId) => readPersistedBaseline(baselineFilePath, sessionId),
61
+ writePersistedBaseline: (sessionId, hash, version) =>
62
+ writePersistedBaseline(baselineFilePath, sessionId, hash, version),
63
+ };
64
+
65
+ const logic = createSystemPromptTrace(env, switchStash);
66
+
67
+ pi.on("session_start", async (event: SessionStartLikeEvent, ctx: ExtensionContext) => {
68
+ logic.onSessionStart(
69
+ event.reason,
70
+ typeof event.previousSessionFile === "string" ? event.previousSessionFile : undefined,
71
+ toTraceContext(pi, ctx),
72
+ );
73
+ });
74
+
75
+ pi.on("session_before_switch", async (event: SessionBeforeSwitchLikeEvent) => {
76
+ logic.onSessionBeforeSwitch(
77
+ event.reason,
78
+ typeof event.targetSessionFile === "string" ? event.targetSessionFile : undefined,
79
+ );
80
+ });
81
+
82
+ pi.on("turn_start", async (_event: TurnStartLikeEvent, ctx: ExtensionContext) => {
83
+ logic.onTurnStart(toTraceContext(pi, ctx));
84
+ });
85
+ }
package/src/trace.ts ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * 留痕状态机(纯逻辑,文件系统与 pi 上下文经 env/ctx 注入,可脱离 pi 单测)。
3
+ *
4
+ * 写入时机(设计 D2 校正):
5
+ * - 不在 session_start 写——该事件 emit 早于 resources_discover 的 prompt 重建,快照必不完整、
6
+ * 首 turn 必误报一次 change;
7
+ * - 首个 turn_start 写 initial/resume——此时 getSystemPrompt() 已含 before_agent_start 注入
8
+ * (pi 的事件链:用户消息 handler 先 await emitBeforeAgentStart(agent-session.ts:1243-1244),
9
+ * 再 agent_start → turn_start 事件分发(agent-session.ts:739-746));
10
+ * - 后续每个 turn_start 做 hash 对比,变化才写 change。
11
+ */
12
+
13
+ import { createHash } from "node:crypto";
14
+
15
+ import { summarizePromptDiff } from "./diff.js";
16
+ import {
17
+ mapReasonForFirstWrite,
18
+ normalizeSessionStartReason,
19
+ SYSTEM_PROMPT_CUSTOM_TYPE,
20
+ } from "./types.js";
21
+ import type {
22
+ PromptBaseline,
23
+ SessionStartReason,
24
+ SwitchStash,
25
+ SystemPromptTraceEntryData,
26
+ TraceReason,
27
+ } from "./types.js";
28
+
29
+ /** 逻辑所需的 per-call pi 上下文(wiring 从 ExtensionContext + ExtensionAPI 适配;测试注入 fake)。 */
30
+ export interface TraceContext {
31
+ getSystemPrompt(): string;
32
+ appendEntry(customType: string, data: unknown): void;
33
+ getSessionId(): string;
34
+ }
35
+
36
+ /** 文件系统侧依赖(wiring 用真实 fs + agentDir;测试注入临时目录实现)。 */
37
+ export interface TraceEnv {
38
+ readLastPromptFromFile(filePath: string): PromptBaseline | null;
39
+ readPersistedBaseline(sessionId: string): PromptBaseline | null;
40
+ writePersistedBaseline(sessionId: string, hash: string, version: number): void;
41
+ }
42
+
43
+ export interface SystemPromptTrace {
44
+ onSessionStart(reason: string, previousSessionFile: string | undefined, ctx: TraceContext): void;
45
+ onSessionBeforeSwitch(reason: string, targetSessionFile: string | undefined): void;
46
+ onTurnStart(ctx: TraceContext): void;
47
+ }
48
+
49
+ /** 当前已确立的 prompt 版本(写过或基线去重命中后确立)。 */
50
+ interface CurrentPrompt {
51
+ version: number;
52
+ hash: string;
53
+ fullText: string;
54
+ }
55
+
56
+ export function computePromptHash(text: string): string {
57
+ return createHash("sha256").update(text, "utf-8").digest("hex");
58
+ }
59
+
60
+ export function createSystemPromptTrace(env: TraceEnv, stash: SwitchStash): SystemPromptTrace {
61
+ let sessionStartReason: SessionStartReason | null = null;
62
+ let baseline: PromptBaseline | null = null;
63
+ let current: CurrentPrompt | null = null;
64
+
65
+ const write = (
66
+ ctx: TraceContext,
67
+ text: string,
68
+ hash: string,
69
+ reason: TraceReason,
70
+ version: number,
71
+ parentFullText: string | undefined,
72
+ ): void => {
73
+ const data: SystemPromptTraceEntryData = {
74
+ version,
75
+ hash,
76
+ reason,
77
+ fullText: text,
78
+ charCount: text.length,
79
+ };
80
+ if (parentFullText !== undefined) {
81
+ data.parentVersionDiffSummary = summarizePromptDiff(parentFullText, text);
82
+ }
83
+ ctx.appendEntry(SYSTEM_PROMPT_CUSTOM_TYPE, data);
84
+ // 落盘成功后才刷新自持久化基线(app 重启直 spawn resume 的唯一基线来源,设计 D2 路径 2)
85
+ env.writePersistedBaseline(ctx.getSessionId(), hash, version);
86
+ };
87
+
88
+ return {
89
+ onSessionStart(reason, previousSessionFile, ctx) {
90
+ sessionStartReason = normalizeSessionStartReason(reason);
91
+ current = null;
92
+ // 基线解析(设计 D2 跨重启三路径,优先级从高到低):
93
+ // 1. session_before_switch 直读目标文件(进程内 resume;stash 为模块级单例,跨 runtime 传递)
94
+ // 2. fork 的 previousSessionFile 直读【暂定语义,待 P2 实测定】
95
+ // 3. agentDir 自持久化小文件(app 重启直 spawn resume / reload——这两种链路没有 switch 事件)
96
+ // 4. 全 miss → null:首个 turn 按 reason 映射写 initial/resume(resume 必写 = 兜底路径)
97
+ // stash 无论是否采用都消费:cancelled switch 的残留基线不允许污染下一次 session_start
98
+ const stashed = stash.pending;
99
+ stash.pending = null;
100
+ if (stashed !== null && sessionStartReason === "resume") {
101
+ baseline = stashed;
102
+ } else if (sessionStartReason === "fork" && previousSessionFile !== undefined) {
103
+ const fromPrev = env.readLastPromptFromFile(previousSessionFile);
104
+ baseline = fromPrev === null ? null : { ...fromPrev, source: "previous-session-file" };
105
+ } else {
106
+ baseline = env.readPersistedBaseline(ctx.getSessionId());
107
+ }
108
+ },
109
+
110
+ onSessionBeforeSwitch(reason, targetSessionFile) {
111
+ // 只有 resume 的目标文件承载同 session 历史;new 的目标是全新空文件。
112
+ // 该 handler 在旧 runtime 里执行,结果写入模块级 stash 供新 runtime 的
113
+ // session_start 消费(switch 会重建 extension runtime,见 types.ts SwitchStash 注释)。
114
+ stash.pending =
115
+ reason === "resume" && targetSessionFile !== undefined
116
+ ? env.readLastPromptFromFile(targetSessionFile)
117
+ : null;
118
+ },
119
+
120
+ onTurnStart(ctx) {
121
+ try {
122
+ const text = ctx.getSystemPrompt();
123
+ const hash = computePromptHash(text);
124
+ if (current !== null) {
125
+ // 后续 turn:hash 对比去重(设计 D2),变化才写 change
126
+ if (hash === current.hash) return;
127
+ write(ctx, text, hash, "change", current.version + 1, current.fullText);
128
+ current = { version: current.version + 1, hash, fullText: text };
129
+ return;
130
+ }
131
+ // 本 session_start 周期的首个 turn_start
132
+ if (baseline !== null && baseline.hash === hash) {
133
+ // 跨重启基线命中且未变化:不写,但确立 current 供后续 turn 继续对比;
134
+ // 顺带刷新自持久化基线(updatedAt 续命 + 小文件丢失时自愈)
135
+ current = { version: baseline.version, hash, fullText: text };
136
+ env.writePersistedBaseline(ctx.getSessionId(), hash, baseline.version);
137
+ return;
138
+ }
139
+ // 必写:有基线 → resume(该 session 已有历史版本,这是重开点的快照,version 续接);
140
+ // 无基线 → 按 SessionStartEvent.reason 映射(startup/new→initial,resume→resume 兜底必写)
141
+ const version = baseline === null ? 1 : baseline.version + 1;
142
+ const reason: TraceReason =
143
+ baseline === null ? mapReasonForFirstWrite(sessionStartReason ?? "startup") : "resume";
144
+ const parentFullText = baseline === null ? undefined : baseline.fullText;
145
+ write(ctx, text, hash, reason, version, parentFullText);
146
+ current = { version, hash, fullText: text };
147
+ } catch (e) {
148
+ // 留痕是诊断性旁路:任何失败都不允许影响 agent 主流程(错误进 pi stdout,随日志落盘)
149
+ console.error("[pi-system-prompt-trace] turn_start handler failed:", e);
150
+ }
151
+ },
152
+ };
153
+ }
package/src/types.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * 共享类型、常量与 reason 映射。
3
+ *
4
+ * 留痕 entry 是 custom 类型(不进 LLM context,零模型侧影响,设计 D2),
5
+ * 数据形状见 SystemPromptTraceEntryData。
6
+ */
7
+
8
+ /** 留痕 entry 的 customType(xyz: 前缀 = xyz-agent 自定义命名空间)。 */
9
+ export const SYSTEM_PROMPT_CUSTOM_TYPE = "xyz:system-prompt";
10
+
11
+ /** pi SessionStartEvent.reason 原生 5 值(pi 源码 core/extensions/types.ts:565)。 */
12
+ export type SessionStartReason = "startup" | "reload" | "new" | "resume" | "fork";
13
+
14
+ /** 落盘 reason 枚举(initial/resume/change,对齐 DSH request/header 语义,设计 D2)。 */
15
+ export type TraceReason = "initial" | "resume" | "change";
16
+
17
+ /** appendEntry("xyz:system-prompt", data) 的 data 形状(设计 §5 单元 1)。 */
18
+ export interface SystemPromptTraceEntryData {
19
+ /** session 内单调递增(首条 1;有基线时续接基线版本 +1)。 */
20
+ version: number;
21
+ /** sha256(fullText) 十六进制——hash 对比去重与跨重启基线的依据。 */
22
+ hash: string;
23
+ reason: TraceReason;
24
+ /** 完整 system prompt(每条 ~12KB,hash 去重后典型 session 只写 1-3 次,设计 D2 权衡)。 */
25
+ fullText: string;
26
+ /** fullText.length(UTF-16 码元数)。 */
27
+ charCount: number;
28
+ /** 与上一版的行级 diff 摘要;无 parent 全文(自持久化基线只有 hash/首条)时缺省。 */
29
+ parentVersionDiffSummary?: string;
30
+ }
31
+
32
+ /** 跨重启恢复的 hash 基线。 */
33
+ export interface PromptBaseline {
34
+ hash: string;
35
+ version: number;
36
+ /** 从 session 文件留痕 entry 直读时有值(可生成 diff 摘要);自持久化小文件只有 hash+version。 */
37
+ fullText?: string;
38
+ /** 基线来源(三路径,见 trace.ts onSessionStart 的解析优先级)。 */
39
+ source: "target-file" | "previous-session-file" | "persisted";
40
+ }
41
+
42
+ /**
43
+ * session_before_switch → session_start 之间传递的直读基线。
44
+ * 必须是模块级单例对象而非闭包变量:switchSession 会 teardown 并重建 extension runtime
45
+ * (pi agent-session-runtime.ts teardownCurrent → createRuntime 重新调用 factory),
46
+ * 闭包状态不跨 runtime 存活,只有模块缓存(extensions/loader.ts extensionCache)在进程内延续。
47
+ */
48
+ export interface SwitchStash {
49
+ pending: PromptBaseline | null;
50
+ }
51
+
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
+ /** 留痕 entry data 的运行时 guard(读 JSONL / 测试断言复用)。 */
58
+ export function isSystemPromptTraceEntryData(value: unknown): value is SystemPromptTraceEntryData {
59
+ if (!isRecord(value)) return false;
60
+ const version = value["version"];
61
+ const hash = value["hash"];
62
+ const reason = value["reason"];
63
+ const fullText = value["fullText"];
64
+ const charCount = value["charCount"];
65
+ return (
66
+ typeof version === "number" &&
67
+ Number.isFinite(version) &&
68
+ typeof hash === "string" &&
69
+ (reason === "initial" || reason === "resume" || reason === "change") &&
70
+ typeof fullText === "string" &&
71
+ typeof charCount === "number"
72
+ );
73
+ }
74
+
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
+ /**
89
+ * 无基线时 SessionStartEvent.reason → 落盘 reason 的映射(A11)。
90
+ *
91
+ * - startup / new → initial(新 session 首建快照)
92
+ * - resume → resume(重开快照)
93
+ * - fork / reload → resume【暂定,待 P2 实测定(A13 探针),测试显式标注】:
94
+ * fork 的新文件携带源 session 的历史 entry(版本链延续,且 xyz-agent 的 fork 实际经
95
+ * switchSession 走 resume 链路),reload 是同 session 的 extension 运行时重建——
96
+ * 两者语义上都更接近「重开」而非「首建」。
97
+ *
98
+ * 注意:只要恢复了任一 hash 基线,首个 turn 一律写 resume(见 trace.ts),不走本映射。
99
+ */
100
+ export function mapReasonForFirstWrite(reason: SessionStartReason): TraceReason {
101
+ switch (reason) {
102
+ case "startup":
103
+ case "new":
104
+ return "initial";
105
+ case "resume":
106
+ case "fork":
107
+ case "reload":
108
+ return "resume";
109
+ }
110
+ }