pi-cache-optimizer 2.4.9 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -24
- package/README.zh-CN.md +43 -112
- package/index.ts +369 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -104,25 +104,10 @@ This extension is pure Node.js — no shell exec, no native bindings, no platfor
|
|
|
104
104
|
|
|
105
105
|
State files under `~/.pi/agent/` are resolved via Node's `os.homedir()`, so on Windows the path automatically expands to `C:\Users\<you>\.pi\agent\...`. The extension's compat warnings, `/cache-optimizer doctor`, and `/cache-optimizer compat` show the platform-appropriate path automatically (`~/.pi/agent/models.json` on Linux/macOS, `%USERPROFILE%\.pi\agent\models.json` on Windows). All shell snippets in this README are bash, matching the shell Pi runs in on every supported platform; no PowerShell or `cmd.exe` translation is needed when commands are executed inside (or for) Pi.
|
|
106
106
|
|
|
107
|
-
## Quickstart
|
|
108
|
-
|
|
109
|
-
1. (Optional but recommended) Read the official Pi + DeepSeek onboarding guide: [`pi_mono.zh-CN.md`](https://github.com/deepseek-ai/awesome-deepseek-agent/blob/main/docs/pi_mono.zh-CN.md). It covers Pi installation and core configuration.
|
|
110
|
-
2. Install this extension:
|
|
111
|
-
|
|
112
|
-
```bash
|
|
113
|
-
pi install npm:pi-cache-optimizer
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
3. Export your DeepSeek API key in the same shell where you run `pi` (if you use a DeepSeek model):
|
|
117
|
-
|
|
118
|
-
```bash
|
|
119
|
-
export DEEPSEEK_API_KEY='...'
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
This extension never reads, stores, or prints the key value.
|
|
123
|
-
|
|
124
107
|
## Install
|
|
125
108
|
|
|
109
|
+
Install and configure Pi first, then install this extension:
|
|
110
|
+
|
|
126
111
|
```bash
|
|
127
112
|
pi install npm:pi-cache-optimizer
|
|
128
113
|
```
|
|
@@ -254,11 +239,30 @@ Stats rules:
|
|
|
254
239
|
- Stats are persisted in a small local JSON state file at `~/.pi/agent/pi-cache-optimizer-stats.json`. Earlier 1.x releases used `~/.pi/agent/deepseek-cache-optimizer-stats.json`; on first run after upgrade the old file is read once, copied into the new path, and best-effort deleted. The file stores only counters and the local day; it does not store API keys, prompts, messages, headers, or model output.
|
|
255
240
|
- Existing v1 state files from DeepSeek-only releases are migrated into the DeepSeek adapter counters automatically.
|
|
256
241
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
-
|
|
260
|
-
-
|
|
261
|
-
|
|
242
|
+
Session scope:
|
|
243
|
+
|
|
244
|
+
- Stats are now scoped per Pi session + provider/model, not global.
|
|
245
|
+
- Each Pi process (session) starts with fresh counters. Different sessions using the
|
|
246
|
+
same provider/model do not share footer statistics or reset effects.
|
|
247
|
+
- Within the same Pi session, stats accumulate normally for each provider/model.
|
|
248
|
+
- Pi restarts start fresh stats for the new session.
|
|
249
|
+
- `/reload` does **not** clear accumulated session-scoped stats; it only clears transient
|
|
250
|
+
in-memory data (recent samples, integrity notification state).
|
|
251
|
+
- Crossing the local natural-day boundary resets counters on the next status update or
|
|
252
|
+
supported-provider response.
|
|
253
|
+
- Persisted stats are stored under an opaque session hash key (SHA-256 hash of session id)
|
|
254
|
+
so that different sessions' data is isolated on disk. Raw session ids are never logged,
|
|
255
|
+
displayed, or written to the stats file.
|
|
256
|
+
|
|
257
|
+
> **Concurrent-write caveat**: Stats are persisted atomically (write-temp then rename),
|
|
258
|
+
> but multiple Pi processes reading and writing simultaneously can still experience
|
|
259
|
+
> a lost-update window (the classic read-modify-write race). The implementation
|
|
260
|
+
> preserves sequential operation semantics (each write replaces only the current
|
|
261
|
+
> session's data and re-appends other sessions from the previous read), but does
|
|
262
|
+
> **not** guarantee concurrent-safety across processes. If you run multiple Pi
|
|
263
|
+
> instances using the same `models.json` with different provider/model IDs, their
|
|
264
|
+
> stats files may occasionally overwrite each other's session data. This affects
|
|
265
|
+
> only the on-disk persistence; in-memory counters per process remain correct.
|
|
262
266
|
|
|
263
267
|
## Suggested compat config
|
|
264
268
|
|
|
@@ -307,13 +311,35 @@ The extension registers a Pi command `/cache-optimizer` for interactive diagnosi
|
|
|
307
311
|
and low-hit cause diagnosis
|
|
308
312
|
/cache-optimizer stats — show active model stats bucket and recent trend
|
|
309
313
|
/cache-optimizer compat — show compat suggestion with edit instructions
|
|
314
|
+
/cache-optimizer reset — reset local session stats for the current model
|
|
315
|
+
(does not affect upstream provider prompt cache)
|
|
310
316
|
```
|
|
311
317
|
|
|
312
318
|
When run without arguments, `/cache-optimizer` shows an interactive selection menu
|
|
313
|
-
(Doctor / Stats / Compat / Cancel) when the Pi UI supports it (`ctx.ui.select`).
|
|
314
|
-
non-interactive terminals, it falls back to text help with current model compat
|
|
319
|
+
(Doctor / Stats / Compat / Reset / Cancel) when the Pi UI supports it (`ctx.ui.select`).
|
|
320
|
+
In non-interactive terminals, it falls back to text help with current model compat
|
|
315
321
|
status.
|
|
316
322
|
|
|
323
|
+
### `/cache-optimizer reset`
|
|
324
|
+
|
|
325
|
+
Resets only the current Pi session's stats bucket for the active provider/model.
|
|
326
|
+
Clears today's request counters (hit/total), cached token counts, and recent trend
|
|
327
|
+
samples for that model. Other provider/model buckets within the same session are
|
|
328
|
+
unaffected, and other sessions' data is preserved.
|
|
329
|
+
|
|
330
|
+
```text
|
|
331
|
+
Provider: otokapi
|
|
332
|
+
Model: gpt-5.5
|
|
333
|
+
|
|
334
|
+
✅ Reset local session cache stats for "otokapi/gpt-5.5".
|
|
335
|
+
Upstream provider prompt cache was not modified.
|
|
336
|
+
New requests will start a fresh stats bucket for this Pi session.
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
If no active model is selected, a warning is shown. If the active model does not
|
|
340
|
+
match a cache adapter (footer stats are not shown for it), a friendly no-op message
|
|
341
|
+
is displayed instead.
|
|
342
|
+
|
|
317
343
|
### `/cache-optimizer doctor`
|
|
318
344
|
|
|
319
345
|
Displays the active model's provider, model id, name, API type, base URL, current
|
package/README.zh-CN.md
CHANGED
|
@@ -107,25 +107,10 @@ Generic OpenAI-compatible 代理**不会**仅因为使用 OpenAI 形状 API 或
|
|
|
107
107
|
|
|
108
108
|
状态文件 `~/.pi/agent/` 通过 Node 的 `os.homedir()` 解析,所以在 Windows 上会自动展开为 `C:\Users\<你>\.pi\agent\...`。扩展的 compat 提醒、`/cache-optimizer doctor` 和 `/cache-optimizer compat` 会自动显示适合当前平台的路径(Linux/macOS 上显示 `~/.pi/agent/models.json`,Windows 上显示 `%USERPROFILE%\.pi\agent\models.json`)。本文档中所有 shell 命令均使用 bash 语法,与 Pi 在每个受支持平台下运行的 shell 一致;只要在 Pi 内(或为 Pi 而执行)运行,就**不需要**改写为 PowerShell 或 `cmd.exe` 形式。
|
|
109
109
|
|
|
110
|
-
## 快速开始
|
|
111
|
-
|
|
112
|
-
1. (可选但推荐)先读一遍官方 Pi + DeepSeek 接入指南:[`pi_mono.zh-CN.md`](https://github.com/deepseek-ai/awesome-deepseek-agent/blob/main/docs/pi_mono.zh-CN.md)。它讲了 Pi 安装与基础配置。
|
|
113
|
-
2. 安装本扩展:
|
|
114
|
-
|
|
115
|
-
```bash
|
|
116
|
-
pi install npm:pi-cache-optimizer
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
3. 如果使用 DeepSeek 模型,请在运行 `pi` 的同一个 shell 中导出 DeepSeek API key:
|
|
120
|
-
|
|
121
|
-
```bash
|
|
122
|
-
export DEEPSEEK_API_KEY='...'
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
本扩展**不会**读取、存储或打印 key 的值。
|
|
126
|
-
|
|
127
110
|
## 安装
|
|
128
111
|
|
|
112
|
+
请先安装并配置好 Pi,然后安装本扩展:
|
|
113
|
+
|
|
129
114
|
```bash
|
|
130
115
|
pi install npm:pi-cache-optimizer
|
|
131
116
|
```
|
|
@@ -249,11 +234,48 @@ Gemini cache 1/2 · 0.18M/0.50M tok (36%)
|
|
|
249
234
|
- 统计会持久化到本地小 JSON 文件:`~/.pi/agent/pi-cache-optimizer-stats.json`。早期 1.x 版本使用 `~/.pi/agent/deepseek-cache-optimizer-stats.json`;首次运行新版时会从旧路径读一次、复制到新路径、然后 best-effort 删除旧文件。该文件只保存计数器和本地日期,不保存 API key、prompt、消息内容、headers 或模型输出。
|
|
250
235
|
- DeepSeek-only 旧版本的 v1 状态文件会自动迁移到 DeepSeek adapter 计数器。
|
|
251
236
|
|
|
252
|
-
|
|
237
|
+
## 统计桶隔离(Session-scoped)
|
|
238
|
+
|
|
239
|
+
- 统计现在按 Pi session + provider/model 隔离,不再全局聚合。
|
|
240
|
+
- 每个 Pi 进程(session)从零开始计数。不同 session 对同一 provider/model 的统计不共享。
|
|
241
|
+
- 同一 Pi session 中,同一 provider/model 的统计正常累积。
|
|
242
|
+
- Pi 进程重新启动时,新的 session 从头开始统计。
|
|
243
|
+
- `/reload` **不会**清空累计的 session-scoped 统计;只清除临时内存状态(recent samples、integrity notification)。
|
|
244
|
+
- 跨过本地自然日时,下一次状态更新或受支持 provider 响应时会自动按本地日期清零。
|
|
245
|
+
- 持久化统计文件使用不透明的 session hash key(SHA-256 哈希后的 session id)来隔离不同 session 的数据。原始 session id 不会写入文件。
|
|
246
|
+
|
|
247
|
+
> **并发写入说明**:统计以原子方式持久化(写 temp 文件再 rename),但多个 Pi 进程同时读写仍然存在 lost-update 窗口(经典的 read-modify-write 竞态)。实现中尽可能保留顺序语义(每次写入只替换当前 session 的数据,其他 session 的数据从前次读取追加),但**不保证**跨进程并发安全。如果同时运行多个 Pi 实例使用不同 provider/model,统计文件偶尔可能覆盖彼此 session 的数据。这只影响磁盘持久化;每个进程的内存统计始终正确。
|
|
248
|
+
|
|
249
|
+
## 诊断命令
|
|
250
|
+
|
|
251
|
+
扩展注册了 Pi 命令 `/cache-optimizer` 用于交互式诊断。
|
|
252
|
+
|
|
253
|
+
```
|
|
254
|
+
/cache-optimizer — 交互菜单(无 UI 时显示文字帮助)
|
|
255
|
+
/cache-optimizer doctor — 显示 provider、model、API、base URL、compat 状态
|
|
256
|
+
及低命中原因诊断
|
|
257
|
+
/cache-optimizer stats — 显示当前模型的 stats 桶和近期趋势
|
|
258
|
+
/cache-optimizer compat — 显示 compat 建议和编辑说明
|
|
259
|
+
/cache-optimizer reset — 重置当前 Pi session 中当前模型的本地统计
|
|
260
|
+
(不影响上游 provider prompt cache)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
不带参数时,当 Pi UI 支持时(`ctx.ui.select` 可用),`/cache-optimizer` 会显示交互选择菜单(Doctor / Stats / Compat / Reset / Cancel)。在非交互终端中,会回退到文字帮助和当前模型 compat 状态。
|
|
253
264
|
|
|
254
|
-
-
|
|
255
|
-
|
|
256
|
-
|
|
265
|
+
### `/cache-optimizer reset`
|
|
266
|
+
|
|
267
|
+
仅重置当前 Pi session 中活跃 provider/model 的统计桶。清除今日请求计数(命中/总数)、缓存 token 计数和近期趋势样本。同一 session 中其他 provider/model 的桶不受影响,其他 session 的数据也不受影响。
|
|
268
|
+
|
|
269
|
+
```text
|
|
270
|
+
Provider: otokapi
|
|
271
|
+
Model: gpt-5.5
|
|
272
|
+
|
|
273
|
+
✅ 已重置 "otokapi/gpt-5.5" 的本地 session 缓存统计。
|
|
274
|
+
上游 provider prompt cache 未被修改。
|
|
275
|
+
新的请求将为这个 Pi session 重新开始统计。
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
如果没有选中的模型,显示警告。如果当前模型不匹配缓存的 adapter(不显示底部统计),则显示友好的无操作提示。
|
|
257
279
|
|
|
258
280
|
## 建议的 compat 配置
|
|
259
281
|
|
|
@@ -291,97 +313,6 @@ Gemini cache 1/2 · 0.18M/0.50M tok (36%)
|
|
|
291
313
|
|
|
292
314
|
> 提醒:只有在 endpoint 或代理明确支持时,才建议启用 session-affinity headers 或 cache-control compat。
|
|
293
315
|
|
|
294
|
-
## 诊断命令
|
|
295
|
-
|
|
296
|
-
扩展注册了 Pi 命令 `/cache-optimizer` 用于交互式诊断。
|
|
297
|
-
|
|
298
|
-
```
|
|
299
|
-
/cache-optimizer — 交互菜单(无 UI 时显示文字帮助)
|
|
300
|
-
/cache-optimizer doctor — 显示 provider、model、API、base URL、compat 状态
|
|
301
|
-
及低命中原因诊断
|
|
302
|
-
/cache-optimizer stats — 显示当前模型的 stats 桶和近期趋势
|
|
303
|
-
/cache-optimizer compat — 显示 compat 建议和编辑说明
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
不带参数时,当 Pi UI 支持时(`ctx.ui.select` 可用),`/cache-optimizer` 会显示交互选择菜单(Doctor / Stats / Compat / Cancel)。在非交互终端中,会回退到文字帮助和当前模型 compat 状态。
|
|
307
|
-
|
|
308
|
-
### `/cache-optimizer doctor`
|
|
309
|
-
|
|
310
|
-
显示当前模型的 provider、model id、名称、API 类型、base URL、当前 `compat` 标志以及缺少的缓存/session-affinity 标志。如果缺少标志,还会显示可复制的 JSON 片段和精确编辑位置。
|
|
311
|
-
|
|
312
|
-
输出中还会包含 "Cache diagnosis"(缓存诊断)章节,按优先级分析低命中原因:
|
|
313
|
-
1. **缺少 compat 标志** — 缺少启用 prompt 缓存和 session-affinity 路由的标志。
|
|
314
|
-
2. **路由/渠道风险** — 多后端路由可能导致缓存分散到不同上游实例。
|
|
315
|
-
3. **缺少 usage 字段** — 代理可能未返回 prompt 层级的使用情况字段,导致 footer 低估命中率。
|
|
316
|
-
4. **近期趋势低** — 当今日缓存命中率低于 30% 时,诊断提示代理路由不稳定或 prompt 前缀变化。
|
|
317
|
-
|
|
318
|
-
对于已完整配置但命中率仍低的模型,诊断会重点提示粘性路由和上游缓存使用验证,而非 compat 标志。
|
|
319
|
-
|
|
320
|
-
### `/cache-optimizer stats`
|
|
321
|
-
|
|
322
|
-
显示当前模型的 stats 桶(`provider/modelId`),今日请求计数(命中/总数)、缓存输入令牌 vs 总输入令牌及命中率百分比。同时显示近期趋势摘要(最近 10 条和最近 30 条样本):
|
|
323
|
-
|
|
324
|
-
```text
|
|
325
|
-
Model key: otokapi/gpt-5.5
|
|
326
|
-
Adapter: OpenAI cache
|
|
327
|
-
|
|
328
|
-
── Today ──
|
|
329
|
-
Requests: 3 hit / 10 total · 30%
|
|
330
|
-
Cached tokens: 0.0015M / 0.005M input · 30%
|
|
331
|
-
|
|
332
|
-
── Recent trend ──
|
|
333
|
-
Recent 10/10: 3/10 hits · 30% tok cached
|
|
334
|
-
Recent 10/10: 3/10 hits · 30% tok cached
|
|
335
|
-
```
|
|
336
|
-
|
|
337
|
-
如果当前模型没有匹配的 adapter,显示友好提示。如果尚未记录样本,趋势显示 "no samples"。
|
|
338
|
-
|
|
339
|
-
如果所有 compat 标志都已配置且适用(第三方 `openai-completions` 代理),输出显示 `✅ Compat fully configured.`。对于不适用 compat 检查的模型(官方 OpenAI、非 `openai-completions` API、custom transport),显示 `ℹ️ Compat check not applicable for this model.`:
|
|
340
|
-
|
|
341
|
-
```text
|
|
342
|
-
Provider: otokapi
|
|
343
|
-
Model: gpt-5.5
|
|
344
|
-
API: openai-completions
|
|
345
|
-
Base URL: https://otokapi.example.com/v1
|
|
346
|
-
Compat: {}
|
|
347
|
-
⚠️ Missing compat flags: supportsLongCacheRetention, sendSessionAffinityHeaders
|
|
348
|
-
Edit ~/.pi/agent/models.json -> providers["otokapi"] -> compat (same level as baseUrl/api/apiKey/models):
|
|
349
|
-
{
|
|
350
|
-
"supportsLongCacheRetention": true,
|
|
351
|
-
"sendSessionAffinityHeaders": true
|
|
352
|
-
}
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
### `/cache-optimizer compat`
|
|
356
|
-
|
|
357
|
-
显示当前模型的 compat 建议,包括文件路径、provider 路径和可复制 JSON 片段。当没有缺失的 compat 标志时,如果模型是适用的第三方代理则显示 `✅ Compat fully configured.`,否则显示 `ℹ️ Compat check not applicable for this model.`。
|
|
358
|
-
|
|
359
|
-
当模型通过已知的路由器/通道代理(OpenRouter、Vercel AI Gateway、LiteLLM、OneAPI/NewAPI/VoAPI 或通用第三方 OpenAI-compatible 代理)时,`doctor` 和 `compat` 子命令都会附加路由/通道诊断信息和建议。
|
|
360
|
-
|
|
361
|
-
### 路由/通道诊断
|
|
362
|
-
|
|
363
|
-
对于通过非官方 base URL 使用 OpenAI-compatible API(`openai-completions` 或 `openai-responses`)的模型,扩展会从 `provider`、`baseUrl` 和 `compat` 元数据中检测常见的路由/通道代理模式:
|
|
364
|
-
|
|
365
|
-
| 类型 | 检测方式 | 建议 |
|
|
366
|
-
|------|----------|------|
|
|
367
|
-
| **OpenRouter** | baseUrl 或 provider 包含 `openrouter`/`openrouter.ai` | 在 compat 中用 `openRouterRouting.only` 或 `.order` 固定上游 provider |
|
|
368
|
-
| **Vercel AI Gateway** | baseUrl 包含 `ai-gateway.vercel.sh` 或 provider 包含 `vercel` | 在 compat 中用 `vercelGatewayRouting.only` 或 `.order` 固定上游 |
|
|
369
|
-
| **LiteLLM / OneAPI / NewAPI / VoAPI** | baseUrl 或 provider 包含 `litellm`、`oneapi`/`one-api`、`newapi`/`new-api`、`voapi`/`vo-api` | 确保每 session 固定路由,转发 `prompt_cache_key` + session-affinity headers,返回缓存用量字段 |
|
|
370
|
-
| **通用第三方代理** | 任何非官方 base URL 的 `openai-completions` 模型,且不匹配以上类型 | 通用建议:验证单上游路由、转发 `prompt_cache_key` + session-affinity headers、返回缓存用量 |
|
|
371
|
-
|
|
372
|
-
这些诊断**仅用于建议**。它们不参与 adapter selection(仍基于 id/name)、不参与 `prompt_cache_key` 注入、不参与 footer 统计、也不做任何自动化配置修改。检测仅使用 Pi 暴露的元数据(`provider`、`api`、`baseUrl`、`compat`),不会读取或暴露 API key、prompt、payload、headers 或模型输出。
|
|
373
|
-
|
|
374
|
-
官方 OpenAI(`api.openai.com`)和 custom transport(`kiro-api`、`anthropic-messages`、`bedrock-converse-stream`)不会触发路由/通道诊断。
|
|
375
|
-
|
|
376
|
-
### 安全说明
|
|
377
|
-
|
|
378
|
-
命令只读取 Pi 通过 `ctx.model` 暴露的元数据:provider、id、name、api、baseUrl、compat。它**不会**读取或暴露:
|
|
379
|
-
- API key 或环境密钥
|
|
380
|
-
- 请求/响应 payload
|
|
381
|
-
- Prompt 或模型输出
|
|
382
|
-
- HTTP headers
|
|
383
|
-
- `~/.pi/agent/models.json` 的原始内容
|
|
384
|
-
|
|
385
316
|
## 原理
|
|
386
317
|
|
|
387
318
|
Provider 缓存通常依赖精确或近似精确的前缀匹配。Pi 的 system prompt 包含跨会话稳定的内容(工具定义、技能、规范),也包含每次变化的动态内容(git status、当前任务)。
|
package/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
@@ -135,6 +136,19 @@ type PersistedCacheStatsV3 = {
|
|
|
135
136
|
legacyFamily: Partial<Record<CacheProviderId, CacheStats>>;
|
|
136
137
|
};
|
|
137
138
|
|
|
139
|
+
/**
|
|
140
|
+
* V4 format: session-scoped stats buckets.
|
|
141
|
+
* Each Pi process/session gets its own stats isolated by a hashed session id.
|
|
142
|
+
*
|
|
143
|
+
* sessions: sessionHash → modelKey (provider/id) → CacheStats
|
|
144
|
+
* legacyFamily: unchanged from v3 (migration/fallback when ctx.model is unknown)
|
|
145
|
+
*/
|
|
146
|
+
type PersistedCacheStatsV4 = {
|
|
147
|
+
version: 4;
|
|
148
|
+
sessions: Record<string, Record<string, CacheStats>>;
|
|
149
|
+
legacyFamily: Partial<Record<CacheProviderId, CacheStats>>;
|
|
150
|
+
};
|
|
151
|
+
|
|
138
152
|
type UsageSnapshot = {
|
|
139
153
|
cacheRead: number;
|
|
140
154
|
cacheWrite: number;
|
|
@@ -562,6 +576,32 @@ function getSessionPromptCacheKey(ctx: ExtensionContext): string | undefined {
|
|
|
562
576
|
return clampPromptCacheKey(ctx.sessionManager.getSessionId());
|
|
563
577
|
}
|
|
564
578
|
|
|
579
|
+
/**
|
|
580
|
+
* Hash a session id for use as a non-reversible opaque scope key.
|
|
581
|
+
* Returns a 16-character hex string (64 bits of SHA-256 digest prefix)
|
|
582
|
+
* suitable for scoping stats buckets without exposing the raw session id.
|
|
583
|
+
*/
|
|
584
|
+
function hashSessionId(sessionId: string): string {
|
|
585
|
+
return createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Build a session-scoped stats key from a session hash + provider/id.
|
|
590
|
+
* Pure function (no closure dependency) for use by tests and internals.
|
|
591
|
+
*/
|
|
592
|
+
function makeSessionModelKey(sessionHash: string, provider: string, id: string): string {
|
|
593
|
+
return `${sessionHash}:${provider}/${id}`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Extract the user-facing model key from a session-scoped key.
|
|
598
|
+
* "abc123:otokapi/gpt-5.5" → "otokapi/gpt-5.5"
|
|
599
|
+
*/
|
|
600
|
+
function modelKeyFromSessionKey(sessionModelKey: string): string {
|
|
601
|
+
const idx = sessionModelKey.indexOf(":");
|
|
602
|
+
return idx >= 0 ? sessionModelKey.slice(idx + 1) : sessionModelKey;
|
|
603
|
+
}
|
|
604
|
+
|
|
565
605
|
function asRecord(value: unknown): UnknownRecord | undefined {
|
|
566
606
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
567
607
|
return value as UnknownRecord;
|
|
@@ -2651,7 +2691,39 @@ function parsePersistedCacheStats(value: unknown): CacheStatsState | undefined {
|
|
|
2651
2691
|
const record = asRecord(value);
|
|
2652
2692
|
if (!record) return undefined;
|
|
2653
2693
|
|
|
2654
|
-
// version
|
|
2694
|
+
// version 4: session-scoped stats + legacy family fallback
|
|
2695
|
+
if (record.version === 4) {
|
|
2696
|
+
const legacyFamily: Partial<Record<CacheProviderId, CacheStats>> = {};
|
|
2697
|
+
const rawFamily = asRecord(record.legacyFamily);
|
|
2698
|
+
if (rawFamily) {
|
|
2699
|
+
for (const id of CACHE_PROVIDER_IDS) {
|
|
2700
|
+
const stats = parseCacheStats(rawFamily[id]);
|
|
2701
|
+
if (stats) legacyFamily[id] = stats;
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
// Collect all session entries into statsByModel with session-hash-prefixed keys
|
|
2706
|
+
// (e.g. "abc123:otokapi/gpt-5.5") so that writePersistedCacheStats can later
|
|
2707
|
+
// reconstruct individual sessions from the flat key format and other sessions'
|
|
2708
|
+
// data is not silently lost on round-trip.
|
|
2709
|
+
const statsByModel: Record<string, CacheStats> = {};
|
|
2710
|
+
const rawSessions = asRecord(record.sessions);
|
|
2711
|
+
if (rawSessions) {
|
|
2712
|
+
for (const [sessionHash, modelMap] of Object.entries(rawSessions)) {
|
|
2713
|
+
const parsedMap = asRecord(modelMap);
|
|
2714
|
+
if (parsedMap) {
|
|
2715
|
+
for (const [modelKey, val] of Object.entries(parsedMap)) {
|
|
2716
|
+
const parsed = parseCacheStats(val);
|
|
2717
|
+
if (parsed) statsByModel[`${sessionHash}:${modelKey}`] = parsed;
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
return { statsByModel, legacyFamily };
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
// version 3: migrate to v4 semantics by wrapping statsByModel into sessions
|
|
2655
2727
|
if (record.version === 3) {
|
|
2656
2728
|
const statsByModel: Record<string, CacheStats> = {};
|
|
2657
2729
|
const rawModelMap = asRecord(record.statsByModel);
|
|
@@ -2736,11 +2808,122 @@ async function readPersistedCacheStats(): Promise<CacheStatsState | undefined> {
|
|
|
2736
2808
|
return undefined;
|
|
2737
2809
|
}
|
|
2738
2810
|
|
|
2739
|
-
|
|
2811
|
+
/**
|
|
2812
|
+
* The closure-internal writer. Since the closure has access to currentSessionHash,
|
|
2813
|
+
* it passes the hash and statsByModel here. This function wraps them in the v4
|
|
2814
|
+
* sessions format, combining with any previously-persisted sessions for safety.
|
|
2815
|
+
*
|
|
2816
|
+
* When called from the closure, `state.statsByModel` contains only the current
|
|
2817
|
+
* session's entries (keyed by `${sessionHash}:${provider}/${id}`). We extract
|
|
2818
|
+
* the model-key-only entries and store them under the session hash.
|
|
2819
|
+
*/
|
|
2820
|
+
/**
|
|
2821
|
+
* Merge in-memory stats state into an existing sessions map for persistence.
|
|
2822
|
+
*
|
|
2823
|
+
* When `currentSessionHash` is provided (explicit hash mode):
|
|
2824
|
+
* - Current-session entries are extracted from `state.statsByModel` (keys
|
|
2825
|
+
* prefixed with `currentSessionHash:`) and written under the session hash.
|
|
2826
|
+
* - The transitional legacy `_nosession` bucket is DELETED — its entries
|
|
2827
|
+
* were already consumed and migrated into memory by `restoreCacheStats`.
|
|
2828
|
+
* Keeping `_nosession` on disk would allow resurrection of reset stats
|
|
2829
|
+
* on the next reload (the reset-undo bug).
|
|
2830
|
+
* - Other real session hashes are preserved intact.
|
|
2831
|
+
*
|
|
2832
|
+
* When `currentSessionHash` is undefined (no-hash mode):
|
|
2833
|
+
* - Keys with a hash prefix (`hash:provider/model`) are grouped under their
|
|
2834
|
+
* respective session hashes.
|
|
2835
|
+
* - Keys without a hash prefix (legacy v3) are grouped under `_nosession` so
|
|
2836
|
+
* `restoreCacheStats` can migrate them on the next load before the session
|
|
2837
|
+
* id is known.
|
|
2838
|
+
*
|
|
2839
|
+
* Pure function (no I/O) — suitable for unit tests without touching the real
|
|
2840
|
+
* state file at `~/.pi/agent/pi-cache-optimizer-stats.json`.
|
|
2841
|
+
*/
|
|
2842
|
+
function mergeCacheSessions(
|
|
2843
|
+
existingSessions: Record<string, Record<string, CacheStats>>,
|
|
2844
|
+
state: CacheStatsState,
|
|
2845
|
+
currentSessionHash?: string,
|
|
2846
|
+
): Record<string, Record<string, CacheStats>> {
|
|
2847
|
+
// Deep-copy to avoid mutating the caller's object.
|
|
2848
|
+
const sessions: Record<string, Record<string, CacheStats>> = {};
|
|
2849
|
+
for (const [hash, models] of Object.entries(existingSessions)) {
|
|
2850
|
+
sessions[hash] = { ...models };
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
if (currentSessionHash !== undefined) {
|
|
2854
|
+
// Explicit hash mode: extract this session's data from state.statsByModel.
|
|
2855
|
+
// When the session has no entries (e.g. after reset of sole bucket), this
|
|
2856
|
+
// still sets an empty map, ensuring the deleted bucket does not return.
|
|
2857
|
+
const prefix = `${currentSessionHash}:`;
|
|
2858
|
+
const currentModelStats: Record<string, CacheStats> = {};
|
|
2859
|
+
for (const [fullKey, stats] of Object.entries(state.statsByModel)) {
|
|
2860
|
+
if (fullKey.startsWith(prefix)) {
|
|
2861
|
+
currentModelStats[fullKey.slice(prefix.length)] = stats;
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
sessions[currentSessionHash] = currentModelStats;
|
|
2865
|
+
|
|
2866
|
+
// _nosession is a transitional legacy migration bucket — once we write
|
|
2867
|
+
// under an authoritative session hash, those entries have already been
|
|
2868
|
+
// consumed and migrated into memory by restoreCacheStats. Delete to
|
|
2869
|
+
// prevent resurrection of reset stats on the next reload.
|
|
2870
|
+
delete sessions["_nosession"];
|
|
2871
|
+
} else {
|
|
2872
|
+
// No-hash mode: group entries by their existing hash prefix to avoid
|
|
2873
|
+
// collapsing multiple sessions into one bucket. Keys without a hash
|
|
2874
|
+
// prefix (legacy v3) go under "_nosession" so restoreCacheStats can
|
|
2875
|
+
// migrate them to the current session on next load.
|
|
2876
|
+
const nosessionMap: Record<string, CacheStats> = {};
|
|
2877
|
+
for (const [fullKey, stats] of Object.entries(state.statsByModel)) {
|
|
2878
|
+
const idx = fullKey.indexOf(":");
|
|
2879
|
+
if (idx >= 0) {
|
|
2880
|
+
const hash = fullKey.slice(0, idx);
|
|
2881
|
+
const modelKey = fullKey.slice(idx + 1);
|
|
2882
|
+
if (!sessions[hash]) sessions[hash] = {};
|
|
2883
|
+
sessions[hash][modelKey] = stats;
|
|
2884
|
+
} else {
|
|
2885
|
+
// Key without hash prefix (legacy v3) — group under _nosession.
|
|
2886
|
+
nosessionMap[fullKey] = stats;
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
if (Object.keys(nosessionMap).length > 0) {
|
|
2890
|
+
sessions["_nosession"] = nosessionMap;
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
return sessions;
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
async function writePersistedCacheStats(state: CacheStatsState, currentSessionHash?: string): Promise<void> {
|
|
2740
2898
|
await mkdir(STATE_DIR, { recursive: true });
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2899
|
+
|
|
2900
|
+
// Read existing file to preserve other sessions' data.
|
|
2901
|
+
let existingSessions: Record<string, Record<string, CacheStats>> = {};
|
|
2902
|
+
try {
|
|
2903
|
+
const raw = await readFile(STATE_FILE_PATH, "utf8");
|
|
2904
|
+
const parsed = parsePersistedCacheStats(JSON.parse(raw));
|
|
2905
|
+
if (parsed) {
|
|
2906
|
+
// Reconstruct sessions from statsByModel keys.
|
|
2907
|
+
// Each key has form `${hash}:${provider}/${id}`; group by hash.
|
|
2908
|
+
for (const [fullKey, stats] of Object.entries(parsed.statsByModel)) {
|
|
2909
|
+
const idx = fullKey.indexOf(":");
|
|
2910
|
+
if (idx >= 0) {
|
|
2911
|
+
const hash = fullKey.slice(0, idx);
|
|
2912
|
+
const modelKey = fullKey.slice(idx + 1);
|
|
2913
|
+
if (!existingSessions[hash]) existingSessions[hash] = {};
|
|
2914
|
+
existingSessions[hash][modelKey] = stats;
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
} catch {
|
|
2919
|
+
// Ignore read errors (file may not exist yet).
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
const sessions = mergeCacheSessions(existingSessions, state, currentSessionHash);
|
|
2923
|
+
|
|
2924
|
+
const payload: PersistedCacheStatsV4 = {
|
|
2925
|
+
version: 4,
|
|
2926
|
+
sessions,
|
|
2744
2927
|
legacyFamily: state.legacyFamily,
|
|
2745
2928
|
};
|
|
2746
2929
|
const tempPath = `${STATE_FILE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
|
@@ -3294,6 +3477,17 @@ export const __internals_for_tests = {
|
|
|
3294
3477
|
formatTokenM,
|
|
3295
3478
|
hasMissingUsageFields,
|
|
3296
3479
|
keyForModelExt,
|
|
3480
|
+
// Session-scoped helpers
|
|
3481
|
+
hashSessionId,
|
|
3482
|
+
makeSessionModelKey,
|
|
3483
|
+
modelKeyFromSessionKey,
|
|
3484
|
+
// Persistence helpers (for reload/reset tests)
|
|
3485
|
+
mergeCacheSessions,
|
|
3486
|
+
writePersistedCacheStats,
|
|
3487
|
+
readPersistedCacheStats,
|
|
3488
|
+
STATE_FILE_PATH,
|
|
3489
|
+
LEGACY_STATE_FILE_PATH,
|
|
3490
|
+
STATE_DIR,
|
|
3297
3491
|
};
|
|
3298
3492
|
|
|
3299
3493
|
export default function (pi: ExtensionAPI) {
|
|
@@ -3304,10 +3498,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
3304
3498
|
let persistenceWarningShown = false;
|
|
3305
3499
|
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
3306
3500
|
let integrityNotificationShown = false;
|
|
3501
|
+
let currentSessionId = "";
|
|
3502
|
+
let currentSessionHash = "";
|
|
3503
|
+
let currentSessionHashSet = false;
|
|
3307
3504
|
const PERSIST_DEBOUNCE_MS = 2000;
|
|
3308
3505
|
/** In-memory recent usage samples per model key (not persisted, cleared on reload). */
|
|
3309
3506
|
const recentSamplesByModelKey = new Map<string, CacheUsageSample[]>();
|
|
3310
3507
|
|
|
3508
|
+
/**
|
|
3509
|
+
* Build a session-scoped stats key from the current session hash + model key.
|
|
3510
|
+
* Returns `${sessionHash}:${provider}/${id}`.
|
|
3511
|
+
*/
|
|
3512
|
+
function sessionModelKey(model: { provider: string; id: string }): string {
|
|
3513
|
+
const hash = currentSessionHash || "_nosession";
|
|
3514
|
+
return `${hash}:${model.provider}/${model.id}`;
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
/**
|
|
3518
|
+
* Extract the user-facing model key from a session-scoped key.
|
|
3519
|
+
* "abc123:otokapi/gpt-5.5" → "otokapi/gpt-5.5"
|
|
3520
|
+
*/
|
|
3521
|
+
function modelKeyFromSessionScoped(sKey: string): string {
|
|
3522
|
+
const idx = sKey.indexOf(":");
|
|
3523
|
+
return idx >= 0 ? sKey.slice(idx + 1) : sKey;
|
|
3524
|
+
}
|
|
3525
|
+
|
|
3311
3526
|
function recordRecentSample(modelKeyStr: string, usage: UsageSnapshot, missingUsageFields: boolean): void {
|
|
3312
3527
|
let samples = recentSamplesByModelKey.get(modelKeyStr);
|
|
3313
3528
|
if (!samples) {
|
|
@@ -3342,7 +3557,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3342
3557
|
/** Look up active stats for a model, falling back to legacy family. */
|
|
3343
3558
|
function getStatsForModel(model: PiModel | undefined, adapter: CacheProviderAdapter): CacheStats {
|
|
3344
3559
|
if (model) {
|
|
3345
|
-
const key =
|
|
3560
|
+
const key = sessionModelKey(model);
|
|
3346
3561
|
const existing = cacheStatsByModel[key];
|
|
3347
3562
|
if (existing) return existing;
|
|
3348
3563
|
}
|
|
@@ -3369,7 +3584,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3369
3584
|
|
|
3370
3585
|
async function persistCacheStats(ctx?: ExtensionContext): Promise<void> {
|
|
3371
3586
|
try {
|
|
3372
|
-
await writePersistedCacheStats(getCacheStatsState());
|
|
3587
|
+
await writePersistedCacheStats(getCacheStatsState(), currentSessionHashSet ? currentSessionHash : undefined);
|
|
3373
3588
|
} catch (error) {
|
|
3374
3589
|
console.warn(`${LOG_PREFIX}: failed to persist cache stats`, error);
|
|
3375
3590
|
if (!persistenceWarningShown) {
|
|
@@ -3435,20 +3650,80 @@ export default function (pi: ExtensionAPI) {
|
|
|
3435
3650
|
}
|
|
3436
3651
|
|
|
3437
3652
|
async function restoreCacheStats(reason: string, ctx: ExtensionContext): Promise<void> {
|
|
3653
|
+
// Set session id on first load and on reload (same session).
|
|
3654
|
+
const sid = ctx.sessionManager.getSessionId();
|
|
3655
|
+
if (sid && (sid !== currentSessionId || !currentSessionHashSet)) {
|
|
3656
|
+
currentSessionId = sid;
|
|
3657
|
+
currentSessionHash = hashSessionId(sid);
|
|
3658
|
+
currentSessionHashSet = true;
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3438
3661
|
if (reason === "reload") {
|
|
3439
|
-
|
|
3440
|
-
|
|
3662
|
+
// /reload: preserve session-scoped stats (same session hash).
|
|
3663
|
+
// Pi extension reload creates a fresh closure, so cacheStatsByModel
|
|
3664
|
+
// starts empty. Read persisted data and filter for current session.
|
|
3441
3665
|
lastStatusText = undefined;
|
|
3442
|
-
// Reset integrity diagnostics on reload
|
|
3443
3666
|
lastPromptIntegrityWarningAt = 0;
|
|
3444
3667
|
integrityNotificationShown = false;
|
|
3445
3668
|
clearRecentSamples();
|
|
3446
|
-
|
|
3669
|
+
|
|
3670
|
+
const persisted = await readPersistedCacheStats();
|
|
3671
|
+
if (persisted && currentSessionHash) {
|
|
3672
|
+
const prefix = `${currentSessionHash}:`;
|
|
3673
|
+
const filteredModelStats: Record<string, CacheStats> = {};
|
|
3674
|
+
for (const [fullKey, stats] of Object.entries(persisted.statsByModel)) {
|
|
3675
|
+
if (fullKey.startsWith(prefix)) {
|
|
3676
|
+
// Current session's data
|
|
3677
|
+
filteredModelStats[fullKey] = stats;
|
|
3678
|
+
} else if (!fullKey.includes(":")) {
|
|
3679
|
+
// Legacy v3-style key without session hash — migrate to current session
|
|
3680
|
+
filteredModelStats[`${currentSessionHash}:${fullKey}`] = stats;
|
|
3681
|
+
} else if (fullKey.startsWith("_nosession:")) {
|
|
3682
|
+
// _nosession migration remnant from old-path v4 write — migrate to current session
|
|
3683
|
+
filteredModelStats[`${currentSessionHash}:${fullKey.slice("_nosession:".length)}`] = stats;
|
|
3684
|
+
}
|
|
3685
|
+
}
|
|
3686
|
+
cacheStatsByModel = filteredModelStats;
|
|
3687
|
+
cacheStatsLegacyFamily = persisted.legacyFamily;
|
|
3688
|
+
} else if (persisted) {
|
|
3689
|
+
cacheStatsByModel = persisted.statsByModel;
|
|
3690
|
+
cacheStatsLegacyFamily = persisted.legacyFamily;
|
|
3691
|
+
} else {
|
|
3692
|
+
cacheStatsByModel = {};
|
|
3693
|
+
cacheStatsLegacyFamily = emptyAllCacheStats();
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
await rollOverStatsIfNeeded(ctx);
|
|
3447
3697
|
return;
|
|
3448
3698
|
}
|
|
3449
3699
|
|
|
3700
|
+
// First load / process start: read persisted stats and filter for
|
|
3701
|
+
// this session's entries. If the session has no persisted data yet,
|
|
3702
|
+
// start fresh.
|
|
3450
3703
|
const persisted = await readPersistedCacheStats();
|
|
3451
|
-
if (persisted) {
|
|
3704
|
+
if (persisted && currentSessionHash) {
|
|
3705
|
+
const prefix = `${currentSessionHash}:`;
|
|
3706
|
+
const filteredModelStats: Record<string, CacheStats> = {};
|
|
3707
|
+
for (const [fullKey, stats] of Object.entries(persisted.statsByModel)) {
|
|
3708
|
+
if (fullKey.startsWith(prefix)) {
|
|
3709
|
+
// Current session's data — load it.
|
|
3710
|
+
filteredModelStats[fullKey] = stats;
|
|
3711
|
+
} else if (!fullKey.includes(":")) {
|
|
3712
|
+
// Legacy v3-style key without session hash (e.g. "otokapi/gpt-5.5").
|
|
3713
|
+
// Migrate to current session by prefixing with the session hash.
|
|
3714
|
+
filteredModelStats[`${currentSessionHash}:${fullKey}`] = stats;
|
|
3715
|
+
} else if (fullKey.startsWith("_nosession:")) {
|
|
3716
|
+
// _nosession migration remnant from old-path v4 write — migrate to current session
|
|
3717
|
+
filteredModelStats[`${currentSessionHash}:${fullKey.slice("_nosession:".length)}`] = stats;
|
|
3718
|
+
}
|
|
3719
|
+
// Other sessions' entries are preserved in the file but not loaded
|
|
3720
|
+
// into memory; they'll be rewritten on next persist.
|
|
3721
|
+
}
|
|
3722
|
+
cacheStatsByModel = filteredModelStats;
|
|
3723
|
+
cacheStatsLegacyFamily = persisted.legacyFamily;
|
|
3724
|
+
} else if (persisted) {
|
|
3725
|
+
// Persisted data exists but no session hash set yet.
|
|
3726
|
+
// This shouldn't normally happen — use the data as-is.
|
|
3452
3727
|
cacheStatsByModel = persisted.statsByModel;
|
|
3453
3728
|
cacheStatsLegacyFamily = persisted.legacyFamily;
|
|
3454
3729
|
} else {
|
|
@@ -3465,13 +3740,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
3465
3740
|
const adapter = selectAdapterForModel(model);
|
|
3466
3741
|
let statusText: string | undefined;
|
|
3467
3742
|
if (adapter) {
|
|
3468
|
-
// Display
|
|
3469
|
-
//
|
|
3470
|
-
//
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
const key = model ? modelKey(model) : undefined;
|
|
3474
|
-
const stats = key ? cacheStatsByModel[key] : undefined;
|
|
3743
|
+
// Display session-scoped stats. A model that has never been used
|
|
3744
|
+
// in this session shows 0/0. The message_end hook populates
|
|
3745
|
+
// cacheStatsByModel[sessionModelKey(model)] on first use.
|
|
3746
|
+
const sk = model ? sessionModelKey(model) : undefined;
|
|
3747
|
+
const stats = sk ? cacheStatsByModel[sk] : undefined;
|
|
3475
3748
|
statusText = formatCacheStats(adapter, stats ?? emptyCacheStats());
|
|
3476
3749
|
}
|
|
3477
3750
|
|
|
@@ -3629,22 +3902,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
3629
3902
|
|
|
3630
3903
|
// Record recent sample (even when usage is missing, for trend diagnosis)
|
|
3631
3904
|
if (ctx.model) {
|
|
3632
|
-
const
|
|
3905
|
+
const sk = sessionModelKey(ctx.model);
|
|
3633
3906
|
const missingFields = usage === undefined || (usage.cacheRead === 0 && usage.cacheWrite === 0 && usage.totalInput === 0)
|
|
3634
3907
|
? true
|
|
3635
3908
|
: hasMissingUsageFields(event.message, adapter);
|
|
3636
|
-
recordRecentSample(
|
|
3909
|
+
recordRecentSample(sk, usage ?? { cacheRead: 0, cacheWrite: 0, totalInput: 0 }, missingFields);
|
|
3637
3910
|
}
|
|
3638
3911
|
|
|
3639
3912
|
if (!usage) return;
|
|
3640
3913
|
|
|
3641
3914
|
await rollOverStatsIfNeeded(ctx);
|
|
3642
3915
|
|
|
3643
|
-
// Update stats scoped to
|
|
3916
|
+
// Update stats scoped to current session + active model.
|
|
3644
3917
|
// Falls back to legacy family when ctx.model is undefined.
|
|
3645
3918
|
if (ctx.model) {
|
|
3646
|
-
const
|
|
3647
|
-
addUsageToCacheStats(getOrCreateStatsByModelKey(
|
|
3919
|
+
const sk = sessionModelKey(ctx.model);
|
|
3920
|
+
addUsageToCacheStats(getOrCreateStatsByModelKey(sk), usage);
|
|
3648
3921
|
} else {
|
|
3649
3922
|
addUsageToCacheStats(getStatsForModel(undefined, adapter), usage);
|
|
3650
3923
|
}
|
|
@@ -3660,6 +3933,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3660
3933
|
// with low-hit diagnosis
|
|
3661
3934
|
// stats — show active model stats bucket, recent trend, usage
|
|
3662
3935
|
// compat — show compat suggestion with file path
|
|
3936
|
+
// reset — reset current session model stats bucket (local only)
|
|
3663
3937
|
// (no args) — interactive menu (with UI) or help summary
|
|
3664
3938
|
// ────────────────────────────────────────────────────────────────
|
|
3665
3939
|
pi.registerCommand("cache-optimizer", {
|
|
@@ -3675,8 +3949,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
3675
3949
|
}
|
|
3676
3950
|
const diagnosis = buildDoctorDiagnosis(model);
|
|
3677
3951
|
const adapter = selectAdapterForModel(model);
|
|
3678
|
-
const
|
|
3679
|
-
const
|
|
3952
|
+
const sk = model ? sessionModelKey(model) : undefined;
|
|
3953
|
+
const statsState = sk ? cacheStatsByModel[sk] : undefined;
|
|
3954
|
+
const samples = sk ? getRecentSamples(sk) : [];
|
|
3680
3955
|
const lowHitLines = buildLowHitDiagnosis(model, adapter, statsState, samples);
|
|
3681
3956
|
const fullDiagnosis = lowHitLines.length > 0
|
|
3682
3957
|
? diagnosis + "\n" + lowHitLines.join("\n")
|
|
@@ -3688,9 +3963,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
3688
3963
|
return;
|
|
3689
3964
|
}
|
|
3690
3965
|
const adapter = selectAdapterForModel(model);
|
|
3691
|
-
const
|
|
3692
|
-
const statsState =
|
|
3693
|
-
const samples =
|
|
3966
|
+
const sk = model ? sessionModelKey(model) : undefined;
|
|
3967
|
+
const statsState = sk ? cacheStatsByModel[sk] : undefined;
|
|
3968
|
+
const samples = sk ? getRecentSamples(sk) : [];
|
|
3694
3969
|
const output = buildStatsOutput(model, adapter, statsState, samples);
|
|
3695
3970
|
cmdCtx.ui.notify(output, "info");
|
|
3696
3971
|
} else if (subcommand === "compat") {
|
|
@@ -3709,6 +3984,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
3709
3984
|
"info",
|
|
3710
3985
|
);
|
|
3711
3986
|
}
|
|
3987
|
+
} else if (subcommand === "reset") {
|
|
3988
|
+
if (!model) {
|
|
3989
|
+
cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
|
|
3990
|
+
return;
|
|
3991
|
+
}
|
|
3992
|
+
const adapter = selectAdapterForModel(model);
|
|
3993
|
+
if (!adapter) {
|
|
3994
|
+
cmdCtx.ui.notify("ℹ️ Active model does not match a cache adapter. No stats to reset.", "info");
|
|
3995
|
+
return;
|
|
3996
|
+
}
|
|
3997
|
+
|
|
3998
|
+
const sk = sessionModelKey(model);
|
|
3999
|
+
const displayKey = modelKey(model);
|
|
4000
|
+
|
|
4001
|
+
// Reset session-scoped stats for the active model.
|
|
4002
|
+
delete cacheStatsByModel[sk];
|
|
4003
|
+
|
|
4004
|
+
// Clear recent samples for this session+model key.
|
|
4005
|
+
recentSamplesByModelKey.delete(sk);
|
|
4006
|
+
|
|
4007
|
+
// Persist immediately.
|
|
4008
|
+
await flushPersistCacheStats(cmdCtx as unknown as ExtensionContext);
|
|
4009
|
+
|
|
4010
|
+
// Update footer to show 0/0.
|
|
4011
|
+
await publishStatus(cmdCtx as unknown as ExtensionContext, model);
|
|
4012
|
+
|
|
4013
|
+
cmdCtx.ui.notify(
|
|
4014
|
+
`✅ Reset local session cache stats for "${displayKey}". ` +
|
|
4015
|
+
"Upstream provider prompt cache was not modified. " +
|
|
4016
|
+
"New requests will start a fresh stats bucket for this Pi session.",
|
|
4017
|
+
"info",
|
|
4018
|
+
);
|
|
3712
4019
|
} else {
|
|
3713
4020
|
// Try interactive selection menu when UI supports it
|
|
3714
4021
|
if (cmdCtx.hasUI) {
|
|
@@ -3716,6 +4023,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3716
4023
|
"🩺 Doctor — Show current model cache configuration",
|
|
3717
4024
|
"📊 Stats — Show active model stats bucket and trend",
|
|
3718
4025
|
"⚙️ Compat — Show compat suggestion with edit instructions",
|
|
4026
|
+
"🔄 Reset — Reset local session stats for current model",
|
|
3719
4027
|
"❌ Cancel",
|
|
3720
4028
|
];
|
|
3721
4029
|
const choice = await cmdCtx.ui.select("Cache Optimizer", menuOptions);
|
|
@@ -3725,8 +4033,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
3725
4033
|
} else {
|
|
3726
4034
|
const diagnosis = buildDoctorDiagnosis(model);
|
|
3727
4035
|
const adapter = selectAdapterForModel(model);
|
|
3728
|
-
const
|
|
3729
|
-
const
|
|
4036
|
+
const sk = model ? sessionModelKey(model) : undefined;
|
|
4037
|
+
const statsState = sk ? cacheStatsByModel[sk] : undefined;
|
|
4038
|
+
const samples = sk ? getRecentSamples(sk) : [];
|
|
3730
4039
|
const lowHitLines = buildLowHitDiagnosis(model, adapter, statsState, samples);
|
|
3731
4040
|
const fullDiagnosis = lowHitLines.length > 0
|
|
3732
4041
|
? diagnosis + "\n" + lowHitLines.join("\n")
|
|
@@ -3738,9 +4047,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
3738
4047
|
cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
|
|
3739
4048
|
} else {
|
|
3740
4049
|
const adapter = selectAdapterForModel(model);
|
|
3741
|
-
const
|
|
3742
|
-
const statsState =
|
|
3743
|
-
const samples =
|
|
4050
|
+
const sk = model ? sessionModelKey(model) : undefined;
|
|
4051
|
+
const statsState = sk ? cacheStatsByModel[sk] : undefined;
|
|
4052
|
+
const samples = sk ? getRecentSamples(sk) : [];
|
|
3744
4053
|
const output = buildStatsOutput(model, adapter, statsState, samples);
|
|
3745
4054
|
cmdCtx.ui.notify(output, "info");
|
|
3746
4055
|
}
|
|
@@ -3760,6 +4069,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
3760
4069
|
);
|
|
3761
4070
|
}
|
|
3762
4071
|
}
|
|
4072
|
+
} else if (choice === menuOptions[3]) {
|
|
4073
|
+
if (!model) {
|
|
4074
|
+
cmdCtx.ui.notify("No active model selected. Select a model first with /model or pi --model.", "warning");
|
|
4075
|
+
} else {
|
|
4076
|
+
const adapter = selectAdapterForModel(model);
|
|
4077
|
+
if (!adapter) {
|
|
4078
|
+
cmdCtx.ui.notify("ℹ️ Active model does not match a cache adapter. No stats to reset.", "info");
|
|
4079
|
+
} else {
|
|
4080
|
+
const sk = sessionModelKey(model);
|
|
4081
|
+
const displayKey = modelKey(model);
|
|
4082
|
+
delete cacheStatsByModel[sk];
|
|
4083
|
+
recentSamplesByModelKey.delete(sk);
|
|
4084
|
+
await flushPersistCacheStats(cmdCtx as unknown as ExtensionContext);
|
|
4085
|
+
await publishStatus(cmdCtx as unknown as ExtensionContext, model);
|
|
4086
|
+
cmdCtx.ui.notify(
|
|
4087
|
+
`✅ Reset local session cache stats for "${displayKey}". ` +
|
|
4088
|
+
"Upstream provider prompt cache was not modified.",
|
|
4089
|
+
"info",
|
|
4090
|
+
);
|
|
4091
|
+
}
|
|
4092
|
+
}
|
|
3763
4093
|
}
|
|
3764
4094
|
// choice === "cancel" or undefined → no action
|
|
3765
4095
|
return;
|
|
@@ -3771,16 +4101,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
3771
4101
|
diagnosis.push(" doctor — Show current model/provider/api/baseUrl/compat and low-hit diagnosis");
|
|
3772
4102
|
diagnosis.push(" stats — Show active model stats bucket and recent trend");
|
|
3773
4103
|
diagnosis.push(" compat — Show compat suggestion with edit location");
|
|
4104
|
+
diagnosis.push(" reset — Reset local session stats for current model (does not affect upstream)");
|
|
3774
4105
|
diagnosis.push("");
|
|
3775
4106
|
if (model) {
|
|
4107
|
+
const displayKey = modelKey(model);
|
|
3776
4108
|
const missing = describeMissingOpenAICompatibleProxyCompat(model);
|
|
3777
4109
|
if (missing.length > 0) {
|
|
3778
|
-
diagnosis.push(`⚠️ Active model "${
|
|
4110
|
+
diagnosis.push(`⚠️ Active model "${displayKey}" missing compat: ${missing.join(", ")}`);
|
|
3779
4111
|
diagnosis.push('Run "/cache-optimizer compat" for edit instructions.');
|
|
3780
4112
|
} else if (isCompatCheckApplicable(model)) {
|
|
3781
|
-
diagnosis.push(`✅ Active model "${
|
|
4113
|
+
diagnosis.push(`✅ Active model "${displayKey}": compat fully configured.`);
|
|
3782
4114
|
} else {
|
|
3783
|
-
diagnosis.push(`ℹ️ Active model "${
|
|
4115
|
+
diagnosis.push(`ℹ️ Active model "${displayKey}": compat check not applicable.`);
|
|
3784
4116
|
}
|
|
3785
4117
|
} else {
|
|
3786
4118
|
diagnosis.push("No active model selected.");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cache-optimizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "Pi extension that improves provider-side KV/prompt cache hit rates (DeepSeek, OpenAI, Claude, Gemini) by reordering the system prompt, requesting long retention, and showing footer cache stats. Renamed from pi-deepseek-cache-optimizer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|