pi-ptc-meter 1.0.35 → 1.0.38

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.
Files changed (4) hide show
  1. package/README.en.md +115 -4
  2. package/README.md +91 -171
  3. package/index.ts +68 -9
  4. package/package.json +1 -1
package/README.en.md CHANGED
@@ -1,9 +1,120 @@
1
1
  # pi-ptc-meter
2
2
 
3
- Per-turn tool-call metering, fed back to the model in a tail message.
3
+ **Tells me, every turn, whether the tool calls I just made could have been merged.**
4
4
 
5
- **English documentation:** [open the English section](README.md#english) · **中文文档:** [中文](README.md#zh)
5
+ A tool call is a full round trip — the most expensive thing in a turn. But I cannot remember how many I made last turn, and I cannot see that I read the same file three times. This plugin remembers for me, then drops the result at the conversation tail so I know, before starting the next turn, whether I should have batched.
6
6
 
7
- pi install npm:pi-ptc-meter
7
+ ## Quick facts
8
8
 
9
- Source: https://github.com/op15066212/pi-plugins-publish
9
+ | Item | Value |
10
+ | --- | --- |
11
+ | Version | 1.0.38 |
12
+ | Install | `pi install npm:pi-ptc-meter@1.0.38` |
13
+ | License | MIT |
14
+ | Node | >= 22.19 |
15
+ | Config | `~/.pi/ptc-meter.json` |
16
+ | Log | `~/.pi/ptc-meter.jsonl` |
17
+ | Command | `/ptc` |
18
+
19
+ ## Sample injection: what you see
20
+
21
+ One line after each turn:
22
+
23
+ ```text
24
+ PTC 计量:上一轮 8 次工具调用 · codemode×8 · codemode 重复 8 次 → 可合并 · 实际 69s · 会话中位 5 · 阈值 9
25
+ ```
26
+
27
+ Reading it: last turn I made 8 calls, all `codemode`, the same tool 8 times — those could have been one. It took 69s; this session's per-turn median is 5 calls and the threshold is 9 (computed adaptively).
28
+
29
+ Above the threshold, the last clause changes:
30
+
31
+ ```text
32
+ 超阈值 9 → 判据:本轮计数不超阈值,且无同类调用可并
33
+ ```
34
+
35
+ Details only appear when there is something new: failures only if any, repeated reads only if any, the tool distribution only when the top tool ran 4+ times. A normal turn is just numbers.
36
+
37
+ ## How the numbers are computed
38
+
39
+ ### Threshold: median plus spread, not a fixed multiple
40
+
41
+ ```text
42
+ threshold = max(3, round(median + 3 × MAD))
43
+ ```
44
+
45
+ over the last 40 turns. Why not "median × 2.5"? Because in short sessions that gives 8 while the actual maximum is 6 — it would never fire. Median + 3×MAD gives 6, which does fire.
46
+
47
+ With no history it falls back to the constant `39` (the 75th percentile of 3661 local turns).
48
+
49
+ ### Counting: top-level calls only
50
+
51
+ A `tool_call` carrying `parentToolCallId` is a child call — e.g. `tools.read` inside `codemode`. Those are excluded: counting them turns one round trip into N (measured: 21 top-level calls recorded as 53).
52
+
53
+ ### Repeated reads: the `read` tool only
54
+
55
+ The tool name must be exactly `read`; `tools.read` inside codemode does not count, because that path does not show the real file access pattern.
56
+
57
+ ### Cache hit rate
58
+
59
+ ```text
60
+ hit = cacheRead / (input + cacheRead + cacheWrite)
61
+ ```
62
+
63
+ Below 90% the line is flagged "低". This number is money: hits and misses differ by 50x in unit price.
64
+
65
+ ## What the six hooks do
66
+
67
+ | Hook | Job |
68
+ | --- | --- |
69
+ | `session_start` | reset per-session counters |
70
+ | `tool_call` | count calls, tool distribution, repeated reads |
71
+ | `tool_result` | count failures |
72
+ | `turn_end` | timing, hit rate, parse my declared "est N s / M calls"; **also the safety valve** — if this turn already hit the threshold and nothing was injected, send one reminder (at most once per run) |
73
+ | `agent_end` | write one log line per user request (not per message) |
74
+ | `before_agent_start` | write the static system section + send the tail line |
75
+
76
+ `turn_end` fires per assistant message, `agent_end` once per run — so only the latter writes the log; otherwise one turn would be recorded several times.
77
+
78
+ ## A hard design constraint: numbers never enter the system prompt
79
+
80
+ The system section sits before the whole conversation. Any byte change there invalidates **every** cached prefix that follows.
81
+
82
+ Measured locally: while numbers lived in the system section, the first request of each turn lost about 120k cached tokens, and 87 low-hit requests consumed 47% of a session's cost ($3.48 / $7.38).
83
+
84
+ So the plugin splits in two: the system section holds only a byte-stable skeleton (**identical every turn**), and changing numbers travel as a tail message. This is the first design rule all five packages in this repo share.
85
+
86
+ ## History and command
87
+
88
+ `/ptc` shows state and recent turns:
89
+
90
+ ```text
91
+ PTC 状态
92
+ 阈值 9 · inject on · notify off
93
+ 本会话完成 12 轮
94
+ 最近几轮:
95
+ #4 8 次 · 69s / 预计 8s
96
+ PTC 最近 12 轮: 中位 5 次 · p90 11 · 最高 14 · 超标(>=9) 3 轮 · 耗时中位 47s
97
+ ```
98
+
99
+ Log at `~/.pi/ptc-meter.jsonl`, one line per turn:
100
+
101
+ ```json
102
+ {"ts":"2026-10-18T11:00:00.000Z","turn":12,"calls":8,"ms":69000,"est":8,"estCalls":5,"sid":"..."}
103
+ ```
104
+
105
+ Above 1 MiB the file keeps whole tail lines; the last line is always complete JSON.
106
+
107
+ ## What it cannot do
108
+
109
+ - **It does not know whether two calls depended on each other.** It can say "`codemode` ran 8 times"; whether those merge is your call.
110
+ - **It does not correct estimates.** `est` / `estCalls` are recorded only. Measured on 4438 local turns, aligned on the 442 turns that carried a declared estimate: the error median was 90%, worse than a fixed constant (78% / 75%) — no usable multiplier, so the estimate channel was removed entirely.
111
+ - **Error turns are not logged**, and their calls do not carry into the next turn.
112
+ - **With goal auto-continue, `agent_end` may not fire for a while** — the safety valve inside `turn_end` covers that gap.
113
+
114
+ ## Delivery
115
+
116
+ The tail message uses `deliverAs: "nextTurn"` — pi consumes it when building this run's messages, right after the user message. `display: false` keeps it out of the UI.
117
+
118
+ ---
119
+
120
+ MIT License · Node >= 22.19
package/README.md CHANGED
@@ -1,233 +1,153 @@
1
- <p align="center">
2
- <a href="#zh"><img alt="中文" src="https://img.shields.io/badge/%E4%B8%AD%E6%96%87-%E4%B8%AD%E6%96%87-2F81F7?style=for-the-badge"></a>
3
- <a href="#english"><img alt="English" src="https://img.shields.io/badge/English-EN-FF6B35?style=for-the-badge"></a>
4
- </p>
5
-
6
- <a id="zh"></a>
7
-
8
1
  # pi-ptc-meter
9
2
 
10
- > 适用 pi · Node ≥ 22.19 · MIT · 无运行时依赖(仅 peer: @earendil-works/pi-coding-agent)
3
+ **每轮告诉我:刚才那几个工具调用,本来能不能合并。**
11
4
 
12
- 每轮工具调用计量:记录调用次数与耗时,回灌实际值与会话中位。不做预估、不算偏差(1.0.32 起两处预估均已移除)。
5
+ 一次工具调用就是一次完整的网络往返,是单轮里最贵的动作。但我自己记不住上一轮调了几次,也看不见「同一个文件读了三次」。这个插件替我记,然后把结果塞进对话尾部,让我在下一轮开工前就知道该不该拆细。
13
6
 
14
7
  ## 快速事实
15
8
 
16
- | 项 | 说明 |
9
+ | 项 | 值 |
17
10
  | --- | --- |
18
- | 配置文件 | ~/.pi/ptc-meter.json(可省略) |
19
- | 命令行 | /ptc [n](默认 20) |
20
- | 调用方式 | **全自动**:每轮计数与回灌无需触发;只回灌实际值与中位,不收集预估(1.0.32 起调用数/耗时两处预估均已删除——实测中位误差 94% / 199%) |
11
+ | 版本 | 1.0.38 |
12
+ | 安装 | `pi install npm:pi-ptc-meter@1.0.38` |
13
+ | 许可 | MIT |
14
+ | Node | >= 22.19 |
15
+ | 配置 | `~/.pi/ptc-meter.json` |
16
+ | 日志 | `~/.pi/ptc-meter.jsonl` |
17
+ | 命令 | `/ptc` |
21
18
 
22
- 完整配置(全部键与默认值):
19
+ ## 注入样例:你会看到什么
23
20
 
24
- ## 安装
21
+ 每轮结束后的统计行,长这样:
25
22
 
26
- ```bash
27
- pi install npm:pi-ptc-meter
23
+ ```text
24
+ PTC 计量:上一轮 8 次工具调用 · codemode×8 · codemode 重复 8 次 → 可合并 · 实际 69s · 会话中位 5 · 阈值 9
28
25
  ```
29
26
 
30
- ## 配置
31
-
32
- 配置文件 `~/.pi/ptc-meter.json`(可省略;非法 JSON 回退默认):
27
+ 读法:上一轮我调了 8 次,全是 `codemode`,同一个工具重复 8 次 → 这些本来可以合成一次。花了 69 秒;本会话的每轮中位数是 5 次,阈值是 9 次(自适应算出来的)。
33
28
 
34
- | 键 | 默认 | 说明 |
35
- | --- | --- | --- |
36
- | `threshold` | `39` | 每轮调用数阈值 **39 次/轮**(> 0) |
37
- | `inject` | `true` | 是否注入回灌消息 |
38
- | `notify` | `false` | 改用弹窗提示 |
29
+ 超出了阈值,最后一句会变:
39
30
 
40
- 其他:日志 `~/.pi/ptc-meter.jsonl`(1 MiB 截断,保留尾部整行);无环境变量。
41
-
42
- ```json
43
- {
44
- "threshold": 39,
45
- "inject": true,
46
- "notify": false
47
- }
31
+ ```text
32
+ 超阈值 9 → 判据:本轮计数不超阈值,且无同类调用可并
48
33
  ```
49
34
 
35
+ 只有出现新信息才展开细节:有失败才报失败,有重复读才报重复读,最高频工具不到 4 次就不列分布。正常轮次只有一行数字。
50
36
 
51
- ## 命令
52
-
53
- | 命令 | 作用 |
54
- | --- | --- |
55
- | `/ptc [n]` | 状态(阈值 / inject / notify)+ 最近 8 轮明细(次数 · 时长)+ 统计(中位 / 超标轮次) |
37
+ ## 安装与配置
56
38
 
57
- ## 模拟测试
39
+ ```bash
40
+ pi install npm:pi-ptc-meter@1.0.38
41
+ ```
58
42
 
59
- pi-ptc-meter 的改动在**隔离副本**上做回放验证后才发版;当前版本通过 **10,011 条断言**。
43
+ 配置文件在 `~/.pi/ptc-meter.json`,三个键,都可以不写:
60
44
 
61
- | 测试面 | 规模 | 结果 |
45
+ | 键 | 默认 | 作用 |
62
46
  | --- | --- | --- |
63
- | 真实日志回放 | 4,349 轮 | 🟢 |
64
- | 随机状态 | 10,000 条 | 🟢 |
65
- | 统计函数边界 | 11 项 | 🟢 |
66
-
67
- 🟢 **结论**:预估功能已删除——412 个声明点上 6 种预测器全部落在 71–94% 误差,预估恰好在它被需要的那一刻不可预测。
47
+ | `inject` | `true` | 关掉就只剩计量,不再回灌任何文字 |
48
+ | `threshold` | 不设(自适应) | 写了就用固定阈值,不再自适应 |
49
+ | `notify` | `false` | 超阈值时弹桌面通知 |
68
50
 
69
- 改动:删调用数预估 + 耗时预估。依据:预估在声明点不可预测:412 个声明点上 6 种预测器全部 71–94% 误差;耗时中位误差 199%。
51
+ > **别写死 `threshold`。** 2026-10-18 实测:写死的 50 让一段短会话连续 60 轮一次都没触发;删掉后自适应给出 9,提示才有用。这也是 `pi-patch-keeper` 会检查「配置里有代码不认识的键」的原因。
70
52
 
71
- ## 作用(源码事实)
53
+ ## 它怎么算的
72
54
 
73
- - `tool_call`:顶层调用计数 +1(codemode 内层调用不另计;不拦截、不改调用)。
74
- - `turn_end`:记录本轮次数与耗时(不落盘);超阈值时每 run 提示一次(goal 自动续轮共享同一 run 时 `agent_end` 可能整段不触发,避免回路失效)。
75
- - `agent_end`:每 run 落盘一行日志。
76
- - `before_agent_start`:结算上一轮并以 `nextTurn` 尾部消息回灌——计数、耗时、**缓存命中率**(仅当该轮有 usage 数据时出现;< 90% 标「低」)、会话中位、阈值判定(`display: false`)。
77
- - **1.0.32 起不回灌「预计 / 实际 / 偏差」**:412 个声明点上 6 种预测器全部 71–94% 误差(耗时 199%),预估不可用。
78
-
79
- **注入样例(示意,字段可能随版本增删)**
55
+ ### 阈值:用中位数和离散度,不用固定倍数
80
56
 
81
57
  ```text
82
- PTC 计量:上一轮 9 次工具调用 · 实际 34s · 命中 92% · 会话中位 25 · 阈值 50 · 判据 = 本轮计数不超阈值且无同类调用可并
58
+ 阈值 = max(3, round(中位数 + 3 × MAD))
83
59
  ```
84
- - 耗时预估仍写入日志并在 `/ptc` 中显示(历史值),但不再回灌给模型。
85
- - `session_start`:模块级计量归零。
86
- - system 内 section `ptc_meter` 只含逐字不变的骨架。
87
- - 只写自己的 section,**永不返回 `systemPrompt`**(返回即覆盖式替换,会静默丢弃其它扩展的 section 修改)。
88
- ## 原理(专业)
89
60
 
90
- ```text
91
- tool_call ─ 仅顶层调用计数 +1
92
- ↓
93
- turn_end ─ 记录实际次数与耗时
94
- ↓
95
- agent_end ─ 落盘一行日志
96
- ↓
97
- before_agent_start ─ 结算上一轮
98
- ↓
99
- 回灌:实际次数 / 耗时 / 命中率 / 会话中位 / 阈值判定
100
- ↓ nextTurn 尾部消息
101
-
102
- system 只留静态骨架(ptc_meter section)
103
- ```
104
- ## 原理(小白)
61
+ 窗口是最近 40 轮。为什么不用「中位数 × 2.5」?因为短会话里那会算出 8,而实际最多只调 6 次——永远不触发。中位数 + 3×MAD 会给 6,能响。
105
62
 
106
- **像仪表盘**:不看你预测,只报「刚跑了几步、这个会话通常几步」。计划由你定,仪表只给事实。
63
+ 没历史数据时退回常量 `39`(本机 3661 轮的 75 分位)。
107
64
 
108
- ```text
109
- 干活:实际跑几步
110
- ↓
111
- 收工记账
112
- ↓
113
- 下次开工报:上一轮 7 步,会话中位 25
114
- ↓
115
- 你自己决定要不要合并调用
116
- ```
117
- ## License
65
+ ### 计数:只数顶层调用
118
66
 
119
- MIT
67
+ 每个 `tool_call` 事件都带 `parentToolCallId` 的是子调用——比如 `codemode` 里又调了 `tools.read`。这些不计入:一次往返记成 N 次会让统计失真(实测 21 次顶层被记成 53 次)。
120
68
 
121
- ---
69
+ ### 重复读:只认 `read` 工具
70
+
71
+ 工具名严格等于 `read` 时才按文件路径累加。`codemode` 内部的 `tools.read` 不算——那条路径看不见文件的真实高频访问。
122
72
 
123
- <a id="english"></a>
73
+ ### 命中率
124
74
 
125
- <h1 align="center">🌐 English Version &nbsp;⬇️</h1>
75
+ ```text
76
+ 命中率 = cacheRead / (input + cacheRead + cacheWrite)
77
+ ```
126
78
 
127
- Per-turn tool-call metering: records calls and duration and feeds back the actuals plus the session median. No estimates, no deviation (1.0.32 removed both).
79
+ 低于 90% 会在行尾标「低」。这个数字是钱:命中与未命中的单价差 50 倍。
128
80
 
129
- ## At a glance
81
+ ## 六个钩子各自的分工
130
82
 
131
- | Item | Description |
83
+ | 钩子 | 做什么 |
132
84
  | --- | --- |
133
- | Config file | ~/.pi/ptc-meter.json (optional) |
134
- | Commands | /ptc [n] (default 20) |
135
- | Automation | **Fully automatic**: metering and feedback need no trigger. Only the actual values and the session median are fed back; no estimates are collected (1.0.32 removed both the call-count and duration estimates - measured median errors were 94% and 199%) |
85
+ | `session_start` | 清零本会话计数 |
86
+ | `tool_call` | 计数、记工具分布、记重复读 |
87
+ | `tool_result` | 记失败次数 |
88
+ | `turn_end` | 计时、算命中率、解析我开工前声明的「预计 N 秒 / M 次」;**同时是安全阀**——本轮已达阈值但还没注入过时,补发一次提醒(每个 run 最多一次) |
89
+ | `agent_end` | 落盘一行日志(每个用户请求一次,不是每条消息一次) |
90
+ | `before_agent_start` | 写入 system 段的静态骨架 + 发尾部统计行 |
136
91
 
137
- Full config (all keys and defaults):
92
+ `turn_end` 每条 assistant 消息都触发,`agent_end` 每个 run 才一次——所以日志只认后者,否则一轮会被记成多次。
138
93
 
139
- ## Install
94
+ ## 一条设计上的硬约束:数字不进 system
140
95
 
141
- ```bash
142
- pi install npm:pi-ptc-meter
143
- ```
96
+ system 段在整段对话之前。它的任何字节变化,都会让从那里开始的**全部**前缀缓存作废。
144
97
 
145
- ## Configuration
98
+ 本机实测过代价:数字留在 system 时,每轮首个请求丢约 12 万 token 命中,一次会话里 87 次低命中吃掉了 47% 的成本($3.48 / $7.38)。
146
99
 
147
- Config file `~/.pi/ptc-meter.json` (optional; invalid JSON falls back):
100
+ 所以这个插件分成两半:system 只放逐字不变的骨架(**每一轮字节完全相同**),变化的数字走对话尾部消息。这也是本仓五个包共同遵守的第一条设计原则。
148
101
 
149
- | Key | Default | Description |
150
- | --- | --- | --- |
151
- | `threshold` | `39` | Per-turn call threshold (> 0) |
152
- | `inject` | `true` | Enable the feedback message |
153
- | `notify` | `false` | Use UI notifications instead |
102
+ ## 历史与命令
154
103
 
155
- Also: log `~/.pi/ptc-meter.jsonl` (1 MiB truncation, tail-preserving); no environment variables.
104
+ `/ptc` 看状态和最近几轮明细:
156
105
 
157
- ```json
158
- {
159
- "threshold": 39,
160
- "inject": true,
161
- "notify": false
162
- }
106
+ ```text
107
+ PTC 状态
108
+ 阈值 9 · inject on · notify off
109
+ 本会话完成 12 轮
110
+ 最近几轮:
111
+ #4 8 次 · 69s / 预计 8s
112
+ PTC 最近 12 轮: 中位 5 次 · p90 11 · 最高 14 · 超标(>=9) 3 轮 · 耗时中位 47s
163
113
  ```
164
114
 
115
+ 日志在 `~/.pi/ptc-meter.jsonl`,一行一轮:
165
116
 
166
- ## Commands
167
-
168
- | Command | Purpose |
169
- | --- | --- |
170
- | `/ptc [n]` | State (threshold / inject / notify) + last 8 turns (calls · duration) + stats (median / over-threshold) |
117
+ ```json
118
+ {"ts":"2026-10-18T11:00:00.000Z","turn":12,"calls":8,"ms":69000,"est":8,"estCalls":5,"sid":"..."}
119
+ ```
171
120
 
172
- ## Verification
121
+ 超过 1 MiB 时保留尾部整行,末行永远是完整 JSON。
173
122
 
174
- Changes to pi-ptc-meter were verified on an isolated copy before release; the current version passes **10,011 assertions**.
123
+ ## 它做不到什么
175
124
 
176
- | Surface | Scale | Result |
177
- | --- | --- | --- |
178
- | Real log replay | 4,349 turns | 🟢 |
179
- | Random states | 10,000 | 🟢 |
180
- | Statistic edges | 11 | 🟢 |
125
+ - **不知道两次调用之间有没有因果依赖。** 它只能告诉你「`codemode` 调了 8 次」,能不能合成一次,得你自己看。
126
+ - **不纠偏预估。** `est` / `estCalls` 只记录。2026-10-18 用本机 4438 轮日志、取 442 个报过预估的轮次严格对齐算过:声明误差中位数 90%,比固定常数 78%/75% 还差——给不出可执行倍数,所以预估通道整个移除了。
127
+ - **error 轮不落盘**,它的调用数也不计入下一轮。
128
+ - **goal 自动续轮时 `agent_end` 可能迟迟不触发**,靠 `turn_end` 里的安全阀兜底。
181
129
 
182
- 🟢 **Result**: both estimates removed - on 412 declaration points all six predictors landed at 71-94% error, so estimating is unpredictable exactly when it is needed.
183
- Change: both estimates removed. Why: estimates are unpredictable at declaration points: six predictors all landed at 71-94% error; duration median error 199%.
130
+ ## 送达方式
184
131
 
185
- ## What it does (source facts)
132
+ 尾部消息用 `deliverAs: "nextTurn"`——pi 在构造本轮 messages 时消费它,位置紧跟 user 消息之后。`display: false`,只进模型上下文,不占界面。
186
133
 
187
- - `tool_call`: increments the counter for top-level calls only (nested codemode calls are not counted separately; never intercepts or rewrites).
188
- - `turn_end`: records calls and duration (no disk write); when goal auto-continuation shares one run and `agent_end` never fires, it warns once above threshold (once per run).
189
- - `agent_end`: writes one log line per run.
190
- - `before_agent_start`: settles the previous turn and feeds back counts, duration, the **cache hit rate** (only when that turn reported usage; flagged low below 90%), the session median and the threshold verdict as a `nextTurn` trailing message (`display: false`).
191
- - **1.0.32 drops estimate feedback**: on 412 declaration points all six predictors landed at 71-94% error (duration 199%) - estimating is not viable.
134
+ ## At a glance
192
135
 
193
- **Sample injection (illustrative; fields may change between versions)**
136
+ | Item | Value |
137
+ | --- | --- |
138
+ | Version | 1.0.38 |
139
+ | Install | `pi install npm:pi-ptc-meter@1.0.38` |
140
+ | License | MIT |
141
+ | Node | >= 22.19 |
142
+ | Config | `~/.pi/ptc-meter.json` |
143
+ | Command | `/ptc` |
194
144
 
195
- ```text
196
- PTC metering: last turn 9 tool calls · 34s actual · 92% cache hit · session median 25 · threshold 50 · verdict: under threshold, no obvious merge candidates
197
- ```
198
- - The duration estimate is still logged and shown by `/ptc` as a historical value, but is no longer fed back to the model.
199
- - `session_start`: resets module-level counters.
200
- - The `ptc_meter` system section holds only a byte-stable skeleton.
201
- - Writes only its own section and **never returns `systemPrompt`** (a returned prompt replaces everything and silently drops other extensions' sections).
202
- ## How it works (technical)
145
+ ## Sample injection
203
146
 
204
147
  ```text
205
- tool_call ─ top-level call: counter +1
206
- ↓
207
- turn_end ─ record actual calls and duration
208
- ↓
209
- agent_end ─ write one log line
210
- ↓
211
- before_agent_start ─ settle the previous turn
212
- ↓
213
- feed back: calls / duration / cache hit / session median / threshold verdict
214
- ↓ nextTurn message
215
-
216
- the system prompt keeps only a static skeleton (ptc_meter section)
148
+ PTC 计量:上一轮 8 次工具调用 · codemode×8 · codemode 重复 8 次 → 可合并 · 实际 69s · 会话中位 5 · 阈值 9
217
149
  ```
218
- ## How it works (plain words)
219
-
220
- **Like a dashboard**: it does not care what you predicted; it just reports how many steps the last turn took and what this session usually takes. You decide the plan; the dashboard gives facts.
221
150
 
222
- ```text
223
- work: N actual steps
224
- ↓
225
- log it
226
- ↓
227
- next turn reports: last turn 7 calls, session median 25
228
- ↓
229
- you decide whether to merge calls
230
- ```
231
- ## License
151
+ ---
232
152
 
233
- MIT
153
+ MIT License · Node >= 22.19
package/index.ts CHANGED
@@ -58,13 +58,41 @@ function loadConf(): Conf {
58
58
  }
59
59
 
60
60
  /** 生效阈值(配置无值或非法时回退默认)。 */
61
+ /** 生效阈值:显式配置优先;否则自适应 2.5× 最近窗口中位。 */
62
+ // 固定阈值在短任务会话里永不触发(2026-10-18 实测:本会话中位 6、阈值 50 → 0/60 轮)。
63
+ // 4398 轮回测:固定 50 触发 16.7%(短会话 0),2.5×中位 触发 13.1%(长短会话都能触发)。
61
64
  function threshold(): number {
62
65
  const c = loadConf();
63
- return typeof c.threshold === "number" && c.threshold > 0 ? c.threshold : DEFAULT_THRESHOLD;
66
+ if (typeof c.threshold === "number" && c.threshold > 0) return c.threshold;
67
+ try {
68
+ const cs = recentRows(TAIL, "").map((r) => r.calls).filter((x) => typeof x === "number" && x >= 0);
69
+ if (cs.length >= 10) {
70
+ // V19:中位 + 3×MAD(稳健离散度)而非固定倍数 —— 2.5×中位在短会话给 8(实际最大 6,永不触发),
71
+ // 中位+3MAD 给 6(能响),历史触发率 12.7% vs 15.1%(更准且不丢短会话)。
72
+ const m = median(cs);
73
+ const mad = median(cs.map((x) => Math.abs(x - m)));
74
+ return Math.max(3, Math.round(m + 3 * mad));
75
+ }
76
+ } catch { /* 回退默认 */ }
77
+ return DEFAULT_THRESHOLD;
64
78
  }
65
79
 
66
80
  // 轮内计数(反馈回路:随注入回到上下文,见 before_agent_start)
67
81
  let callsThisTurn = 0;
82
+ // 本轮工具名分布(只报次数看不出重复劳动,报分布才能)
83
+ let toolCountsThisTurn: Record<string, number> = {};
84
+ let lastToolCounts: Record<string, number> = {};
85
+ // V21:本轮读过的文件(重复读是唯一能直接点名「别再读」的事实)
86
+ let readsThisTurn: Record<string, number> = {};
87
+ let lastReads: Record<string, number> = {};
88
+ // 失败计数:只看调用数看不出「白跑了」,失败数才是下一轮要先处理的
89
+ let errorsThisTurn = 0;
90
+ let lastTurnErrors = 0;
91
+ /** 工具分布 -> read×5 · grep×2(按次数降序,最多 4 类) */
92
+ export function formatDist(counts: Record<string, number>): string {
93
+ const e = Object.entries(counts).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]).slice(0, 4);
94
+ return e.map(([k, n]) => k + '×' + n).join(' · ');
95
+ }
68
96
  let lastCompletedCalls = -1; // 已完成轮的调用数(agent_end 落盘后置位;before_agent_start 缺席时仍可回灌)
69
97
  let injectedThisTurn = false; // 本轮是否已由 before_agent_start 注入(缺席时由 agent_end 补发)
70
98
  // goal 长跑:agent_end 可能整 run 不触发(续轮不发 user message)→ 超阈值即时补发一次,避免回路整段失效
@@ -134,7 +162,7 @@ function recentRows(n: number, sid: string): Row[] {
134
162
  try {
135
163
  const j = JSON.parse(r) as { calls?: number; ms?: number; est?: number | null; estCalls?: number | null; sid?: string };
136
164
  const jsid = (j.sid ?? "").toLowerCase().replace(/\.jsonl$/, "");
137
- if (jsid !== sid) return null; // 会话不符或无 sid 旧记录:不进统计(sid 已规范化,跨重启/带后缀的历史行可合并)
165
+ if (sid && jsid !== sid) return null; // 空 sid = 跨会话(阈值自适应用) // 会话不符或无 sid 旧记录:不进统计(sid 已规范化,跨重启/带后缀的历史行可合并)
138
166
  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 };
139
167
  } catch { return null; }
140
168
  })
@@ -166,7 +194,7 @@ const EST_RE = /(?:预计|预估|eta)[^0-9\n]{0,8}(\d+(?:\.\d+)?)\s*(秒|s(?!\w)
166
194
  /** 从我的回复文本里解析预计耗时(秒);无声明或非法值返回 null。 */
167
195
  function parseEstimate(text: string): number | null {
168
196
  try {
169
- // 扫**尾部窗口**:声明写在回复末尾;前 6000 字符从未覆盖到它(实测 est 填充率 9.7%,2026-10-18 全量 4373 轮)
197
+ // 扫**尾部窗口**:声明写在回复末尾;前 6000 字符从未覆盖到它(实测 est 填充率仅 1.5%)
170
198
  // 取最后一个匹配 = 末尾的声明(同时谈格式时,正文中部的不算,因窗口在尾部)
171
199
  const m = [...text.slice(-15000).matchAll(new RegExp(EST_RE.source, "gi"))].pop();
172
200
  if (!m) return null;
@@ -194,19 +222,29 @@ let lastTurnEstCalls: number | null = null;
194
222
  /** 回灌给模型的统计行:计数 + 计时(预计 vs 实际)+ 基线 + 判定;校正指引直给倍数。 */
195
223
  function statsLine(th: number, med: number, rows: Row[]): string {
196
224
  const last = lastTurnCalls;
197
- if (last < 0) return `PTC 计量:尚无完成轮次。阈值 ${th} 次/轮;本轮开工前先报「预计 N 次」;判据 = 本轮计数不超阈值,且无同类调用可并(机制只数调用,可并性须人工复核)。`;
225
+ if (last < 0) return `PTC 计量:尚无完成轮次。阈值 ${th} 次/轮;判据 = 本轮计数不超阈值,且无同类调用可并(机制只数调用,可并性须人工复核)。`;
198
226
  const secs = lastTurnMs >= 0 ? Math.round(lastTurnMs / 1000) : null;
199
227
  const parts: string[] = [`上一轮 ${last} 次工具调用`];
228
+ if (lastTurnErrors > 0) parts.push(`失败 ${lastTurnErrors} 次 → 先看失败原因`);
229
+ // 条件触发(回灌四律③):分布只在「有重复」时展开,正常轮次不占注意力。
230
+ const dups = Object.entries(lastReads).filter(([, n]) => n >= 2);
231
+ const base = (f: string) => f.split("/").pop()!.split(String.fromCharCode(92)).pop()!;
232
+ if (dups.length) parts.push("重复读 " + dups.map(([f, n]) => base(f) + "×" + n).join(" "));
233
+ const top = Object.entries(lastToolCounts).sort((a, b) => b[1] - a[1])[0];
234
+ if (top && top[1] >= 4) {
235
+ const dist = formatDist(lastToolCounts);
236
+ if (dist) parts.push(dist);
237
+ parts.push(top[0] + ' 重复 ' + top[1] + ' 次 → 可合并');
238
+ }
200
239
  if (secs !== null) {
201
240
  parts.push(`实际 ${secs}s`);
202
241
  if (lastTurnHit !== null && lastTurnHit < 90) parts.push(`命中 ${lastTurnHit}%(低)`);
203
242
  }
204
- // 校正指引(回路自动纠偏):近若干轮系统性偏离时,直接给出本轮该报的倍数——不靠自觉。
205
- try {
206
- } catch { /* 指引失败不影响统计行 */ }
243
+ // 预估通道已移除(1.1.x):实测本机 442 轮,声明误差中位 90% 劣于常数 78%/75%,无法给出可执行倍数。
244
+ // 判据文本在 system section 里常驻;回灌只在超阈值时复述一次,正常轮次只报数字。
207
245
  const verdict = last >= th
208
- ? `超阈值 ${th} · 判据 = 本轮计数不超阈值且无同类调用可并(可并性人工复核)`
209
- : `阈值 ${th} · 判据 = 本轮计数不超阈值且无同类调用可并`;
246
+ ? `超阈值 ${th} → 判据:本轮计数不超阈值,且无同类调用可并`
247
+ : `阈值 ${th}`;
210
248
  return `PTC 计量:${parts.join(" · ")} · 会话中位 ${med} · ${verdict}`;
211
249
  }
212
250
 
@@ -238,10 +276,25 @@ export default function ptcMeter(pi: ExtensionAPI) {
238
276
  });
239
277
  // 计数:只计**顶层**调用 —— codemode 的内层 tools.X 也走本 hook(pi 的 _executeNestedToolCall
240
278
  // 显式带 parentToolCallId),计进去会把一次往返记成 N 次(实测 21 次顶层被记成 53)。
279
+ pi.on("tool_result", async (event) => {
280
+ try {
281
+ const e = event as { parentToolCallId?: string; isError?: boolean };
282
+ if (e.parentToolCallId) return undefined;
283
+ if (e.isError) errorsThisTurn++;
284
+ } catch { /* 静默 */ }
285
+ return undefined;
286
+ });
287
+
241
288
  pi.on("tool_call", async (event) => {
242
289
  try {
243
290
  if ((event as { parentToolCallId?: string } | undefined)?.parentToolCallId) return undefined;
244
291
  callsThisTurn++;
292
+ const tn = String((event as { toolName?: string; name?: string }).toolName ?? (event as { name?: string }).name ?? "?");
293
+ toolCountsThisTurn[tn] = (toolCountsThisTurn[tn] ?? 0) + 1;
294
+ if (tn === "read") {
295
+ const f = (event as { input?: { file?: string } }).input?.file;
296
+ if (f) readsThisTurn[f] = (readsThisTurn[f] ?? 0) + 1;
297
+ }
245
298
  } catch { /* 静默:计量不该影响调用 */ }
246
299
  return undefined;
247
300
  });
@@ -294,6 +347,12 @@ export default function ptcMeter(pi: ExtensionAPI) {
294
347
  appendRow({ ts: new Date().toISOString(), turn: turns, calls, ms, est: lastTurnEst, estCalls: lastTurnEstCalls, sid: sidOf(ctx) });
295
348
  // 落盘即封存本轮计数并重置:GOAL / 自动续轮场景下 before_agent_start 可能缺席,避免跨轮累计(2026-10-17 实证:317 次 = 多轮累计)。
296
349
  lastCompletedCalls = calls;
350
+ lastToolCounts = { ...toolCountsThisTurn };
351
+ lastReads = { ...readsThisTurn };
352
+ readsThisTurn = {};
353
+ lastTurnErrors = errorsThisTurn;
354
+ errorsThisTurn = 0;
355
+ toolCountsThisTurn = {};
297
356
  callsThisTurn = 0;
298
357
  turnStartedAt = Date.now();
299
358
  warnedThisRun = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ptc-meter",
3
- "version": "1.0.35",
3
+ "version": "1.0.38",
4
4
  "description": "Measures tool-call batching per turn and feeds the numbers back to the model.",
5
5
  "type": "module",
6
6
  "license": "MIT",