pi-ptc-meter 0.0.0-stage → 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md ADDED
@@ -0,0 +1,44 @@
1
+ # pi-ptc-meter — per-turn tool-call metering (pi extension)
2
+
3
+ Turns "one tool call = one round trip" into visible numbers, and feeds them back to the model when a turn runs long.
4
+
5
+ ## Features
6
+
7
+ - Counts tool calls, wall time and the predicted count declared before the turn.
8
+ - Flags turns above the threshold (default 39) for a merge review.
9
+ - Numbers travel in a tail message; the system section stays byte-identical (prefix cache safe).
10
+ - Log: `~/.pi/ptc-meter.jsonl`, one JSON line per turn.
11
+ - `/ptc [n]` shows the last n turns: calls, time, prediction, median, over-threshold.
12
+
13
+ ## Defaults
14
+
15
+ | Item | Default | Note |
16
+ | --- | --- | --- |
17
+ | Threshold | 39 calls/turn | `threshold` in `~/.pi/ptc-meter.json` |
18
+ | Injection | on | `inject: false` disables |
19
+ | Popup | off | `notify: true` enables (silent by default) |
20
+ | Log | `~/.pi/ptc-meter.jsonl` | keeps whole trailing lines over 1 MiB |
21
+ | Language | Chinese | injected text and command output |
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pi install npm:pi-ptc-meter@1.0.0
27
+ pi install ./pi-ptc-meter
28
+ ```
29
+
30
+ ## Config
31
+
32
+ ```json
33
+ { "threshold": 39, "inject": true, "notify": false }
34
+ ```
35
+
36
+ ## How it works
37
+
38
+ - `tool_call` counts → `turn_end` records → next `before_agent_start` settles and feeds back.
39
+ - Only the tail message changes; the section is stable.
40
+
41
+ ## Limits
42
+
43
+ - Counts calls only; whether they could be merged still needs a human check.
44
+ - Does not intercept input or read conversation content.
package/README.md CHANGED
@@ -1,3 +1,44 @@
1
- # Temporary Holding Version
1
+ # pi-ptc-meter — 每轮工具调用计量与回灌(pi 扩展)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 把「一次工具调用 = 一次往返」的浪费变成可见数字:统计每轮调用次数与时长,超阈值时把数字回灌给模型。
4
+
5
+ ## 功能
6
+
7
+ - 统计每轮工具调用次数、实际耗时、开工前声明的预计值。
8
+ - 超阈值(默认 39 次/轮)时提示复核「是否有可合并的调用」。
9
+ - 数字走对话尾部消息回灌,system 内只留逐字稳定的骨架(保护前缀缓存)。
10
+ - 日志 `~/.pi/ptc-meter.jsonl`,每行含轮次、次数、时长、预计、会话 id。
11
+ - 命令 `/ptc [n]`:最近 n 轮的次数、时长、预计、中位、超标。
12
+
13
+ ## 默认配置
14
+
15
+ | 项 | 默认 | 说明 |
16
+ | --- | --- | --- |
17
+ | 阈值 | 39 次/轮 | `~/.pi/ptc-meter.json` 的 `threshold` |
18
+ | 注入回灌 | 开 | `inject: false` 关闭 |
19
+ | 弹窗提示 | 关 | `notify: true` 开启(默认走静默注入) |
20
+ | 日志 | `~/.pi/ptc-meter.jsonl` | 超过 1 MiB 保留尾部整行 |
21
+ | 语言 | 中文 | 注入文本与命令输出 |
22
+
23
+ ## 安装
24
+
25
+ ```bash
26
+ pi install npm:pi-ptc-meter@1.0.0
27
+ pi install ./pi-ptc-meter
28
+ ```
29
+
30
+ ## 配置
31
+
32
+ ```json
33
+ { "threshold": 39, "inject": true, "notify": false }
34
+ ```
35
+
36
+ ## 机制
37
+
38
+ - `tool_call` 计数 → `turn_end` 记录 → 下轮 `before_agent_start` 结算并回灌。
39
+ - 只在尾部消息里变化,system section 每轮字节相同。
40
+
41
+ ## 限制
42
+
43
+ - 计数只反映调用次数,不判断调用是否真的可合并;可并性需要自行复核。
44
+ - 不拦截、不改输入、不读取对话内容。
package/index.ts ADDED
@@ -0,0 +1,386 @@
1
+ /**
2
+ * ptc-meter —— PTC(执行合并)的计量与**反馈回路**(pi 扩展,自建)
3
+ *
4
+ * ## 为什么存在
5
+ * 手册 §2 把 PTC 列为「第一纪律」,但它此前是唯一零反馈的纪律:每轮用了多少工具调用、
6
+ * 有多少轮明显超标,全靠自省 —— 而手册自己写着「不以自省为准,以计数为准」。
7
+ *
8
+ * ## 闭环(同 caveman-think-zh 的「剥离 → 计数 → 注入 → 改正」)
9
+ * tool_call 计数 → before_agent_start 结算上轮 → 静态骨架进 section、数字进**对话尾部消息** → 我读到数字 → 改正
10
+ * 数字**回灌到我自己的上下文**才叫闭环:只落盘给用户看是仪表盘,不是回路。
11
+ *
12
+ * ## 与 caveman-think-zh 的两处有意差异(源对照,避免误读为背离)
13
+ * ① **双通道**:section 只放逐字稳定的**静态骨架**(含常量 SELF_QUESTIONS 索引),**回灌数据**(计数 / 计时 / 判定)走**对话尾部消息**(回灌四律②)——**不复述规则**:规则的权威定义只在手册(§2 PTC / §3 本条目),代码里只有数字与自问。
14
+ * ② section key `ptc_meter` 是本扩展新造的(caveman 的 `caveman_reasoning` 亦然);pi 的 sections 是
15
+ * `Record<string,string>`,扩展写自己的 key 即可(与内置 MCP 的 `sections[MCP_SERVERS_SECTION]` 同法)。
16
+ *
17
+ * ## 为什么写 section 而不是 return systemPrompt
18
+ * pi 对 section 做 diff 增量下发(内容不变时零额外 token),且返回 systemPrompt 是 forceSystemPrompt
19
+ * **覆盖式替换**——会静默丢弃其后所有 section 的修改。本扩展永远只改自己那一节、永不返回 systemPrompt。
20
+ *
21
+ * ## 计时(预计 vs 实际)
22
+ * 我在每轮开工前于回复里声明 `预计 ~Ns`(或 `预计 2m`);turn_end 解析该声明并入日志,
23
+ * before_agent_start 回灌「实际 Xs(预计 Ys,偏差 ±Z%)」——偏差可见,下次估得更准。
24
+ * 未声明则只报实际 + 同类中位(机制不依赖我是否声明)。回灌尾句放**工程自问索引**(常量 `SELF_QUESTIONS`,关键词指向手册元规则 14 全文——不复述规则,见回灌四律④)。
25
+ * 仍然**不弹窗**:反馈走对话尾部消息(section 只放静态骨架;notify 由配置显式开启)。
26
+ *
27
+ * ## 纪律
28
+ * - 任何失败只跳过 —— **绝不抛错**(tool_call 层无 try/catch,抛错即调用失败)。
29
+ * - 绝不拦截、绝不改输入、不读 event 字段以外的任何东西。
30
+ *
31
+ * ## 配置
32
+ * `~/.pi/ptc-meter.json`:{ "threshold": 39, "notify": false, "inject": true }
33
+ * threshold 超标线 · **notify 超标时弹窗(默认关**——反馈走 section 注入,弹窗只在你想被打扰时才开)· inject 是否写 section(关掉就退回纯计量)
34
+ */
35
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
36
+ import { appendFileSync, closeSync, existsSync, openSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs";
37
+ import * as os from "node:os";
38
+ import * as path from "node:path";
39
+
40
+ const HOME = os.homedir();
41
+ const LOG = path.join(HOME, ".pi", "ptc-meter.jsonl");
42
+ const CONF = path.join(HOME, ".pi", "ptc-meter.json");
43
+ const DEFAULT_THRESHOLD = 39; // p75 口径:3661 轮实测中位 18 / p75 39;12 会 62% 触发(提示失真,2026-10-17)
44
+ const SECTION_KEY = "ptc_meter";
45
+ const SELF_QUESTIONS = "工程自问(元规则 14:空间换时间 / 复杂度 / 公式 / 算法 / 库 / 框架)";
46
+ // system 里只留这句**静态**骨架(每轮字节相同 → 零缓存成本);动态数字走对话尾部消息。
47
+ const SECTION_STATIC = `PTC 计量(F = 合并可并调用、删冗余;域 = 一轮内工具调用,读/查/验/算可并、写不并;不动点 = 本轮计数不超阈值,且复核无同类调用可并(机制只数调用,可并性须人工复核);观察者 = agent_end 落盘日志;回路 = 上轮计数与计时偏差回灌)· ${SELF_QUESTIONS}`;
48
+ const TAIL = 40; // 基线窗口:中位数只取最近这么多轮
49
+
50
+ type Conf = { threshold?: number; notify?: boolean; inject?: boolean };
51
+
52
+ function loadConf(): Conf {
53
+ try {
54
+ if (!existsSync(CONF)) return {};
55
+ const j = JSON.parse(readFileSync(CONF, "utf8")) as Conf;
56
+ return j && typeof j === "object" ? j : {};
57
+ } catch { return {}; }
58
+ }
59
+
60
+ /** 生效阈值(配置无值或非法时回退默认)。 */
61
+ function threshold(): number {
62
+ const c = loadConf();
63
+ return typeof c.threshold === "number" && c.threshold > 0 ? c.threshold : DEFAULT_THRESHOLD;
64
+ }
65
+
66
+ // 轮内计数(反馈回路:随注入回到上下文,见 before_agent_start)
67
+ let callsThisTurn = 0;
68
+ let lastCompletedCalls = -1; // 已完成轮的调用数(agent_end 落盘后置位;before_agent_start 缺席时仍可回灌)
69
+ let injectedThisTurn = false; // 本轮是否已由 before_agent_start 注入(缺席时由 agent_end 补发)
70
+ let lastTurnCalls = -1;
71
+ let lastTurnMs = -1;
72
+ let lastTurnEst: number | null = null;
73
+ let turnStartedAt = Date.now();
74
+ let turns = 0;
75
+
76
+ function percentile(xs: number[], p: number): number {
77
+ if (!xs.length) return 0;
78
+ const s = [...xs].sort((a, b) => a - b);
79
+ const idx = Math.min(s.length - 1, Math.max(0, Math.ceil((p / 100) * s.length) - 1));
80
+ return s[idx];
81
+ }
82
+
83
+
84
+ export function median(xs: number[]): number {
85
+ if (!xs.length) return 0;
86
+ const s = [...xs].sort((a, b) => a - b);
87
+ const m = Math.floor(s.length / 2);
88
+ return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);
89
+ }
90
+
91
+ /** 会话短标识(工作区目录 + 会话文件名,统一小写、去 .jsonl)。日志全局、多会话共写,靠它切分统计;Windows 重启后路径大小写/后缀会变,故规范化。 */
92
+ function sidOf(ctx: ExtensionContext): string {
93
+ try {
94
+ const f = ctx.sessionManager.getSessionFile();
95
+ if (!f) return "";
96
+ const parts = f.split(/[\\/]+/);
97
+ const lower = parts.map((p) => p.toLowerCase());
98
+ const i = lower.indexOf("sessions");
99
+ // 工作区目录 + 会话 id:子代理 run-* 共享父会话 id,不同会话不再共用 "session"。
100
+ const pick = i >= 0 && parts.length > i + 2 ? parts.slice(i + 1, i + 3) : parts.slice(-2);
101
+ return pick.join("/").toLowerCase().replace(/\.jsonl$/, "");
102
+ } catch { return ""; }
103
+ }
104
+
105
+
106
+ type Row = { calls: number; ms: number; est: number | null; estCalls?: number | null };
107
+
108
+ /** 只读文件尾部至多 maxBytes 字节并按行切分;起点不在文件头时丢弃首行残片。 */
109
+ function tailText(file: string, maxBytes: number): string {
110
+ let fd: number | null = null;
111
+ try {
112
+ fd = openSync(file, "r");
113
+ const size = statSync(file).size;
114
+ const start = Math.max(0, size - maxBytes);
115
+ const buf = Buffer.alloc(size - start);
116
+ readSync(fd, buf, 0, buf.length, start);
117
+ const text = buf.toString("utf8");
118
+ return start > 0 ? text.slice(text.indexOf("\n") + 1) : text;
119
+ } catch { return ""; } finally {
120
+ if (fd !== null) { try { closeSync(fd); } catch { /* 静默 */ } }
121
+ }
122
+ }
123
+
124
+ /** 读最近 n 轮的 calls/ms/est(缺文件、坏行、旧格式都当 0,绝不抛错)。只统计本会话。 */
125
+ function recentRows(n: number, sid: string): Row[] {
126
+ try {
127
+ if (!existsSync(LOG)) return [];
128
+ // 只读尾部 1 MiB:日志全局累积、无轮转,全量读会让每轮成本随历史线性增长。
129
+ return tailText(LOG, 1 << 20).trim().split("\n").slice(-2000).filter(Boolean)
130
+ .map((r) => {
131
+ try {
132
+ const j = JSON.parse(r) as { calls?: number; ms?: number; est?: number | null; estCalls?: number | null; sid?: string };
133
+ const jsid = (j.sid ?? "").toLowerCase().replace(/\.jsonl$/, "");
134
+ if (jsid !== sid) return null; // 会话不符或无 sid 旧记录:不进统计(sid 已规范化,跨重启/带后缀的历史行可合并)
135
+ return { calls: j.calls ?? 0, ms: typeof j.ms === "number" ? j.ms : 0, est: typeof j.est === "number" ? j.est : null, estCalls: typeof j.estCalls === "number" ? j.estCalls : null };
136
+ } catch { return null; }
137
+ })
138
+ .filter((r): r is Row => r !== null)
139
+ .slice(-n);
140
+ } catch { return []; }
141
+ }
142
+
143
+ const MAX_LOG = 1 << 20;
144
+
145
+ /** 落盘并保持日志不超过 1 MiB(与 think-anchor 同口径):超限保留尾部整行,末行始终是完整 JSON。 */
146
+ export function appendRow(row: Record<string, unknown>): void {
147
+ try {
148
+ appendFileSync(LOG, JSON.stringify(row) + "\n");
149
+ if (statSync(LOG).size > MAX_LOG) {
150
+ const keep = readFileSync(LOG, "utf8").slice(-MAX_LOG);
151
+ const cut = keep.indexOf("\n");
152
+ writeFileSync(LOG, cut >= 0 ? keep.slice(cut + 1) : keep);
153
+ }
154
+ } catch { /* 静默:计量不该影响会话 */ }
155
+ }
156
+
157
+ export { sidOf, recentRows };
158
+
159
+ // 预计声明:`预计 ~Ns` / `预计 2m` / `ETA 90s`。单位 m 带 (?!s) 以免把 ms 读成分钟。
160
+ const EST_RE = /(?:预计|预估|eta)[^0-9\n]{0,8}(\d+(?:\.\d+)?)\s*(秒|s(?!\w)|分钟|min|m(?!s))/i;
161
+
162
+ /** 从我的回复文本里解析预计耗时(秒);无声明或非法值返回 null。 */
163
+ function parseEstimate(text: string): number | null {
164
+ try {
165
+ // 扫**尾部窗口**:声明写在回复末尾;前 6000 字符从未覆盖到它(实测 est 填充率仅 1.5%)
166
+ // 取最后一个匹配 = 末尾的声明(同时谈格式时,正文中部的不算,因窗口在尾部)
167
+ const m = [...text.slice(-15000).matchAll(new RegExp(EST_RE.source, "gi"))].pop();
168
+ if (!m) return null;
169
+ const n = parseFloat(m[1]);
170
+ if (!isFinite(n) || n <= 0) return null;
171
+ const u = m[2].toLowerCase();
172
+ const secs = u === "秒" || u.startsWith("s") ? n : n * 60;
173
+ return Math.round(Math.min(secs, 7200));
174
+ } catch { return null; }
175
+ }
176
+
177
+ /** 取消息里的纯文本(content 可为字符串或分片数组;其它形态返回空串)。 */
178
+ function textOf(msg: unknown): string {
179
+ try {
180
+ const c = (msg as { content?: unknown } | null | undefined)?.content;
181
+ if (typeof c === "string") return c;
182
+ if (Array.isArray(c)) return c.map((p) => (p && typeof (p as { text?: unknown }).text === "string" ? String((p as { text: string }).text) : "")).join("\n");
183
+ return "";
184
+ } catch { return ""; }
185
+ }
186
+ // 上一轮缓存命中率(%):数字走对话尾部(同其它动态值),不写 system。
187
+ let lastTurnHit: number | null = null;
188
+ let lastTurnEstCalls: number | null = null;
189
+
190
+ /** 回灌给模型的统计行:计数 + 计时(预计 vs 实际)+ 基线 + 判定;校正指引直给倍数。 */
191
+ function statsLine(th: number, med: number, rows: Row[]): string {
192
+ const last = lastTurnCalls;
193
+ if (last < 0) return `PTC 计量:尚无完成轮次。阈值 ${th} 次/轮;本轮开工前先报「预计 N 次」;判据 = 本轮计数不超阈值,且无同类调用可并(机制只数调用,可并性须人工复核)。`;
194
+ const secs = lastTurnMs >= 0 ? Math.round(lastTurnMs / 1000) : null;
195
+ const parts: string[] = [`上一轮 ${last} 次工具调用`];
196
+ if (secs !== null) {
197
+ parts.push(`实际 ${secs}s`);
198
+ if (lastTurnHit !== null) parts.push(`命中 ${lastTurnHit}%${lastTurnHit < 90 ? "(低)" : ""}`);
199
+ if (lastTurnEstCalls !== null && lastTurnEstCalls > 0) {
200
+ const dc = Math.round(((last - lastTurnEstCalls) / lastTurnEstCalls) * 100);
201
+ parts.push(`预计 ${lastTurnEstCalls} 次 / 实际 ${last} 次(偏差 ${dc >= 0 ? "+" : ""}${dc}%)`);
202
+ }
203
+ if (lastTurnEst !== null) {
204
+ const dev = Math.round(((lastTurnMs / 1000 - lastTurnEst) / lastTurnEst) * 100);
205
+ parts.push(`预计 ${lastTurnEst}s / 实际 ${secs}s(偏差 ${dev >= 0 ? "+" : ""}${dev}%)`);
206
+ } else {
207
+ const base = Math.round(median(rows.map((r) => r.ms)) / 1000);
208
+ parts.push(base > 0 ? `预计未声明(同类中位 ${base}s)→ 本轮开工前先报「预计 N 次」` : "预计未声明 → 本轮开工前先报「预计 N 次」");
209
+ }
210
+ }
211
+ // 校正指引(回路自动纠偏):近若干轮系统性偏离时,直接给出本轮该报的倍数——不靠自觉。
212
+ // 调用数校正:次数由任务类型决定,方差远小于时长——优先用它给指引。
213
+ try {
214
+ const ecRows = rows.filter((r) => typeof r.estCalls === "number" && (r.estCalls as number) > 0);
215
+ if (ecRows.length >= 3) {
216
+ const dcs = ecRows.map((r) => ((r.calls - (r.estCalls as number)) / (r.estCalls as number)) * 100);
217
+ const mdc = median(dcs);
218
+ if (mdc <= -20 || mdc >= 20) {
219
+ const f = Math.min(3, Math.max(0.2, Math.round((1 + mdc / 100) * 100) / 100)); // 夹在 0.2–3:极端中位(声明失真)不放大成荒谬倍数
220
+ parts.push(`近 ${ecRows.length} 轮调用数系统性${mdc < 0 ? "高估" : "低估"}(中位 ${mdc > 0 ? "+" : ""}${Math.round(mdc)}%)→ 本轮按约 ${f} 倍报`);
221
+ }
222
+ }
223
+ } catch { /* 静默 */ }
224
+ try {
225
+ const estRows = rows.filter((r) => typeof r.est === "number" && (r.est as number) > 0);
226
+ if (estRows.length >= 3) {
227
+ const devs = estRows.map((r) => ((r.ms / 1000) - (r.est as number)) / (r.est as number) * 100);
228
+ const md = median(devs);
229
+ if (md <= -20 || md >= 20) {
230
+ const factor = Math.min(3, Math.max(0.2, Math.round((1 + md / 100) * 100) / 100));
231
+ parts.push(`近 ${estRows.length} 轮耗时系统性${md < 0 ? "高估" : "低估"}(中位 ${md > 0 ? "+" : ""}${Math.round(md)}%)→ 本轮按约 ${factor} 倍报`);
232
+ }
233
+ }
234
+ } catch { /* 指引失败不影响统计行 */ }
235
+ const verdict = last >= th
236
+ ? `超阈值 ${th} · 判据 = 本轮计数不超阈值且无同类调用可并(可并性人工复核)`
237
+ : `阈值 ${th} · 判据 = 本轮计数不超阈值且无同类调用可并`;
238
+ return `PTC 计量:${parts.join(" · ")} · 会话中位 ${med} · ${verdict}`;
239
+ }
240
+
241
+ /** 在给定文本后追加统计段(rows 由调用方读好,避免同一命令重复读文件)。 */
242
+ function withStats(text: string, rows: Row[], th: number): string {
243
+ if (!rows.length) return text + "\nPTC: 暂无已完成轮次。";
244
+ const cs = rows.map((r) => r.calls);
245
+ const over = cs.filter((x) => x >= th).length;
246
+ const medS = Math.round(median(rows.map((r) => r.ms)) / 1000);
247
+ const withEst = rows.filter((r) => typeof r.est === "number" && r.est > 0);
248
+ const dev = withEst.map((r) => Math.round((((r.ms / 1000) - (r.est as number)) / (r.est as number)) * 100));
249
+ const devLine = dev.length ? ` · 估算偏差(实际-预计) ${dev.slice(-5).join("/")}%` : "";
250
+ return text + `\nPTC 最近 ${rows.length} 轮: 中位 ${median(cs)} 次 · p90 ${percentile(cs, 90)} · 最高 ${Math.max(...cs)} · 超标(>=${th}) ${over} 轮 · 耗时中位 ${medS}s${devLine}`;
251
+ }
252
+
253
+ export default function ptcMeter(pi: ExtensionAPI) {
254
+ pi.on("session_start", () => {
255
+ // 进程内新会话:模块级计量归零,避免把上一会话的 turns/calls 当作「上轮」
256
+ callsThisTurn = 0;
257
+ lastCompletedCalls = -1;
258
+ injectedThisTurn = false;
259
+ lastTurnCalls = -1;
260
+ lastTurnMs = -1;
261
+ lastTurnEst = null;
262
+ lastTurnEstCalls = null;
263
+ lastTurnHit = null;
264
+ turns = 0;
265
+ turnStartedAt = Date.now();
266
+ });
267
+ // 计数:不读 event 字段、不返回任何值 —— 这是最安全的形态(该层无 try/catch)。
268
+ pi.on("tool_call", async () => {
269
+ try { callsThisTurn++; } catch { /* 静默:计量不该影响调用 */ }
270
+ return undefined;
271
+ });
272
+
273
+ // 捕获:turn_end 只更新时序/用量(**不重置** calls —— 重置在 before_agent_start,避免双重置丢计数)
274
+ // 捕获:turn_end 每条 assistant 消息触发一次(pi agent-loop):只更新时序/用量证据,不落盘、不计轮。
275
+ pi.on("turn_end", async (_event) => {
276
+ try {
277
+ lastTurnMs = Date.now() - turnStartedAt;
278
+ // 量纲一致:est 取本轮**首次**声明(开工前),ms 是整轮;中途覆盖会让偏差失真
279
+ { const e = parseEstimate(textOf((_event as unknown as { message?: unknown })?.message)); if (e !== null && lastTurnEst === null) lastTurnEst = e; }
280
+ try {
281
+ const u = (_event as unknown as { message?: { usage?: Record<string, unknown> } })?.message?.usage;
282
+ if (u) {
283
+ const i = Number(u.input) || 0, cr = Number(u.cacheRead) || 0, cw = Number(u.cacheWrite) || 0;
284
+ const den = i + cr + cw;
285
+ lastTurnHit = den > 0 ? Math.round((cr / den) * 1000) / 10 : null;
286
+ }
287
+ } catch { lastTurnHit = null; }
288
+ try {
289
+ const t2 = textOf((_event as unknown as { message?: unknown })?.message);
290
+ const em = [...t2.slice(-15000).matchAll(/预计[^0-9\n]{0,8}(\d+)\s*次/g)].pop();
291
+ const ec = em ? parseInt(em[1], 10) : 0;
292
+ if (ec > 0 && lastTurnEstCalls === null) lastTurnEstCalls = ec; // 同上:首个声明
293
+ } catch { lastTurnEstCalls = null; }
294
+ } catch { /* 静默 */ }
295
+ });
296
+
297
+ // 落盘:每用户请求(run)一次;用 turn_end 计轮会把一轮记成多次。
298
+ pi.on("agent_end", async (event, ctx: ExtensionContext) => {
299
+ try {
300
+ // 扩展事件没有 willRetry 字段;error 轮不落盘且其 calls 不计入下一轮;aborted 与正常轮同路径(整 run 计时,无半轮逻辑)
301
+ const msgs = (event as unknown as { messages?: Array<{ role?: string; stopReason?: string }> }).messages ?? [];
302
+ const lastA = [...msgs].reverse().find((m) => m.role === "assistant");
303
+ if (lastA?.stopReason === "error") { callsThisTurn = 0; return; }
304
+ const th = threshold();
305
+ const calls = callsThisTurn;
306
+ const ms = Date.now() - turnStartedAt;
307
+ turns++;
308
+ appendRow({ ts: new Date().toISOString(), turn: turns, calls, ms, est: lastTurnEst, estCalls: lastTurnEstCalls, sid: sidOf(ctx) });
309
+ // 落盘即封存本轮计数并重置:GOAL / 自动续轮场景下 before_agent_start 可能缺席,避免跨轮累计(2026-10-17 实证:317 次 = 多轮累计)。
310
+ lastCompletedCalls = calls;
311
+ callsThisTurn = 0;
312
+ turnStartedAt = Date.now();
313
+ // D16:自动续轮下 before_agent_start 可能缺席——本轮未注入过则补发一次尾部回灌,保证反馈不断链(2026-10-17 实证:本轮无任何回灌行)。
314
+ if (!injectedThisTurn && loadConf().inject !== false) {
315
+ lastTurnCalls = calls;
316
+ lastTurnMs = ms;
317
+ try {
318
+ const rows = recentRows(TAIL, sidOf(ctx));
319
+ const line = statsLine(threshold(), median(rows.map((r) => r.calls)), rows);
320
+ (pi as unknown as { sendMessage?: (m: unknown, o?: unknown) => void }).sendMessage?.(
321
+ { customType: SECTION_KEY, content: line, display: false },
322
+ { deliverAs: "nextTurn" },
323
+ );
324
+ } catch { /* 静默 */ }
325
+ lastTurnEst = null;
326
+ lastTurnEstCalls = null;
327
+ lastTurnHit = null;
328
+ }
329
+ injectedThisTurn = false;
330
+ // 弹窗默认关(用户指令:弹窗太频繁)。反馈走 section 注入那条路——我读得到,用户不被打扰。
331
+ if (calls >= th && loadConf().notify === true) {
332
+ ctx.ui.notify(`PTC: 本轮 ${calls} 次工具调用(中位 ${median(recentRows(TAIL, sidOf(ctx)).map((r) => r.calls))},阈值 ${th})—— 是否本可合并?`, "warning");
333
+ }
334
+ } catch { /* 静默 */ }
335
+ });
336
+
337
+ // 回路:结算上一轮 → **静态骨架**写 system section、**动态数字**投对话尾部消息。
338
+ // 为什么这么拆(本机实测):system 在对话之前,system 里任一个字节变化都会让其后整段
339
+ // 前缀缓存失效 —— 数字留在 system 时每轮首请求只命中 ~1.3 万 token、丢 12 万,
340
+ // 87 次低命中吃掉 47% 会话成本($3.48/$7.38;低命中请求总价约为命中态的 12 倍,miss 单价 = 命中 ×50);
341
+ // 数字移到对话尾部后 system 永久字节稳定,前缀命中恢复。
342
+ pi.on("before_agent_start", async (event, ctx: ExtensionContext) => {
343
+ try {
344
+ lastTurnCalls = lastCompletedCalls >= 0 ? lastCompletedCalls : turns > 0 ? callsThisTurn : -1; // 首轮尚无完成记录:保持「无历史」,不误报 0
345
+ callsThisTurn = 0;
346
+ turnStartedAt = Date.now();
347
+ const c = loadConf();
348
+ if (c.inject === false) { lastTurnEst = null; lastTurnEstCalls = null; lastTurnHit = null; return undefined; }
349
+ const rows = recentRows(TAIL, sidOf(ctx));
350
+ const line = statsLine(threshold(), median(rows.map((r) => r.calls)), rows);
351
+ const sections = (event as unknown as { systemPromptOptions?: { sections?: Record<string, string> } })
352
+ .systemPromptOptions?.sections;
353
+ if (sections) sections[SECTION_KEY] = SECTION_STATIC; // 静态:每轮字节相同,零缓存成本
354
+ // 动态:对话尾部消息。deliverAs "nextTurn" = pi 在本轮构造 messages 时消费,
355
+ // 位置紧跟 user 消息之后(agent-session: Inject pending nextTurn messages)。
356
+ (pi as unknown as { sendMessage?: (m: unknown, o?: unknown) => void }).sendMessage?.(
357
+ { customType: SECTION_KEY, content: line, display: false },
358
+ { deliverAs: "nextTurn" },
359
+ );
360
+ injectedThisTurn = true;
361
+ lastTurnEst = null;
362
+ lastTurnEstCalls = null;
363
+ lastTurnHit = null;
364
+ } catch { /* 静默 */ }
365
+ return undefined; // 永不返回 systemPrompt(覆盖式替换会丢弃其它 section 的修改)
366
+ });
367
+
368
+ pi.registerCommand("ptc", {
369
+ description: "PTC 计量:最近若干轮的工具调用数、中位数与超标轮次",
370
+ handler: async (args: string, ctx: ExtensionContext) => {
371
+ try {
372
+ const n = Math.max(1, Math.min(200, parseInt((args || "20").trim(), 10) || 20));
373
+ const rows = recentRows(n, sidOf(ctx));
374
+ const c = loadConf();
375
+ const th = threshold();
376
+ const cs = rows.map((r) => r.calls);
377
+ const over = cs.filter((x) => x >= th).length;
378
+ const mostlyOver = cs.length > 0 && over > cs.length / 2;
379
+ const start = Math.max(0, rows.length - 8);
380
+ const head = rows.slice(-8).map((r, i) => ` #${start + i + 1} ${r.calls} 次 · ${Math.round(r.ms / 1000)}s${r.est !== null ? ` / 预计 ${r.est}s` : ""}`).join("\n");
381
+ const status = `PTC 状态\n 阈值 ${th} · inject ${c.inject !== false ? "on" : "off"} · notify ${c.notify === true ? "on" : "off"}\n 本会话完成 ${turns} 轮\n最近几轮:\n${head}`;
382
+ ctx.ui.notify(withStats(status, rows, th), mostlyOver ? "warning" : "info");
383
+ } catch (e) { ctx.ui.notify("PTC: " + (e as Error).message, "error"); }
384
+ },
385
+ });
386
+ }
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "pi-ptc-meter",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Measures tool-call batching per turn and feeds the numbers back to the model.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi",
9
+ "pi-extension",
10
+ "pi-coding-agent"
11
+ ],
12
+ "pi": {
13
+ "extensions": [
14
+ "./index.ts"
15
+ ]
16
+ },
17
+ "files": [
18
+ "index.ts",
19
+ "README.md",
20
+ "README.en.md",
21
+ "LICENSE"
22
+ ],
23
+ "peerDependencies": {
24
+ "@earendil-works/pi-coding-agent": "*"
25
+ }
26
+ }