@zhushanwen/pi-cache-probe 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/README.md +46 -0
- package/analyze.py +269 -0
- package/index.ts +1 -0
- package/package.json +36 -0
- package/src/__tests__/fingerprint.test.ts +139 -0
- package/src/__tests__/state-machine.test.ts +165 -0
- package/src/fingerprint.ts +175 -0
- package/src/index.ts +106 -0
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# @zhushanwen/pi-cache-probe
|
|
2
|
+
|
|
3
|
+
前缀稳定性长期数据采集 extension(设计文档:`docs/todo/cache-probe-design.md`)。零行为影响:只在 system prompt / tools 指纹变化时向 session JSONL 写 `cache-probe` custom entry(不进 LLM 上下文),配合 `analyze.py` 把 turn 首笔 cache miss 归因为「前缀变化(可修)」vs「服务端淘汰(不可修)」。
|
|
4
|
+
|
|
5
|
+
## 组成
|
|
6
|
+
|
|
7
|
+
- `src/index.ts` — 探针工厂(9 个指纹 hash:7 个输入侧来自 `systemPromptOptions` + `getAllTools()`,`spFull`/`toolsSent` 来自 `before_provider_request` 的最终 payload)
|
|
8
|
+
- `src/fingerprint.ts` — 指纹纯函数层(stable stringify / hash / entry 构建)
|
|
9
|
+
- `analyze.py` — 归因分析脚本(只读,单数据源扫 session 文件)
|
|
10
|
+
|
|
11
|
+
## entry schema v2(数据量精简,长期采集友好)
|
|
12
|
+
|
|
13
|
+
- hash 为 sha256 前 16 hex;**baseline entry** 存全量 9 hash + cwd(约 250B);**normal entry** 只存变化项增量(约 120B);无变化 turn 零写入
|
|
14
|
+
- `seq` = 进程内 turn 计数(无论写不写 entry 都递增),跳跃 = 中间有无变化 turn 或漏记
|
|
15
|
+
|
|
16
|
+
## 挂载方式(长期采集)
|
|
17
|
+
|
|
18
|
+
dev-link 本地源码(live edit,推荐日常使用):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# 见 .agents/skills/dev-link/;或直接用 pi 指定入口
|
|
22
|
+
pi --extension <repo>/extensions/cache-probe/index.ts "<prompt>"
|
|
23
|
+
pi --continue --extension <repo>/extensions/cache-probe/index.ts "<prompt>"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
探针经 `--extension` 加载位于 extension 链头(pi 固定顺序),但 spFull/toolsSent 从 `before_provider_request` 的最终请求体提取,是 LLM 实际收到的前缀,不受链位置影响。
|
|
27
|
+
|
|
28
|
+
## 归因分析
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
python3 extensions/cache-probe/analyze.py ~/.pi/agent/sessions ~/.xyz-agent/pi/sessions
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
输出五部分:扫描概览 / 命中率基线 / 归因矩阵 / 进程边界漂移 / GO-NO-GO 决策建议。
|
|
35
|
+
|
|
36
|
+
## 已知限制(实测查明)
|
|
37
|
+
|
|
38
|
+
- pi CLI 启动路径(`--session` / `--continue`)的 session_start reason 恒为 `startup`(`resume` 仅运行时 switch session 出现)——跨进程识别靠 baseline 标记 + 时间戳 gap,漂移检测靠 hash 对比,均不受影响。
|
|
39
|
+
- 同进程内修改 AGENTS.md 不进 system prompt(pi 启动快照),contextFiles 漂移只出现在跨进程边界——pi 的行为,不是探针缺陷。
|
|
40
|
+
- 真实环境实测发现:extension 链(model-switch / subagent-workflow 等)存在每 turn 级的 system prompt 动态注入,由 spFull 单独捕获——这正是它存在的价值。
|
|
41
|
+
|
|
42
|
+
## 日常维护
|
|
43
|
+
|
|
44
|
+
- 校验:`cd extensions/cache-probe && pnpm typecheck && pnpm test`(或仓库级 `pnpm extensions:typecheck && pnpm extensions:test`)
|
|
45
|
+
- 每日检查:跑一次 analyze.py,确认 `sessions_with_probe` 增长、`error entries` 为 0 或已知原因
|
|
46
|
+
- 采集满 200 turn 后 analyze.py 第 5 部分给出快照方案 GO/NO-GO 倾向;结论与后续动作记录回设计文档
|
package/analyze.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""cache-probe 归因分析脚本(设计文档 docs/todo/cache-probe-design.md §7.2)。
|
|
3
|
+
|
|
4
|
+
输入一个或多个 sessions 目录,输出:
|
|
5
|
+
1. 扫描概览(含 error entry / 畸形行计数,失败要出声)
|
|
6
|
+
2. 命中率基线(与设计文档 §3.1 同口径,供交叉验证)
|
|
7
|
+
3. 归因矩阵:gap<30min + 模型未切换 的 turn 首笔 miss,按前缀变化与否分档
|
|
8
|
+
4. 进程边界(baseline entry)前后指纹漂移统计
|
|
9
|
+
5. 快照方案 GO/NO-GO 决策建议
|
|
10
|
+
|
|
11
|
+
entry schema v2:normal entry 只存变化项(增量),本脚本回放合并为全量指纹。
|
|
12
|
+
|
|
13
|
+
用法:python3 extensions/cache-probe/analyze.py ~/.pi/agent/sessions [--since 2026-08-01]
|
|
14
|
+
只读,不修改任何文件。
|
|
15
|
+
"""
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from collections import defaultdict
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
|
|
23
|
+
SKIP_PAT = ("var-folders", "private-tmp")
|
|
24
|
+
GAP_BUCKETS = [(0, 300, "a.<5min"), (300, 1800, "b.5-30min"),
|
|
25
|
+
(1800, 43200, "c.30min-12h"), (43200, float("inf"), "d.>12h")]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_ts(s):
|
|
29
|
+
if not s:
|
|
30
|
+
return None
|
|
31
|
+
for fmt in ("%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z"):
|
|
32
|
+
try:
|
|
33
|
+
return datetime.strptime(s.replace("Z", "+0000"), fmt).timestamp()
|
|
34
|
+
except ValueError:
|
|
35
|
+
continue
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def bucket_of(sec):
|
|
40
|
+
for lo, hi, name in GAP_BUCKETS:
|
|
41
|
+
if lo <= sec < hi:
|
|
42
|
+
return name
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_real_user(msg):
|
|
47
|
+
c = msg.get("content")
|
|
48
|
+
return isinstance(c, list) and any(b.get("type") == "text" for b in c)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def iter_session_files(roots, since_ts):
|
|
52
|
+
for root in roots:
|
|
53
|
+
if not os.path.isdir(root):
|
|
54
|
+
print(f"[WARN] 目录不存在,跳过:{root}")
|
|
55
|
+
continue
|
|
56
|
+
for dirpath, _dirs, files in os.walk(root):
|
|
57
|
+
if any(p in dirpath for p in SKIP_PAT):
|
|
58
|
+
continue
|
|
59
|
+
for fn in files:
|
|
60
|
+
if not fn.endswith(".jsonl"):
|
|
61
|
+
continue
|
|
62
|
+
path = os.path.join(dirpath, fn)
|
|
63
|
+
try:
|
|
64
|
+
if since_ts and os.path.getmtime(path) < since_ts:
|
|
65
|
+
continue
|
|
66
|
+
except OSError:
|
|
67
|
+
continue
|
|
68
|
+
yield path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_entries(path, counters):
|
|
72
|
+
entries = []
|
|
73
|
+
try:
|
|
74
|
+
with open(path, encoding="utf-8") as fp:
|
|
75
|
+
for line in fp:
|
|
76
|
+
line = line.strip()
|
|
77
|
+
if not line:
|
|
78
|
+
continue
|
|
79
|
+
try:
|
|
80
|
+
entries.append(json.loads(line))
|
|
81
|
+
except json.JSONDecodeError:
|
|
82
|
+
counters["malformed_lines"] += 1
|
|
83
|
+
except OSError:
|
|
84
|
+
counters["unreadable_files"] += 1
|
|
85
|
+
return []
|
|
86
|
+
return entries
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main():
|
|
90
|
+
ap = argparse.ArgumentParser(description="cache-probe 归因分析")
|
|
91
|
+
ap.add_argument("roots", nargs="+", help="sessions 目录(可多个)")
|
|
92
|
+
ap.add_argument("--since", default=None, help="只分析 mtime 在该日期之后的 session(YYYY-MM-DD)")
|
|
93
|
+
args = ap.parse_args()
|
|
94
|
+
since_ts = datetime.strptime(args.since, "%Y-%m-%d").timestamp() if args.since else 0
|
|
95
|
+
|
|
96
|
+
counters = defaultdict(int)
|
|
97
|
+
turn_rows = [] # (model, gap_bucket, model_chg, think_chg, prefix_chg, parts, cr, miss)
|
|
98
|
+
baseline_pairs = [] # (start_reason, changed_parts)
|
|
99
|
+
files = list(iter_session_files(args.roots, since_ts))
|
|
100
|
+
counters["sessions"] = len(files)
|
|
101
|
+
|
|
102
|
+
for path in files:
|
|
103
|
+
entries = load_entries(path, counters)
|
|
104
|
+
if not entries:
|
|
105
|
+
continue
|
|
106
|
+
current_hashes = None
|
|
107
|
+
prev_snapshot = None
|
|
108
|
+
seen_user_turn = False
|
|
109
|
+
# 切换标记作用域 = 自上一条真实用户发言以来的变化;user 发言时冻结到该 turn,
|
|
110
|
+
# 防止「无 LLM 请求的 turn」把标记泄漏到下一 turn(保守低估样本方向的 bug)
|
|
111
|
+
pending_model = pending_think = False
|
|
112
|
+
turn_model_chg = turn_think_chg = False
|
|
113
|
+
last_ts = None
|
|
114
|
+
gap_bucket = "new"
|
|
115
|
+
seen_first = False
|
|
116
|
+
has_probe_turn = False
|
|
117
|
+
for e in entries:
|
|
118
|
+
ts = parse_ts(e.get("timestamp"))
|
|
119
|
+
t = e.get("type")
|
|
120
|
+
if t == "model_change" and seen_user_turn:
|
|
121
|
+
pending_model = True
|
|
122
|
+
elif t == "thinking_level_change" and seen_user_turn:
|
|
123
|
+
pending_think = True
|
|
124
|
+
elif t == "custom" and e.get("customType") == "cache-probe":
|
|
125
|
+
data = e.get("data") or {}
|
|
126
|
+
if data.get("error"):
|
|
127
|
+
counters["error_entries"] += 1
|
|
128
|
+
elif isinstance(data.get("h"), dict):
|
|
129
|
+
if data.get("baseline") and current_hashes is not None:
|
|
130
|
+
changed = [k for k, v in data["h"].items() if current_hashes.get(k) != v]
|
|
131
|
+
baseline_pairs.append((data.get("startReason"), changed))
|
|
132
|
+
# v2 增量 merge:baseline 全量覆盖;normal 增量合并
|
|
133
|
+
current_hashes = {**(current_hashes or {}), **data["h"]}
|
|
134
|
+
elif t == "message":
|
|
135
|
+
m = e.get("message", {})
|
|
136
|
+
role = m.get("role")
|
|
137
|
+
if role == "user" and is_real_user(m):
|
|
138
|
+
seen_user_turn = True
|
|
139
|
+
turn_model_chg, turn_think_chg = pending_model, pending_think
|
|
140
|
+
pending_model = pending_think = False
|
|
141
|
+
if last_ts is not None and ts is not None:
|
|
142
|
+
gap_bucket = bucket_of(ts - last_ts) or "d.>12h"
|
|
143
|
+
else:
|
|
144
|
+
gap_bucket = "new"
|
|
145
|
+
seen_first = False
|
|
146
|
+
elif role == "assistant" and not seen_first:
|
|
147
|
+
u = m.get("usage") or {}
|
|
148
|
+
cr = u.get("cacheRead", 0) or 0
|
|
149
|
+
miss = (u.get("input", 0) or 0) + (u.get("cacheWrite", 0) or 0)
|
|
150
|
+
if cr or miss:
|
|
151
|
+
prefix_chg = None
|
|
152
|
+
parts = []
|
|
153
|
+
if current_hashes is not None:
|
|
154
|
+
has_probe_turn = True
|
|
155
|
+
if prev_snapshot is not None:
|
|
156
|
+
parts = [k for k, v in current_hashes.items()
|
|
157
|
+
if prev_snapshot.get(k) != v]
|
|
158
|
+
prefix_chg = len(parts) > 0
|
|
159
|
+
turn_rows.append((m.get("model", "?"), gap_bucket, turn_model_chg,
|
|
160
|
+
turn_think_chg, prefix_chg, parts, cr, miss))
|
|
161
|
+
prev_snapshot = current_hashes
|
|
162
|
+
seen_first = True
|
|
163
|
+
if ts is not None:
|
|
164
|
+
last_ts = ts
|
|
165
|
+
if has_probe_turn:
|
|
166
|
+
counters["sessions_with_probe"] += 1
|
|
167
|
+
|
|
168
|
+
# ---- 输出 ----
|
|
169
|
+
print("=" * 72)
|
|
170
|
+
print("1. 扫描概览")
|
|
171
|
+
print("=" * 72)
|
|
172
|
+
print(f" sessions: {counters['sessions']}(含 cache-probe 数据 {counters['sessions_with_probe']})")
|
|
173
|
+
print(f" turns: {len(turn_rows)} error entries: {counters['error_entries']}")
|
|
174
|
+
print(f" 畸形行: {counters['malformed_lines']} 不可读文件: {counters['unreadable_files']}")
|
|
175
|
+
think_turns = [r for r in turn_rows if r[3]]
|
|
176
|
+
if think_turns:
|
|
177
|
+
cr = sum(r[6] for r in think_turns)
|
|
178
|
+
tot = cr + sum(r[7] for r in think_turns)
|
|
179
|
+
print(f" think 切换 turn: {len(think_turns)}(命中率 {cr / tot * 100:.1f}%,历史实测 ≈ 基线,仅监控不参与归因过滤)")
|
|
180
|
+
|
|
181
|
+
print()
|
|
182
|
+
print("=" * 72)
|
|
183
|
+
print("2. 命中率基线(全部 turn 首笔,按中断时长;对照设计文档 §3.1 口径)")
|
|
184
|
+
print("=" * 72)
|
|
185
|
+
by_gap = defaultdict(lambda: [0, 0])
|
|
186
|
+
for _m, g, _mc, _tc, _pc, _p, cr, miss in turn_rows:
|
|
187
|
+
by_gap[g][0] += cr
|
|
188
|
+
by_gap[g][1] += miss
|
|
189
|
+
print(f" {'gap':12s} {'命中':>16s} {'未命中':>16s} {'命中率':>8s}")
|
|
190
|
+
for g in sorted(by_gap):
|
|
191
|
+
cr, miss = by_gap[g]
|
|
192
|
+
tot = cr + miss
|
|
193
|
+
if tot:
|
|
194
|
+
print(f" {g:12s} {cr:>16,d} {miss:>16,d} {cr / tot * 100:>7.1f}%")
|
|
195
|
+
|
|
196
|
+
print()
|
|
197
|
+
print("=" * 72)
|
|
198
|
+
print("3. 归因矩阵:gap<30min + 模型未切换 的 turn 首笔(探针覆盖的 turn)")
|
|
199
|
+
print("=" * 72)
|
|
200
|
+
core = [r for r in turn_rows if r[1] in ("a.<5min", "b.5-30min") and not r[2] and r[4] is not None]
|
|
201
|
+
groups = [
|
|
202
|
+
(True, "前缀变化(可修)", [r for r in core if r[4]]),
|
|
203
|
+
(False, "前缀未变仍 miss(服务端淘汰/链盲区并存)", [r for r in core if not r[4]]),
|
|
204
|
+
]
|
|
205
|
+
miss_chg = miss_same = 0
|
|
206
|
+
for is_chg, name, rows in groups:
|
|
207
|
+
cr = sum(r[6] for r in rows)
|
|
208
|
+
miss = sum(r[7] for r in rows)
|
|
209
|
+
tot = cr + miss
|
|
210
|
+
hit = cr / tot * 100 if tot else 0.0
|
|
211
|
+
if is_chg:
|
|
212
|
+
miss_chg = miss
|
|
213
|
+
else:
|
|
214
|
+
miss_same = miss
|
|
215
|
+
print(f" {name}")
|
|
216
|
+
print(f" turns={len(rows)} 命中={cr:,d} 未命中={miss:,d} 命中率={hit:.1f}%")
|
|
217
|
+
part_hist = defaultdict(int)
|
|
218
|
+
for r in core:
|
|
219
|
+
if r[4]:
|
|
220
|
+
for p in r[5]:
|
|
221
|
+
part_hist[p] += 1
|
|
222
|
+
if part_hist:
|
|
223
|
+
print(" 变化部位 Top:")
|
|
224
|
+
for p, n in sorted(part_hist.items(), key=lambda kv: -kv[1])[:8]:
|
|
225
|
+
print(f" {p}: {n} 次")
|
|
226
|
+
|
|
227
|
+
print()
|
|
228
|
+
print("=" * 72)
|
|
229
|
+
print("4. 进程边界漂移(baseline entry 与前一条指纹对比)")
|
|
230
|
+
print("=" * 72)
|
|
231
|
+
if baseline_pairs:
|
|
232
|
+
drift = [p for _r, parts in baseline_pairs if parts for p in parts]
|
|
233
|
+
n_no_drift = sum(1 for _r, parts in baseline_pairs if not parts)
|
|
234
|
+
print(f" 边界事件 {len(baseline_pairs)} 次:漂移 {len(baseline_pairs) - n_no_drift} 次,未漂移 {n_no_drift} 次")
|
|
235
|
+
hist = defaultdict(int)
|
|
236
|
+
for p in drift:
|
|
237
|
+
hist[p] += 1
|
|
238
|
+
for p, n in sorted(hist.items(), key=lambda kv: -kv[1])[:8]:
|
|
239
|
+
print(f" 漂移部位 {p}: {n} 次")
|
|
240
|
+
reasons = defaultdict(int)
|
|
241
|
+
for r, _parts in baseline_pairs:
|
|
242
|
+
reasons[r or "unknown"] += 1
|
|
243
|
+
print(f" startReason 分布:{dict(reasons)}")
|
|
244
|
+
else:
|
|
245
|
+
print(" 暂无 baseline 对比数据(需跨进程场景积累)")
|
|
246
|
+
|
|
247
|
+
print()
|
|
248
|
+
print("=" * 72)
|
|
249
|
+
print("5. 决策建议(快照方案 GO/NO-GO 倾向)")
|
|
250
|
+
print("=" * 72)
|
|
251
|
+
n_core = len(core)
|
|
252
|
+
if n_core < 200:
|
|
253
|
+
print(f" 样本不足(归因矩阵 turn 数 {n_core} < 200),继续收集。终止标准见 README.md。")
|
|
254
|
+
elif miss_chg + miss_same == 0:
|
|
255
|
+
print(" 归因矩阵内无 miss token,无需快照方案。")
|
|
256
|
+
else:
|
|
257
|
+
share = miss_chg / (miss_chg + miss_same)
|
|
258
|
+
if share >= 0.5:
|
|
259
|
+
top = max(part_hist, key=part_hist.get) if part_hist else "?"
|
|
260
|
+
print(f" 前缀变化贡献 miss 的 {share * 100:.0f}% → GO 倾向:优先固化「{top}」相关前缀。")
|
|
261
|
+
elif share <= 0.3:
|
|
262
|
+
print(f" 前缀变化仅贡献 miss 的 {share * 100:.0f}% → NO-GO 倾向:主因在服务端淘汰/链盲区,快照收益有限。")
|
|
263
|
+
else:
|
|
264
|
+
print(f" 前缀变化贡献 miss 的 {share * 100:.0f}% → 混合成因,结合部位分布与价差人工决策。")
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
if __name__ == "__main__":
|
|
269
|
+
sys.exit(main())
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhushanwen/pi-cache-probe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-impact Pi probe extension — records system prompt / tools fingerprint hashes into session custom entries (only on change) to attribute LLM prompt-cache miss to prefix drift vs server-side eviction.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"pi": {
|
|
8
|
+
"extensions": [
|
|
9
|
+
"./index.ts"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"pi-package",
|
|
14
|
+
"extension",
|
|
15
|
+
"cache",
|
|
16
|
+
"observability",
|
|
17
|
+
"prompt-caching"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"files": [
|
|
21
|
+
"src/",
|
|
22
|
+
"index.ts",
|
|
23
|
+
"analyze.py"
|
|
24
|
+
],
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
27
|
+
"vitest": "^4.1.8"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"typecheck": "npx tsc --noEmit",
|
|
34
|
+
"test": "vitest run"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// 测试框架:vitest
|
|
2
|
+
// 运行命令:npx vitest run src/__tests__/fingerprint.test.ts
|
|
3
|
+
//
|
|
4
|
+
// 指纹纯函数层测试:stableStringify 稳定性 / hash 长度 / extractSystem 四口径 /
|
|
5
|
+
// sentToolsOf / diffFingerprints / buildProbeEntry 的 baseline-全量、normal-增量、无变化-null。
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
buildProbeEntry,
|
|
11
|
+
diffFingerprints,
|
|
12
|
+
extractSystem,
|
|
13
|
+
HASH_LEN,
|
|
14
|
+
hashOf,
|
|
15
|
+
sentToolsOf,
|
|
16
|
+
stableStringify,
|
|
17
|
+
type Fingerprints,
|
|
18
|
+
} from "../fingerprint";
|
|
19
|
+
|
|
20
|
+
const fp = (over: Partial<Fingerprints> = {}): Fingerprints => ({
|
|
21
|
+
spFull: "a".repeat(HASH_LEN),
|
|
22
|
+
toolsSent: "b".repeat(HASH_LEN),
|
|
23
|
+
contextFiles: "c".repeat(HASH_LEN),
|
|
24
|
+
skills: "d".repeat(HASH_LEN),
|
|
25
|
+
toolsList: "e".repeat(HASH_LEN),
|
|
26
|
+
toolsReg: "f".repeat(HASH_LEN),
|
|
27
|
+
append: "0".repeat(HASH_LEN),
|
|
28
|
+
guidelines: "1".repeat(HASH_LEN),
|
|
29
|
+
customPrompt: "2".repeat(HASH_LEN),
|
|
30
|
+
...over,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("stableStringify", () => {
|
|
34
|
+
it("key 顺序不同输出相同(防假变化的核心保证)", () => {
|
|
35
|
+
expect(stableStringify({ a: 1, b: { d: 4, c: 3 } })).toBe(stableStringify({ b: { c: 3, d: 4 }, a: 1 }));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("undefined 归一为 null", () => {
|
|
39
|
+
expect(stableStringify(undefined)).toBe("null");
|
|
40
|
+
expect(stableStringify({ x: undefined })).toBe(stableStringify({ x: null }));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("数组保序、原始值直译", () => {
|
|
44
|
+
expect(stableStringify([2, 1])).not.toBe(stableStringify([1, 2]));
|
|
45
|
+
expect(stableStringify("s")).toBe('"s"');
|
|
46
|
+
expect(stableStringify(3)).toBe("3");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("hashOf", () => {
|
|
51
|
+
it(`hash 为 ${HASH_LEN} hex 字符且同输入同输出`, () => {
|
|
52
|
+
const h = hashOf({ a: 1 });
|
|
53
|
+
expect(h).toMatch(/^[0-9a-f]{16}$/);
|
|
54
|
+
expect(hashOf({ a: 1 })).toBe(h);
|
|
55
|
+
expect(hashOf({ b: 1 })).not.toBe(h);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("key 顺序不影响 hash", () => {
|
|
59
|
+
expect(hashOf({ a: 1, b: 2 })).toBe(hashOf({ b: 2, a: 1 }));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("extractSystem(payload 四口径)", () => {
|
|
64
|
+
it("OpenAI 兼容:messages 内 role=system", () => {
|
|
65
|
+
expect(extractSystem({ messages: [{ role: "system", content: "s" }, { role: "user", content: "u" }] })).toEqual([
|
|
66
|
+
{ role: "system", content: "s" },
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("OpenAI 兼容:role=developer(pi useDeveloperRole 分支,实测命中)", () => {
|
|
71
|
+
expect(extractSystem({ messages: [{ role: "developer", content: "s" }] })).toEqual([
|
|
72
|
+
{ role: "developer", content: "s" },
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("Anthropic:顶层 system 数组", () => {
|
|
77
|
+
expect(extractSystem({ system: [{ type: "text", text: "s" }], messages: [] })).toEqual([
|
|
78
|
+
{ type: "text", text: "s" },
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("Google:顶层 systemInstruction 对象", () => {
|
|
83
|
+
expect(extractSystem({ systemInstruction: { role: "system", parts: [] } })).toEqual({
|
|
84
|
+
role: "system",
|
|
85
|
+
parts: [],
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("无 system 内容返回 null", () => {
|
|
90
|
+
expect(extractSystem({ messages: [{ role: "user", content: "u" }] })).toBeNull();
|
|
91
|
+
expect(extractSystem(null)).toBeNull();
|
|
92
|
+
expect(extractSystem("str")).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("sentToolsOf", () => {
|
|
97
|
+
it("有 tools 返回数组,缺失返回 null", () => {
|
|
98
|
+
expect(sentToolsOf({ tools: [{ name: "read" }] })).toEqual([{ name: "read" }]);
|
|
99
|
+
expect(sentToolsOf({})).toBeNull();
|
|
100
|
+
expect(sentToolsOf(null)).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("diffFingerprints / buildProbeEntry", () => {
|
|
105
|
+
it("diff 只报变化项", () => {
|
|
106
|
+
expect(diffFingerprints(fp({ contextFiles: "x".repeat(HASH_LEN) }), fp())).toEqual(["contextFiles"]);
|
|
107
|
+
expect(diffFingerprints(fp(), null)).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("baseline:全量 hash + changed ['*'] + cwd + startReason", () => {
|
|
111
|
+
const cur = fp();
|
|
112
|
+
const e = buildProbeEntry(cur, null, { seq: 1, needsBaseline: true, startReason: "startup", cwd: "/w" });
|
|
113
|
+
expect(e).toEqual({
|
|
114
|
+
v: 2,
|
|
115
|
+
seq: 1,
|
|
116
|
+
baseline: true,
|
|
117
|
+
startReason: "startup",
|
|
118
|
+
cwd: "/w",
|
|
119
|
+
changed: ["*"],
|
|
120
|
+
h: cur,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("normal:只存变化项(增量),不带 cwd", () => {
|
|
125
|
+
const last = fp();
|
|
126
|
+
const cur = fp({ contextFiles: "x".repeat(HASH_LEN) });
|
|
127
|
+
const e = buildProbeEntry(cur, last, { seq: 3, needsBaseline: false, startReason: null, cwd: "/w" });
|
|
128
|
+
expect(e).toEqual({
|
|
129
|
+
v: 2,
|
|
130
|
+
seq: 3,
|
|
131
|
+
changed: ["contextFiles"],
|
|
132
|
+
h: { contextFiles: "x".repeat(HASH_LEN) },
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("无变化返回 null(不写 entry)", () => {
|
|
137
|
+
expect(buildProbeEntry(fp(), fp(), { seq: 2, needsBaseline: false, startReason: null, cwd: "/w" })).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// 测试框架:vitest
|
|
2
|
+
// 运行命令:npx vitest run src/__tests__/state-machine.test.ts
|
|
3
|
+
//
|
|
4
|
+
// SDK 契约测试(extension-conventions.md「SDK 接口契约」条款):用最小 mock 的
|
|
5
|
+
// ExtensionAPI 走完整事件序列,断言 cache-probe 对 pi SDK 的调用契约与状态机行为:
|
|
6
|
+
// baseline / 无变化不写 entry / 增量 entry / error 恢复 / agent_end 丢弃 pending /
|
|
7
|
+
// appendEntry 失败不崩 / session_start 重置。
|
|
8
|
+
|
|
9
|
+
/* eslint-disable taste/no-unsafe-cast */
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
13
|
+
|
|
14
|
+
import cacheProbe from "../index";
|
|
15
|
+
|
|
16
|
+
interface HandlerMap {
|
|
17
|
+
session_start?: (e: unknown) => void | Promise<void>;
|
|
18
|
+
before_agent_start?: (e: unknown) => void | Promise<void>;
|
|
19
|
+
before_provider_request?: (e: unknown) => void | Promise<void>;
|
|
20
|
+
agent_end?: (e: unknown) => void | Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface MockSetup {
|
|
24
|
+
pi: ExtensionAPI;
|
|
25
|
+
handlers: HandlerMap;
|
|
26
|
+
entries: Array<{ customType: string; data: Record<string, unknown> }>;
|
|
27
|
+
getAllTools: ReturnType<typeof vi.fn>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function options(over: Record<string, unknown> = {}) {
|
|
31
|
+
return {
|
|
32
|
+
cwd: "/w",
|
|
33
|
+
contextFiles: [{ path: "/w/AGENTS.md", content: "base" }],
|
|
34
|
+
skills: [{ name: "s1" }],
|
|
35
|
+
selectedTools: ["read"],
|
|
36
|
+
toolSnippets: { read: "r" },
|
|
37
|
+
appendSystemPrompt: "append",
|
|
38
|
+
promptGuidelines: ["g"],
|
|
39
|
+
customPrompt: undefined,
|
|
40
|
+
...over,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function payload(systemContent = "sys") {
|
|
45
|
+
return {
|
|
46
|
+
model: "m",
|
|
47
|
+
messages: [{ role: "developer", content: systemContent }, { role: "user", content: "u" }],
|
|
48
|
+
tools: [{ name: "read", description: "d", parameters: { type: "object" } }],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createMockPi(): MockSetup {
|
|
53
|
+
const handlers: HandlerMap = {};
|
|
54
|
+
const entries: MockSetup["entries"] = [];
|
|
55
|
+
const getAllTools = vi.fn(() => [
|
|
56
|
+
{ name: "read", description: "d", parameters: { type: "object" }, promptGuidelines: undefined },
|
|
57
|
+
]);
|
|
58
|
+
const pi = {
|
|
59
|
+
on: vi.fn((type: string, handler: (e: unknown) => unknown) => {
|
|
60
|
+
handlers[type as keyof HandlerMap] = handler as HandlerMap[keyof HandlerMap];
|
|
61
|
+
}),
|
|
62
|
+
appendEntry: vi.fn((customType: string, data: Record<string, unknown>) => {
|
|
63
|
+
entries.push({ customType, data });
|
|
64
|
+
return String(entries.length);
|
|
65
|
+
}),
|
|
66
|
+
getAllTools,
|
|
67
|
+
};
|
|
68
|
+
return { pi: pi as unknown as ExtensionAPI, handlers, entries, getAllTools };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function runTurn(setup: MockSetup, opts: Record<string, unknown>, payloadArg = payload()) {
|
|
72
|
+
await setup.handlers.before_agent_start!({ type: "before_agent_start", systemPrompt: "s", systemPromptOptions: opts });
|
|
73
|
+
await setup.handlers.before_provider_request!({ type: "before_provider_request", payload: payloadArg });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("cache-probe 状态机(SDK 契约)", () => {
|
|
77
|
+
let setup: MockSetup;
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
setup = createMockPi();
|
|
80
|
+
cacheProbe(setup.pi);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("注册 4 个事件 handler,不注册 tool / 不发消息(零行为影响)", () => {
|
|
84
|
+
expect(setup.handlers.session_start).toBeDefined();
|
|
85
|
+
expect(setup.handlers.before_agent_start).toBeDefined();
|
|
86
|
+
expect(setup.handlers.before_provider_request).toBeDefined();
|
|
87
|
+
expect(setup.handlers.agent_end).toBeDefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("进程首笔请求写 baseline(v2、全量 9 key、16 hex、cwd、startReason)", async () => {
|
|
91
|
+
await setup.handlers.session_start!({ type: "session_start", reason: "startup" });
|
|
92
|
+
await runTurn(setup, options());
|
|
93
|
+
expect(setup.entries).toHaveLength(1);
|
|
94
|
+
const e = setup.entries[0];
|
|
95
|
+
expect(e.customType).toBe("cache-probe");
|
|
96
|
+
expect(e.data).toMatchObject({ v: 2, seq: 1, baseline: true, startReason: "startup", cwd: "/w", changed: ["*"] });
|
|
97
|
+
const h = e.data.h as Record<string, string>;
|
|
98
|
+
expect(Object.keys(h).sort()).toEqual(
|
|
99
|
+
["append", "contextFiles", "customPrompt", "guidelines", "skills", "spFull", "toolsList", "toolsReg", "toolsSent"].sort(),
|
|
100
|
+
);
|
|
101
|
+
expect(Object.values(h).every((v) => /^[0-9a-f]{16}$/.test(v))).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("同状态连续 turn 不写 entry;变化只写增量 entry(省数据量的核心)", async () => {
|
|
105
|
+
await setup.handlers.session_start!({ type: "session_start", reason: "startup" });
|
|
106
|
+
await runTurn(setup, options());
|
|
107
|
+
await runTurn(setup, options());
|
|
108
|
+
await runTurn(setup, options());
|
|
109
|
+
expect(setup.entries).toHaveLength(1); // 3 turn 仅 baseline
|
|
110
|
+
await runTurn(setup, options({ contextFiles: [{ path: "/w/AGENTS.md", content: "changed" }] }));
|
|
111
|
+
expect(setup.entries).toHaveLength(2);
|
|
112
|
+
expect(setup.entries[1].data).toMatchObject({ v: 2, seq: 4, changed: ["contextFiles"] });
|
|
113
|
+
expect(Object.keys(setup.entries[1].data.h as object)).toEqual(["contextFiles"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("payload 侧变化(spFull)单独可检测(extension 链注入场景)", async () => {
|
|
117
|
+
await setup.handlers.session_start!({ type: "session_start", reason: "startup" });
|
|
118
|
+
await runTurn(setup, options(), payload("sys-1"));
|
|
119
|
+
await runTurn(setup, options(), payload("sys-2"));
|
|
120
|
+
expect(setup.entries).toHaveLength(2);
|
|
121
|
+
expect(setup.entries[1].data.changed).toEqual(["spFull"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("agent_end 后 pending 被丢弃:无 turn 上下文的 provider 请求不写 entry", async () => {
|
|
125
|
+
await setup.handlers.session_start!({ type: "session_start", reason: "startup" });
|
|
126
|
+
await runTurn(setup, options());
|
|
127
|
+
await setup.handlers.before_agent_start!({ type: "before_agent_start", systemPrompt: "s", systemPromptOptions: options() });
|
|
128
|
+
await setup.handlers.agent_end!({ type: "agent_end" }); // turn 取消,未发请求
|
|
129
|
+
await setup.handlers.before_provider_request!({ type: "before_provider_request", payload: payload() });
|
|
130
|
+
expect(setup.entries).toHaveLength(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("session_start 重置:跨进程写新 baseline", async () => {
|
|
134
|
+
await setup.handlers.session_start!({ type: "session_start", reason: "startup" });
|
|
135
|
+
await runTurn(setup, options());
|
|
136
|
+
await setup.handlers.session_start!({ type: "session_start", reason: "startup" });
|
|
137
|
+
await runTurn(setup, options());
|
|
138
|
+
expect(setup.entries).toHaveLength(2);
|
|
139
|
+
expect(setup.entries[1].data.baseline).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("handler 异常写 error entry 且下一 turn 恢复 baseline", async () => {
|
|
143
|
+
await setup.handlers.session_start!({ type: "session_start", reason: "startup" });
|
|
144
|
+
setup.getAllTools.mockImplementationOnce(() => {
|
|
145
|
+
throw new Error("injected");
|
|
146
|
+
});
|
|
147
|
+
await setup.handlers.before_agent_start!({ type: "before_agent_start", systemPrompt: "s", systemPromptOptions: options() });
|
|
148
|
+
await setup.handlers.before_provider_request!({ type: "before_provider_request", payload: payload() });
|
|
149
|
+
expect(setup.entries).toHaveLength(1);
|
|
150
|
+
expect(setup.entries[0].data).toMatchObject({ v: 2, seq: 1, error: "injected" });
|
|
151
|
+
// 下一 turn 恢复:needsBaseline 已置位
|
|
152
|
+
await runTurn(setup, options());
|
|
153
|
+
expect(setup.entries).toHaveLength(2);
|
|
154
|
+
expect(setup.entries[1].data.baseline).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("appendEntry 失败不崩(stderr 诊断吞掉)", async () => {
|
|
158
|
+
const pi = setup.pi as unknown as { appendEntry: ReturnType<typeof vi.fn> };
|
|
159
|
+
pi.appendEntry.mockImplementation(() => {
|
|
160
|
+
throw new Error("disk full");
|
|
161
|
+
});
|
|
162
|
+
await setup.handlers.session_start!({ type: "session_start", reason: "startup" });
|
|
163
|
+
await expect(runTurn(setup, options())).resolves.toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cache-probe 指纹纯函数层 —— 设计文档 docs/todo/cache-probe-design.md §7.1。
|
|
3
|
+
*
|
|
4
|
+
* entry schema v2(长期生效版,数据量精简为长期采集优化):
|
|
5
|
+
* - hash 用 sha256 前 16 hex(64bit,同 session 内对比,碰撞概率可忽略)
|
|
6
|
+
* - baseline entry 存全量 9 hash + cwd + startReason
|
|
7
|
+
* - normal entry 只存变化项(增量 merge,脚本侧回放合并)
|
|
8
|
+
* - error entry 只存 v/seq/error
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
|
|
13
|
+
/** 指纹 hash 长度(hex 字符数)。 */
|
|
14
|
+
export const HASH_LEN = 16;
|
|
15
|
+
|
|
16
|
+
/** entry schema 版本(v2:短 hash + 增量 entry)。 */
|
|
17
|
+
export const SCHEMA_VERSION = 2;
|
|
18
|
+
|
|
19
|
+
export type FingerprintKey =
|
|
20
|
+
| "spFull"
|
|
21
|
+
| "toolsSent"
|
|
22
|
+
| "contextFiles"
|
|
23
|
+
| "skills"
|
|
24
|
+
| "toolsList"
|
|
25
|
+
| "toolsReg"
|
|
26
|
+
| "append"
|
|
27
|
+
| "guidelines"
|
|
28
|
+
| "customPrompt";
|
|
29
|
+
|
|
30
|
+
export type Fingerprints = Record<FingerprintKey, string>;
|
|
31
|
+
|
|
32
|
+
export interface ProbeEntryData {
|
|
33
|
+
v: typeof SCHEMA_VERSION;
|
|
34
|
+
seq: number;
|
|
35
|
+
baseline?: true;
|
|
36
|
+
startReason?: string;
|
|
37
|
+
cwd?: string;
|
|
38
|
+
changed?: string[];
|
|
39
|
+
/** baseline 存全量;normal 只存变化项。 */
|
|
40
|
+
h?: Partial<Fingerprints>;
|
|
41
|
+
error?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
45
|
+
return value !== null && typeof value === "object";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 递归 sort keys 的稳定序列化(undefined 归一为 null,防 key 顺序抖动产生假变化)。 */
|
|
49
|
+
export function stableStringify(value: unknown): string {
|
|
50
|
+
if (value === undefined) return "null";
|
|
51
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
52
|
+
if (Array.isArray(value)) return "[" + value.map(stableStringify).join(",") + "]";
|
|
53
|
+
const keys = Object.keys(value).sort();
|
|
54
|
+
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify((value as Record<string, unknown>)[k])).join(",") + "}";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function hashOf(value: unknown): string {
|
|
58
|
+
return createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, HASH_LEN);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 从 provider payload 提取 system 内容(最终视角)。三种口径:
|
|
63
|
+
* - OpenAI 兼容:messages 内 role 为 system 或 developer
|
|
64
|
+
* (pi 源码 ai/src/api/openai-completions.ts 的 useDeveloperRole 分支)
|
|
65
|
+
* - Anthropic:顶层 system(数组)
|
|
66
|
+
* - Google:顶层 systemInstruction(对象)
|
|
67
|
+
*/
|
|
68
|
+
export function extractSystem(payload: unknown): unknown | null {
|
|
69
|
+
if (!isRecord(payload)) return null;
|
|
70
|
+
const system = payload["system"];
|
|
71
|
+
if (Array.isArray(system)) return system;
|
|
72
|
+
const systemInstruction = payload["systemInstruction"];
|
|
73
|
+
if (isRecord(systemInstruction)) return systemInstruction;
|
|
74
|
+
const messages = payload["messages"];
|
|
75
|
+
if (Array.isArray(messages)) {
|
|
76
|
+
const sys = messages.filter(
|
|
77
|
+
(m): m is Record<string, unknown> =>
|
|
78
|
+
isRecord(m) && (m["role"] === "system" || m["role"] === "developer"),
|
|
79
|
+
);
|
|
80
|
+
if (sys.length > 0) return sys;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 从 provider payload 提取最终发送的 tools 数组;缺失返回 null。 */
|
|
86
|
+
export function sentToolsOf(payload: unknown): unknown[] | null {
|
|
87
|
+
if (!isRecord(payload)) return null;
|
|
88
|
+
const tools = payload["tools"];
|
|
89
|
+
return Array.isArray(tools) ? tools : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function diffFingerprints(cur: Fingerprints, last: Fingerprints | null): FingerprintKey[] {
|
|
93
|
+
if (last === null) return [];
|
|
94
|
+
return (Object.keys(cur) as FingerprintKey[]).filter((k) => last[k] !== cur[k]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** systemPromptOptions 的结构子集(探针只读这些字段;宽松类型避免纯函数层耦合 SDK)。 */
|
|
98
|
+
export interface SystemPromptOptionsLike {
|
|
99
|
+
cwd?: string | null;
|
|
100
|
+
contextFiles?: unknown;
|
|
101
|
+
skills?: unknown;
|
|
102
|
+
selectedTools?: unknown;
|
|
103
|
+
toolSnippets?: unknown;
|
|
104
|
+
appendSystemPrompt?: unknown;
|
|
105
|
+
promptGuidelines?: unknown;
|
|
106
|
+
customPrompt?: unknown;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** before_agent_start 暂存的输入侧指纹(payload 侧 spFull/toolsSent 到 provider 请求时补齐)。 */
|
|
110
|
+
export interface PendingFingerprint {
|
|
111
|
+
cwd: string | null;
|
|
112
|
+
parts: Omit<Fingerprints, "spFull" | "toolsSent">;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** getAllTools 注册表条目归一(非对象/缺字段归 null,形态稳定后参与 toolsReg hash)。 */
|
|
116
|
+
function normalizeTool(t: unknown): { name: unknown; description: unknown; parameters: unknown; promptGuidelines: unknown } {
|
|
117
|
+
if (!isRecord(t)) return { name: null, description: null, parameters: null, promptGuidelines: null };
|
|
118
|
+
return {
|
|
119
|
+
name: t["name"] ?? null,
|
|
120
|
+
description: t["description"] ?? null,
|
|
121
|
+
parameters: t["parameters"] ?? null,
|
|
122
|
+
promptGuidelines: t["promptGuidelines"] ?? null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* before_agent_start 输入侧 7 hash 计算(handler 只做编排,纯函数便于单测)。
|
|
128
|
+
* null 容忍语义与 turn 侧一致:缺省字段归一为 null 再 hash,防 undefined/null 抖动产生假变化。
|
|
129
|
+
*/
|
|
130
|
+
export function computePendingFingerprint(
|
|
131
|
+
o: SystemPromptOptionsLike | null | undefined,
|
|
132
|
+
tools: readonly unknown[] | null | undefined,
|
|
133
|
+
): PendingFingerprint {
|
|
134
|
+
const opts = o ?? {};
|
|
135
|
+
return {
|
|
136
|
+
cwd: opts.cwd ?? null,
|
|
137
|
+
parts: {
|
|
138
|
+
contextFiles: hashOf(opts.contextFiles ?? null),
|
|
139
|
+
skills: hashOf(opts.skills ?? null),
|
|
140
|
+
toolsList: hashOf([opts.selectedTools ?? null, opts.toolSnippets ?? null]),
|
|
141
|
+
append: hashOf(opts.appendSystemPrompt ?? null),
|
|
142
|
+
guidelines: hashOf(opts.promptGuidelines ?? null),
|
|
143
|
+
customPrompt: hashOf(opts.customPrompt ?? null),
|
|
144
|
+
toolsReg: hashOf((tools ?? []).map(normalizeTool)),
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 构建待写入的 probe entry;无变化返回 null(不写 entry,session 零膨胀)。
|
|
151
|
+
* baseline 全量;normal 增量。cwd 仅 baseline 携带(cwd 变化必然带动 contextFiles 变化,
|
|
152
|
+
* 变化事实由 changed 体现,诊断回溯最近 baseline)。
|
|
153
|
+
*/
|
|
154
|
+
export function buildProbeEntry(
|
|
155
|
+
cur: Fingerprints,
|
|
156
|
+
last: Fingerprints | null,
|
|
157
|
+
meta: { seq: number; needsBaseline: boolean; startReason: string | null; cwd: string | null },
|
|
158
|
+
): ProbeEntryData | null {
|
|
159
|
+
if (meta.needsBaseline || last === null) {
|
|
160
|
+
return {
|
|
161
|
+
v: SCHEMA_VERSION,
|
|
162
|
+
seq: meta.seq,
|
|
163
|
+
baseline: true,
|
|
164
|
+
startReason: meta.startReason ?? undefined,
|
|
165
|
+
cwd: meta.cwd ?? undefined,
|
|
166
|
+
changed: ["*"],
|
|
167
|
+
h: cur,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const changed = diffFingerprints(cur, last);
|
|
171
|
+
if (changed.length === 0) return null;
|
|
172
|
+
const delta: Partial<Fingerprints> = {};
|
|
173
|
+
for (const k of changed) delta[k] = cur[k];
|
|
174
|
+
return { v: SCHEMA_VERSION, seq: meta.seq, changed, h: delta };
|
|
175
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cache-probe — pi file-type extension(前缀稳定性长期数据采集探针)。
|
|
3
|
+
*
|
|
4
|
+
* 设计文档:docs/todo/cache-probe-design.md(§7.1 实现机制)。
|
|
5
|
+
*
|
|
6
|
+
* 记录 9 个指纹 hash(schema v2:短 hash + 增量 entry),变化时才向 session JSONL
|
|
7
|
+
* 写 custom entry(customType = "cache-probe")。custom entry 不进 LLM 上下文,
|
|
8
|
+
* 探针零行为影响:
|
|
9
|
+
* - before_agent_start(每 turn 1 次):算 7 个输入侧 hash 暂存 pending
|
|
10
|
+
* (systemPromptOptions 各字段 + getAllTools 注册表)
|
|
11
|
+
* - before_provider_request(每笔 LLM 请求,仅消费 turn 首笔):从最终 payload
|
|
12
|
+
* 提取 spFull(system 消息,兼容 system/developer/anthropic/google 口径)与
|
|
13
|
+
* toolsSent(tools 数组),与 pending 合并对比,变化/基线时 appendEntry
|
|
14
|
+
* - agent_end:turn 内无 provider 请求则丢弃 pending(无请求即无归因价值)
|
|
15
|
+
*
|
|
16
|
+
* seq = 进程内 before_agent_start 触发计数(无论是否写 entry 都递增),
|
|
17
|
+
* 脚本靠 seq 跳跃区分「无变化 turn」与「漏记」。
|
|
18
|
+
*
|
|
19
|
+
* Fail-safe:handler 全程捕获,异常时写 error entry(缺口可见非静默)并重置为
|
|
20
|
+
* 需基线状态;appendEntry 自身失败仅 stderr 诊断,绝不阻塞请求。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
buildProbeEntry,
|
|
27
|
+
computePendingFingerprint,
|
|
28
|
+
extractSystem,
|
|
29
|
+
hashOf,
|
|
30
|
+
SCHEMA_VERSION,
|
|
31
|
+
sentToolsOf,
|
|
32
|
+
type Fingerprints,
|
|
33
|
+
type PendingFingerprint,
|
|
34
|
+
} from "./fingerprint";
|
|
35
|
+
|
|
36
|
+
const CUSTOM_TYPE = "cache-probe";
|
|
37
|
+
|
|
38
|
+
function stderr(msg: string): void {
|
|
39
|
+
// stderr 已销毁(EPIPE)时错误走流 error 事件而非同步 throw,无需兜底 catch
|
|
40
|
+
process.stderr.write(`[cache-probe] ${msg}\n`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function (pi: ExtensionAPI): void {
|
|
44
|
+
let seq = 0;
|
|
45
|
+
let needsBaseline = true; // 初始 true 兜底 extension 加载晚于 session_start 的情况
|
|
46
|
+
let startReason: string | null = null;
|
|
47
|
+
let last: Fingerprints | null = null;
|
|
48
|
+
let pending: PendingFingerprint | null = null;
|
|
49
|
+
|
|
50
|
+
pi.on("session_start", (event) => {
|
|
51
|
+
needsBaseline = true;
|
|
52
|
+
startReason = event?.reason ?? null;
|
|
53
|
+
last = null;
|
|
54
|
+
pending = null;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
pi.on("before_agent_start", (event) => {
|
|
58
|
+
seq += 1;
|
|
59
|
+
try {
|
|
60
|
+
// 输入侧 7 hash 计算在 fingerprint.ts 纯函数层(handler 只做编排与 fail-safe)
|
|
61
|
+
pending = computePendingFingerprint(event?.systemPromptOptions, pi.getAllTools());
|
|
62
|
+
} catch (err) {
|
|
63
|
+
stderr(`before_agent_start failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
64
|
+
pending = null;
|
|
65
|
+
try {
|
|
66
|
+
pi.appendEntry(CUSTOM_TYPE, { v: SCHEMA_VERSION, seq, error: String(err instanceof Error ? err.message : err) });
|
|
67
|
+
} catch (appendErr) {
|
|
68
|
+
stderr(`appendEntry failed: ${appendErr instanceof Error ? appendErr.message : String(appendErr)}`);
|
|
69
|
+
}
|
|
70
|
+
needsBaseline = true;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
pi.on("before_provider_request", (event) => {
|
|
75
|
+
if (!pending) return; // turn 首笔已被消费,或本 turn 无 before_agent_start 上下文
|
|
76
|
+
const { cwd, parts } = pending;
|
|
77
|
+
pending = null;
|
|
78
|
+
try {
|
|
79
|
+
const payload = event?.payload;
|
|
80
|
+
const system = extractSystem(payload);
|
|
81
|
+
const cur: Fingerprints = {
|
|
82
|
+
...parts,
|
|
83
|
+
spFull: system === null ? "no-system" : hashOf(system),
|
|
84
|
+
toolsSent: sentToolsOf(payload) === null ? "no-tools" : hashOf(sentToolsOf(payload)),
|
|
85
|
+
};
|
|
86
|
+
const entry = buildProbeEntry(cur, last, { seq, needsBaseline, startReason, cwd });
|
|
87
|
+
if (entry) pi.appendEntry(CUSTOM_TYPE, entry);
|
|
88
|
+
last = cur;
|
|
89
|
+
needsBaseline = false;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
92
|
+
stderr(`before_provider_request failed: ${msg}`);
|
|
93
|
+
try {
|
|
94
|
+
pi.appendEntry(CUSTOM_TYPE, { v: SCHEMA_VERSION, seq, error: msg });
|
|
95
|
+
} catch (appendErr) {
|
|
96
|
+
stderr(`appendEntry failed: ${appendErr instanceof Error ? appendErr.message : String(appendErr)}`);
|
|
97
|
+
}
|
|
98
|
+
needsBaseline = true;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// turn 内没有任何 provider 请求(用户取消等)则丢弃 pending,防跨 turn 污染
|
|
103
|
+
pi.on("agent_end", () => {
|
|
104
|
+
pending = null;
|
|
105
|
+
});
|
|
106
|
+
}
|