@xinghe-labs/pi-context-stats 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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +92 -2
- package/extensions/context-stats.ts +401 -0
- package/package.json +35 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [1.0.0] - 2026-10-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- 状态栏常驻显示压缩次数 + 缓存读 + 花费
|
|
9
|
+
- `/ctxstats` 命令展开详情:
|
|
10
|
+
- 压缩历史(时间、压缩前 tokens、原因、压缩自身花费)
|
|
11
|
+
- 缓存读(累计 / 最近一轮 / 平均每轮 / 命中率 / 缓存写)
|
|
12
|
+
- **上下文占用** (🧠 上下文 / 200k tokens(81.9%))
|
|
13
|
+
- **缓存浪费** (♻️ tokens ≈ $金额 + miss 次数)
|
|
14
|
+
- 非对话回合用量分解(cache_warm / 压缩调用)
|
|
15
|
+
- 输入(未命中) / 输出 / 请求次数 / 总花费
|
|
16
|
+
- 压缩原因通过 `pi.appendEntry("context-stats.compaction", {id, reason})` 持久化,resume 后可回填
|
|
17
|
+
- 压缩历史含该次压缩自身的 LLM 调用费用
|
|
18
|
+
- `type:"usage"` 条目(cache_warm 保温刷新)计入统计
|
|
19
|
+
- 缓存浪费算法与 pi 内置 `computeCacheWaste` 数值等价(10 场景对拍验证)
|
|
20
|
+
- 噪声下限 1024 tokens:小幅波动不报警
|
|
21
|
+
- 压缩/保温后基准重置,切换模型仍计 miss
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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.md
CHANGED
|
@@ -1,3 +1,93 @@
|
|
|
1
|
-
#
|
|
1
|
+
# context-stats
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://pi.dev/packages)
|
|
4
|
+
|
|
5
|
+
pi 扩展:追踪上下文压缩、prompt 缓存命中与 token 花费。**零依赖、纯 TypeScript、~360 行**。
|
|
6
|
+
|
|
7
|
+
## 状态栏
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
📦1次压缩 │ 读713.6k·本轮627.2k │ $0.059
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
| 段 | 含义 |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `📦N次压缩` | 发生过 session 压缩(never compaction 时隐藏) |
|
|
16
|
+
| `读X·本轮Y` | X = 累计 cacheRead tokens;Y = 最近一次对话回合的 cacheRead |
|
|
17
|
+
| `$X.XXX` | 本会话总花费 |
|
|
18
|
+
|
|
19
|
+
## `/ctxstats` 命令
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
🧠 上下文:163.8k / 200.0k tokens(81.9%)
|
|
23
|
+
|
|
24
|
+
📦 压缩次数:2
|
|
25
|
+
10:30 · 压缩前 19.4k tokens · threshold · $0.0025
|
|
26
|
+
11:05 · 压缩前 320.0k tokens · overflow · $0.0040
|
|
27
|
+
|
|
28
|
+
💾 缓存读(累计):1.01M tokens
|
|
29
|
+
最近一轮:300.0k │ 平均每轮:356.8k
|
|
30
|
+
缓存命中率:99.6%
|
|
31
|
+
缓存写:800 tokens
|
|
32
|
+
其中非对话回合:压缩 19.4k · cache_warm 50.0k
|
|
33
|
+
♻️ 缓存浪费:51.2k tokens ≈ $0.1382(1 次 miss)
|
|
34
|
+
|
|
35
|
+
↑ 输入(未命中):3.0k tokens
|
|
36
|
+
↓ 输出:1.0k tokens
|
|
37
|
+
💬 请求次数:2
|
|
38
|
+
💰 总花费:$0.068
|
|
39
|
+
其中:压缩 $0.0065 · cache_warm $0.0150
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## 安装
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pi install npm:pi-context-stats
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
或从本地路径:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pi install /path/to/context-stats
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## 统计口径
|
|
55
|
+
|
|
56
|
+
### 计入的用量来源
|
|
57
|
+
|
|
58
|
+
| 来源 | 说明 |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `message` (assistant) | 常规对话回合 |
|
|
61
|
+
| `type:"usage"` 条目 | `cache_warm` 保温刷新(pi 计入 session 总量,但不进上下文) |
|
|
62
|
+
| `CompactionEntry.usage` | 压缩摘要调用(走 `completeSimple`,**不触发** `message_end`) |
|
|
63
|
+
|
|
64
|
+
### 压缩原因持久化
|
|
65
|
+
|
|
66
|
+
压缩原因只存在于 live 事件(`session_compact.reason`),`CompactionEntry` 里没有。本扩展用 `pi.appendEntry("context-stats.compaction", {id, reason})` 持久化,resume/reload 后按 `entry.id` 回填。
|
|
67
|
+
|
|
68
|
+
### 缓存浪费算法
|
|
69
|
+
|
|
70
|
+
与 pi 内置 `computeCacheWaste` 数值等价(10 场景对拍验证):
|
|
71
|
+
|
|
72
|
+
- 噪声下限 **1024 tokens**(小波动不报警)
|
|
73
|
+
- 压缩 / 分支摘要 → **重置基准**,下一轮不误报
|
|
74
|
+
- 切换模型 → **仍计 miss**(会全量重算)
|
|
75
|
+
- `cache_warm` 保温刷新 → 被当作「上一轮请求」
|
|
76
|
+
|
|
77
|
+
### 已知差异 vs pi 内置
|
|
78
|
+
|
|
79
|
+
pi 自己的实现在 `cost` 明细缺字段时会返回 `NaN`(它写 `cost.input + cost.cacheWrite`)。本扩展用 `?? 0` 兜底,显示有限值而非 `$NaN`。代价:若 provider 真的漏报 cost 桶,数字会偏乐观(缺失桶当 0)。pi-ai 正常产出的 `Usage` 四个桶齐全,实际不会触发。
|
|
80
|
+
|
|
81
|
+
## 开发
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# 运行 mock 测试(11 组行为)
|
|
85
|
+
node /tmp/test-context-stats.mjs
|
|
86
|
+
|
|
87
|
+
# 与 pi 内置 computeCacheWaste 对拍(10 场景)
|
|
88
|
+
node /tmp/crosscheck-cache-waste.mjs
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Stats Extension — 记录压缩次数与缓存 token 用量
|
|
3
|
+
*
|
|
4
|
+
* - 状态栏常驻显示:压缩次数 | 缓存读(累计/本轮) | 花费
|
|
5
|
+
* - /ctxstats 查看详细统计:压缩历史、平均每轮缓存读、请求次数、非对话用量
|
|
6
|
+
* - 数据从 session 分支条目重建,resume / fork 后依然准确
|
|
7
|
+
*
|
|
8
|
+
* 计入的不只是对话回合,还包括 pi 另外两类用量(否则总量会比 pi 自己报的少):
|
|
9
|
+
* - 压缩摘要的一次性 LLM 调用:CompactionEntry.usage,走 completeSimple 直调,不触发 message_end
|
|
10
|
+
* - type:"usage" 条目:如 idle 时的 prompt cache 保温刷新(kind = "cache_warm")
|
|
11
|
+
*
|
|
12
|
+
* 说明:cacheRead 是按请求累计的(每轮都重读整个缓存前缀),
|
|
13
|
+
* 所以它 ≈ 各轮上下文大小之和,大是正常的;命中率才是省钱指标。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
interface UsageLike {
|
|
19
|
+
input: number;
|
|
20
|
+
output: number;
|
|
21
|
+
cacheRead: number;
|
|
22
|
+
cacheWrite: number;
|
|
23
|
+
cost?: {
|
|
24
|
+
input?: number;
|
|
25
|
+
output?: number;
|
|
26
|
+
cacheRead?: number;
|
|
27
|
+
cacheWrite?: number;
|
|
28
|
+
total?: number;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 非对话回合的用量,按 kind 分组(如 cache_warm) */
|
|
33
|
+
interface AuxUsage {
|
|
34
|
+
read: number;
|
|
35
|
+
cost: number;
|
|
36
|
+
count: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface CompactionRecord {
|
|
40
|
+
time: string;
|
|
41
|
+
tokensBefore: number;
|
|
42
|
+
reason: string;
|
|
43
|
+
cost: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Stats {
|
|
47
|
+
compactions: CompactionRecord[];
|
|
48
|
+
input: number;
|
|
49
|
+
output: number;
|
|
50
|
+
cacheRead: number;
|
|
51
|
+
cacheWrite: number;
|
|
52
|
+
cost: number;
|
|
53
|
+
requests: number;
|
|
54
|
+
lastRead: number; // 最近一次对话回合的缓存读
|
|
55
|
+
compactRead: number; // 压缩调用自身的缓存读(已计入 cacheRead)
|
|
56
|
+
compactCost: number;
|
|
57
|
+
aux: Map<string, AuxUsage>; // type:"usage" 条目(已计入上面的总量)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** pi 判定缓存 miss 的噪声下限。复刻自 pi 内置 dist/core/cache-stats.js */
|
|
61
|
+
const NOISE_FLOOR_TOKENS = 1024;
|
|
62
|
+
|
|
63
|
+
interface CacheWaste {
|
|
64
|
+
missedTokens: number;
|
|
65
|
+
missedCost: number;
|
|
66
|
+
missCount: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface RequestState {
|
|
70
|
+
promptTokens: number;
|
|
71
|
+
reportedCache: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface ModelRegistryLike {
|
|
75
|
+
find(provider: string, modelId: string):
|
|
76
|
+
| { cost: { cacheRead: number } }
|
|
77
|
+
| undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 用于扫描分支条目的宽类型(条目种类多,只取需要的字段) */
|
|
81
|
+
interface EntryLike {
|
|
82
|
+
type?: string;
|
|
83
|
+
kind?: string;
|
|
84
|
+
usage?: UsageLike;
|
|
85
|
+
message?: {
|
|
86
|
+
role?: string;
|
|
87
|
+
provider?: string;
|
|
88
|
+
model?: string;
|
|
89
|
+
usage?: UsageLike;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function asPreviousRequest(
|
|
94
|
+
u: UsageLike | undefined,
|
|
95
|
+
reportedCache: boolean,
|
|
96
|
+
): RequestState | undefined {
|
|
97
|
+
if (!u) return undefined;
|
|
98
|
+
const promptTokens = (u.input ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
|
|
99
|
+
if (promptTokens <= 0) return undefined;
|
|
100
|
+
return {
|
|
101
|
+
promptTokens,
|
|
102
|
+
reportedCache: reportedCache || u.cacheRead + u.cacheWrite > 0,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 缓存浪费:上一轮 prompt 里本来已在缓存中的 token,这一轮却被按 input / cacheWrite 重新计费。
|
|
108
|
+
* 它比命中率更能说明「到底花了多少冤枉钱」。算法与 pi 内置的 computeCacheWaste 一致
|
|
109
|
+
* (内的 cache-stats.js 未公开导出,且本目录解析不到包内深层路径,故在此复刻)。
|
|
110
|
+
*/
|
|
111
|
+
function computeCacheWaste(
|
|
112
|
+
entries: readonly EntryLike[],
|
|
113
|
+
registry: ModelRegistryLike | undefined,
|
|
114
|
+
): CacheWaste {
|
|
115
|
+
const totals: CacheWaste = { missedTokens: 0, missedCost: 0, missCount: 0 };
|
|
116
|
+
let prev: RequestState | undefined;
|
|
117
|
+
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
// 上下文被合法改写(压缩/分支摘要),下一轮 prompt 是新内容,不算浪费;模型切换除外(会重新全量计费)
|
|
120
|
+
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
121
|
+
prev = undefined;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
// 保温刷新本身被视为「上一轮请求」,不作为 miss 源
|
|
125
|
+
if (entry.type === "usage" && entry.kind === "cache_warm") {
|
|
126
|
+
prev = asPreviousRequest(entry.usage, true) ?? prev;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
|
|
130
|
+
|
|
131
|
+
const msg = entry.message;
|
|
132
|
+
const u = msg.usage;
|
|
133
|
+
const promptTokens = u
|
|
134
|
+
? (u.input ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0)
|
|
135
|
+
: 0;
|
|
136
|
+
// 首次请求、无缓存活动上报(provider 不支持缓存)都不算 miss
|
|
137
|
+
const eligible =
|
|
138
|
+
prev !== undefined &&
|
|
139
|
+
u !== undefined &&
|
|
140
|
+
promptTokens > 0 &&
|
|
141
|
+
(u.cacheRead + u.cacheWrite > 0 || prev.reportedCache);
|
|
142
|
+
if (eligible && prev && u) {
|
|
143
|
+
const missedTokens = Math.min(prev.promptTokens, promptTokens) - u.cacheRead;
|
|
144
|
+
if (missedTokens > NOISE_FLOOR_TOKENS) {
|
|
145
|
+
// 被重新计费的部分只能落在 input / cacheWrite 桶,所以实付单价直接由本条消息的成本明细得出
|
|
146
|
+
const paidTokens = u.input + u.cacheWrite;
|
|
147
|
+
const paidPerToken =
|
|
148
|
+
paidTokens > 0
|
|
149
|
+
? ((u.cost?.input ?? 0) + (u.cost?.cacheWrite ?? 0)) / paidTokens
|
|
150
|
+
: 0;
|
|
151
|
+
const readPerToken =
|
|
152
|
+
u.cacheRead > 0
|
|
153
|
+
? (u.cost?.cacheRead ?? 0) / u.cacheRead
|
|
154
|
+
: msg.provider && msg.model
|
|
155
|
+
? (registry?.find(msg.provider, msg.model)?.cost.cacheRead ?? 0) / 1_000_000
|
|
156
|
+
: 0;
|
|
157
|
+
totals.missedTokens += missedTokens;
|
|
158
|
+
totals.missedCost += missedTokens * Math.max(0, paidPerToken - readPerToken);
|
|
159
|
+
totals.missCount += 1;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
prev = asPreviousRequest(u, prev?.reportedCache ?? false) ?? prev;
|
|
163
|
+
}
|
|
164
|
+
return totals;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export default function (pi: ExtensionAPI) {
|
|
168
|
+
let stats: Stats = newStats();
|
|
169
|
+
const STATUS_KEY = "ctx-stats";
|
|
170
|
+
// 压缩原因只存在于 live 事件,条目里没有;写入自定义条目以便 resume 后回填
|
|
171
|
+
const REASON_LOG = "context-stats.compaction";
|
|
172
|
+
|
|
173
|
+
function newStats(): Stats {
|
|
174
|
+
return {
|
|
175
|
+
compactions: [],
|
|
176
|
+
input: 0,
|
|
177
|
+
output: 0,
|
|
178
|
+
cacheRead: 0,
|
|
179
|
+
cacheWrite: 0,
|
|
180
|
+
cost: 0,
|
|
181
|
+
requests: 0,
|
|
182
|
+
lastRead: 0,
|
|
183
|
+
compactRead: 0,
|
|
184
|
+
compactCost: 0,
|
|
185
|
+
aux: new Map(),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const fmt = (n: number): string => {
|
|
190
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
|
191
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
192
|
+
return `${n}`;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const fmtTime = (iso: string): string => iso.slice(11, 16); // "HH:MM"
|
|
196
|
+
|
|
197
|
+
function hitRate(s: Stats = stats): number {
|
|
198
|
+
return s.input + s.cacheRead > 0
|
|
199
|
+
? (s.cacheRead / (s.input + s.cacheRead)) * 100
|
|
200
|
+
: 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function updateStatus(ctx: ExtensionContext) {
|
|
204
|
+
if (!ctx.hasUI) return;
|
|
205
|
+
const theme = ctx.ui.theme;
|
|
206
|
+
const dim = (t: string) => theme.fg("dim", t);
|
|
207
|
+
const parts: string[] = [];
|
|
208
|
+
|
|
209
|
+
if (stats.compactions.length > 0) {
|
|
210
|
+
parts.push(theme.fg("warning", `📦${stats.compactions.length}次压缩`));
|
|
211
|
+
}
|
|
212
|
+
parts.push(
|
|
213
|
+
dim(`读${fmt(stats.cacheRead)}·本轮${fmt(stats.lastRead)}`),
|
|
214
|
+
theme.fg("success", `$${stats.cost.toFixed(3)}`),
|
|
215
|
+
);
|
|
216
|
+
ctx.ui.setStatus(STATUS_KEY, parts.join(dim(" │ ")));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 累计一次 LLM 调用。turn = 对话回合;compaction = 压缩摘要调用;aux = 非对话用量 */
|
|
220
|
+
function accumulate(
|
|
221
|
+
u: UsageLike | undefined,
|
|
222
|
+
bucket: "turn" | "aux" | "compaction" = "turn",
|
|
223
|
+
) {
|
|
224
|
+
if (!u) return;
|
|
225
|
+
stats.input += u.input ?? 0;
|
|
226
|
+
stats.output += u.output ?? 0;
|
|
227
|
+
stats.cacheRead += u.cacheRead ?? 0;
|
|
228
|
+
stats.cacheWrite += u.cacheWrite ?? 0;
|
|
229
|
+
stats.cost += u.cost?.total ?? 0;
|
|
230
|
+
if (bucket === "turn") {
|
|
231
|
+
stats.requests += 1;
|
|
232
|
+
stats.lastRead = u.cacheRead ?? 0;
|
|
233
|
+
} else if (bucket === "compaction") {
|
|
234
|
+
stats.compactRead += u.cacheRead ?? 0;
|
|
235
|
+
stats.compactCost += u.cost?.total ?? 0;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** type:"usage" 条目,如 cache_warm 保温刷新(pi 计入 session 总量,但不进模型上下文) */
|
|
240
|
+
function accumulateAux(kind: string, u: UsageLike | undefined) {
|
|
241
|
+
if (!u) return;
|
|
242
|
+
accumulate(u, "aux");
|
|
243
|
+
const prev = stats.aux.get(kind) ?? { read: 0, cost: 0, count: 0 };
|
|
244
|
+
prev.read += u.cacheRead ?? 0;
|
|
245
|
+
prev.cost += u.cost?.total ?? 0;
|
|
246
|
+
prev.count += 1;
|
|
247
|
+
stats.aux.set(kind, prev);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
251
|
+
stats = newStats();
|
|
252
|
+
const entries = ctx.sessionManager.getBranch();
|
|
253
|
+
// 先用自定义条目建 id → reason 映射(压缩条目本身不记录原因)
|
|
254
|
+
const reasons = new Map<string, string>();
|
|
255
|
+
for (const entry of entries) {
|
|
256
|
+
if (entry.type === "custom" && entry.customType === REASON_LOG) {
|
|
257
|
+
const d = entry.data as { id?: string; reason?: string } | undefined;
|
|
258
|
+
if (d?.id) reasons.set(d.id, d.reason ?? "unknown");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
for (const entry of entries) {
|
|
262
|
+
if (entry.type === "compaction") {
|
|
263
|
+
const e = entry as {
|
|
264
|
+
id?: string;
|
|
265
|
+
tokensBefore?: number;
|
|
266
|
+
timestamp?: string;
|
|
267
|
+
usage?: UsageLike;
|
|
268
|
+
};
|
|
269
|
+
stats.compactions.push({
|
|
270
|
+
time: e.timestamp ?? "",
|
|
271
|
+
tokensBefore: e.tokensBefore ?? 0,
|
|
272
|
+
reason: (e.id ? reasons.get(e.id) : undefined) ?? "",
|
|
273
|
+
cost: e.usage?.cost?.total ?? 0,
|
|
274
|
+
});
|
|
275
|
+
accumulate(e.usage, "compaction");
|
|
276
|
+
} else if (entry.type === "usage") {
|
|
277
|
+
const e = entry as { kind?: string; usage?: UsageLike };
|
|
278
|
+
accumulateAux(e.kind ?? "usage", e.usage);
|
|
279
|
+
} else if (entry.type === "message" && entry.message.role === "assistant") {
|
|
280
|
+
accumulate((entry.message as { usage?: UsageLike }).usage);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
updateStatus(ctx);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
pi.on("message_end", async (event, ctx) => {
|
|
287
|
+
const m = event.message as { role?: string; usage?: UsageLike };
|
|
288
|
+
if (m.role !== "assistant") return;
|
|
289
|
+
accumulate(m.usage);
|
|
290
|
+
updateStatus(ctx);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
pi.on("session_compact", async (event, ctx) => {
|
|
294
|
+
const entry = event.compactionEntry as {
|
|
295
|
+
id?: string;
|
|
296
|
+
tokensBefore?: number;
|
|
297
|
+
timestamp?: string;
|
|
298
|
+
usage?: UsageLike;
|
|
299
|
+
};
|
|
300
|
+
const reason = event.reason ?? "manual";
|
|
301
|
+
const cost = entry.usage?.cost?.total ?? 0;
|
|
302
|
+
stats.compactions.push({
|
|
303
|
+
time: entry.timestamp ?? "",
|
|
304
|
+
tokensBefore: entry.tokensBefore ?? 0,
|
|
305
|
+
reason,
|
|
306
|
+
cost,
|
|
307
|
+
});
|
|
308
|
+
// 压缩自身的 LLM 调用走 completeSimple 直调,不触发 message_end,必须在这里记账
|
|
309
|
+
accumulate(entry.usage, "compaction");
|
|
310
|
+
if (entry.id) pi.appendEntry(REASON_LOG, { id: entry.id, reason });
|
|
311
|
+
updateStatus(ctx);
|
|
312
|
+
if (ctx.hasUI) {
|
|
313
|
+
const costText = cost > 0 ? ` · $${cost.toFixed(4)}` : "";
|
|
314
|
+
ctx.ui.notify(
|
|
315
|
+
`📦 第 ${stats.compactions.length} 次压缩完成(压缩前 ${fmt(entry.tokensBefore ?? 0)} tokens${costText})`,
|
|
316
|
+
"info",
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
322
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
pi.registerCommand("ctxstats", {
|
|
326
|
+
description: "查看压缩次数与缓存 token 统计",
|
|
327
|
+
handler: async (_args, ctx) => {
|
|
328
|
+
const avgRead = stats.requests > 0 ? stats.cacheRead / stats.requests : 0;
|
|
329
|
+
const lines: string[] = [];
|
|
330
|
+
|
|
331
|
+
// 当前上下文占用(估算,压缩后会短暂为 null)
|
|
332
|
+
const cu = ctx.getContextUsage?.();
|
|
333
|
+
if (cu && cu.tokens !== null && cu.percent !== null) {
|
|
334
|
+
lines.push(
|
|
335
|
+
`🧠 上下文:${fmt(cu.tokens)} / ${fmt(cu.contextWindow)} tokens(${cu.percent.toFixed(1)}%)`,
|
|
336
|
+
``,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
// 压缩历史
|
|
340
|
+
lines.push(`📦 压缩次数:${stats.compactions.length}`);
|
|
341
|
+
for (const c of stats.compactions.slice(-5)) {
|
|
342
|
+
const bits: string[] = [];
|
|
343
|
+
if (c.time) bits.push(fmtTime(c.time));
|
|
344
|
+
bits.push(`压缩前 ${fmt(c.tokensBefore)} tokens`);
|
|
345
|
+
if (c.reason) bits.push(c.reason);
|
|
346
|
+
if (c.cost > 0) bits.push(`$${c.cost.toFixed(4)}`);
|
|
347
|
+
lines.push(` ${bits.join(" · ")}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 缓存统计
|
|
351
|
+
lines.push(
|
|
352
|
+
``,
|
|
353
|
+
`💾 缓存读(累计):${fmt(stats.cacheRead)} tokens`,
|
|
354
|
+
` 最近一轮:${fmt(stats.lastRead)} │ 平均每轮:${fmt(avgRead)}`,
|
|
355
|
+
` 缓存命中率:${hitRate().toFixed(1)}%`,
|
|
356
|
+
` 缓存写:${fmt(stats.cacheWrite)} tokens`,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// 非对话回合的用量(压缩 + type:"usage" 条目)已含在累计里,这里单独列出
|
|
360
|
+
const readBits: string[] = [];
|
|
361
|
+
if (stats.compactRead > 0) readBits.push(`压缩 ${fmt(stats.compactRead)}`);
|
|
362
|
+
for (const [kind, v] of stats.aux) {
|
|
363
|
+
if (v.read > 0) readBits.push(`${kind} ${fmt(v.read)}`);
|
|
364
|
+
}
|
|
365
|
+
if (readBits.length > 0) {
|
|
366
|
+
lines.push(` 其中非对话回合:${readBits.join(" · ")}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 缓存浪费:与本会话「被重复计费」的 token / 金额(按分支条目现场重算)
|
|
370
|
+
const waste = computeCacheWaste(
|
|
371
|
+
ctx.sessionManager.getBranch() as EntryLike[],
|
|
372
|
+
ctx.modelRegistry,
|
|
373
|
+
);
|
|
374
|
+
lines.push(
|
|
375
|
+
waste.missCount > 0
|
|
376
|
+
? `♻️ 缓存浪费:${fmt(waste.missedTokens)} tokens ≈ $${waste.missedCost.toFixed(4)}(${waste.missCount} 次 miss)`
|
|
377
|
+
: `♻️ 缓存浪费:未检测到重复计费`,
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
// 输入输出
|
|
381
|
+
lines.push(
|
|
382
|
+
``,
|
|
383
|
+
`↑ 输入(未命中):${fmt(stats.input)} tokens`,
|
|
384
|
+
`↓ 输出:${fmt(stats.output)} tokens`,
|
|
385
|
+
`💬 请求次数:${stats.requests}`,
|
|
386
|
+
`💰 总花费:$${stats.cost.toFixed(4)}`,
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
const costBits: string[] = [];
|
|
390
|
+
if (stats.compactCost > 0) costBits.push(`压缩 $${stats.compactCost.toFixed(4)}`);
|
|
391
|
+
for (const [kind, v] of stats.aux) {
|
|
392
|
+
if (v.cost > 0) costBits.push(`${kind} $${v.cost.toFixed(4)}`);
|
|
393
|
+
}
|
|
394
|
+
if (costBits.length > 0) {
|
|
395
|
+
lines.push(` 其中:${costBits.join(" · ")}`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xinghe-labs/pi-context-stats",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "pi 扩展:状态栏实时显示压缩次数+缓存读+花费,/ctxstats 展示压缩历史、缓存命中率、缓存浪费(miss 计费)与各项费用分解",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"context",
|
|
9
|
+
"stats",
|
|
10
|
+
"cache",
|
|
11
|
+
"compaction",
|
|
12
|
+
"tokens",
|
|
13
|
+
"cost"
|
|
14
|
+
],
|
|
15
|
+
"pi": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"./extensions/context-stats.ts"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
22
|
+
"@earendil-works/pi-ai": "*"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"pi": ">=1.3.0"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"files": [
|
|
29
|
+
"extensions",
|
|
30
|
+
"README.md",
|
|
31
|
+
"CHANGELOG.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
}
|
|
37
|
+
}
|