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.
- package/README.en.md +115 -4
- package/README.md +91 -171
- package/index.ts +68 -9
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -1,9 +1,120 @@
|
|
|
1
1
|
# pi-ptc-meter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Tells me, every turn, whether the tool calls I just made could have been merged.**
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
7
|
+
## Quick facts
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
3
|
+
**每轮告诉我:刚才那几个工具调用,本来能不能合并。**
|
|
11
4
|
|
|
12
|
-
|
|
5
|
+
一次工具调用就是一次完整的网络往返,是单轮里最贵的动作。但我自己记不住上一轮调了几次,也看不见「同一个文件读了三次」。这个插件替我记,然后把结果塞进对话尾部,让我在下一轮开工前就知道该不该拆细。
|
|
13
6
|
|
|
14
7
|
## 快速事实
|
|
15
8
|
|
|
16
|
-
| 项 |
|
|
9
|
+
| 项 | 值 |
|
|
17
10
|
| --- | --- |
|
|
18
|
-
|
|
|
19
|
-
|
|
|
20
|
-
|
|
|
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
|
-
```
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
43
|
+
配置文件在 `~/.pi/ptc-meter.json`,三个键,都可以不写:
|
|
60
44
|
|
|
61
|
-
|
|
|
45
|
+
| 键 | 默认 | 作用 |
|
|
62
46
|
| --- | --- | --- |
|
|
63
|
-
|
|
|
64
|
-
|
|
|
65
|
-
|
|
|
66
|
-
|
|
67
|
-
🟢 **结论**:预估功能已删除——412 个声明点上 6 种预测器全部落在 71–94% 误差,预估恰好在它被需要的那一刻不可预测。
|
|
47
|
+
| `inject` | `true` | 关掉就只剩计量,不再回灌任何文字 |
|
|
48
|
+
| `threshold` | 不设(自适应) | 写了就用固定阈值,不再自适应 |
|
|
49
|
+
| `notify` | `false` | 超阈值时弹桌面通知 |
|
|
68
50
|
|
|
69
|
-
|
|
51
|
+
> **别写死 `threshold`。** 2026-10-18 实测:写死的 50 让一段短会话连续 60 轮一次都没触发;删掉后自适应给出 9,提示才有用。这也是 `pi-patch-keeper` 会检查「配置里有代码不认识的键」的原因。
|
|
70
52
|
|
|
71
|
-
##
|
|
53
|
+
## 它怎么算的
|
|
72
54
|
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
干活:实际跑几步
|
|
110
|
-
↓
|
|
111
|
-
收工记账
|
|
112
|
-
↓
|
|
113
|
-
下次开工报:上一轮 7 步,会话中位 25
|
|
114
|
-
↓
|
|
115
|
-
你自己决定要不要合并调用
|
|
116
|
-
```
|
|
117
|
-
## License
|
|
65
|
+
### 计数:只数顶层调用
|
|
118
66
|
|
|
119
|
-
|
|
67
|
+
每个 `tool_call` 事件都带 `parentToolCallId` 的是子调用——比如 `codemode` 里又调了 `tools.read`。这些不计入:一次往返记成 N 次会让统计失真(实测 21 次顶层被记成 53 次)。
|
|
120
68
|
|
|
121
|
-
|
|
69
|
+
### 重复读:只认 `read` 工具
|
|
70
|
+
|
|
71
|
+
工具名严格等于 `read` 时才按文件路径累加。`codemode` 内部的 `tools.read` 不算——那条路径看不见文件的真实高频访问。
|
|
122
72
|
|
|
123
|
-
|
|
73
|
+
### 命中率
|
|
124
74
|
|
|
125
|
-
|
|
75
|
+
```text
|
|
76
|
+
命中率 = cacheRead / (input + cacheRead + cacheWrite)
|
|
77
|
+
```
|
|
126
78
|
|
|
127
|
-
|
|
79
|
+
低于 90% 会在行尾标「低」。这个数字是钱:命中与未命中的单价差 50 倍。
|
|
128
80
|
|
|
129
|
-
##
|
|
81
|
+
## 六个钩子各自的分工
|
|
130
82
|
|
|
131
|
-
|
|
|
83
|
+
| 钩子 | 做什么 |
|
|
132
84
|
| --- | --- |
|
|
133
|
-
|
|
|
134
|
-
|
|
|
135
|
-
|
|
|
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
|
-
|
|
92
|
+
`turn_end` 每条 assistant 消息都触发,`agent_end` 每个 run 才一次——所以日志只认后者,否则一轮会被记成多次。
|
|
138
93
|
|
|
139
|
-
##
|
|
94
|
+
## 一条设计上的硬约束:数字不进 system
|
|
140
95
|
|
|
141
|
-
|
|
142
|
-
pi install npm:pi-ptc-meter
|
|
143
|
-
```
|
|
96
|
+
system 段在整段对话之前。它的任何字节变化,都会让从那里开始的**全部**前缀缓存作废。
|
|
144
97
|
|
|
145
|
-
|
|
98
|
+
本机实测过代价:数字留在 system 时,每轮首个请求丢约 12 万 token 命中,一次会话里 87 次低命中吃掉了 47% 的成本($3.48 / $7.38)。
|
|
146
99
|
|
|
147
|
-
|
|
100
|
+
所以这个插件分成两半:system 只放逐字不变的骨架(**每一轮字节完全相同**),变化的数字走对话尾部消息。这也是本仓五个包共同遵守的第一条设计原则。
|
|
148
101
|
|
|
149
|
-
|
|
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
|
-
|
|
104
|
+
`/ptc` 看状态和最近几轮明细:
|
|
156
105
|
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
121
|
+
超过 1 MiB 时保留尾部整行,末行永远是完整 JSON。
|
|
173
122
|
|
|
174
|
-
|
|
123
|
+
## 它做不到什么
|
|
175
124
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
+
尾部消息用 `deliverAs: "nextTurn"`——pi 在构造本轮 messages 时消费它,位置紧跟 user 消息之后。`display: false`,只进模型上下文,不占界面。
|
|
186
133
|
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
-
|
|
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;
|