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.
Files changed (4) hide show
  1. package/README.md +50 -24
  2. package/README.zh-CN.md +43 -112
  3. package/index.ts +369 -37
  4. 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
- Reset behavior:
258
-
259
- - Pi restarts do **not** clear stats; the persisted counters are restored.
260
- - `/reload` / extension reload resets the persisted counters because Pi exposes `session_start` with reason `reload`.
261
- - Crossing the local natural-day boundary resets counters on the next status update or supported-provider response.
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`). In
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
- - Pi 重启**不会**清零统计;扩展会恢复已持久化的计数器。
255
- - `/reload` / extension reload 会清零并覆盖持久化计数器,因为 Pi 会暴露 `session_start` 的 `reason: "reload"`。
256
- - 长时间运行跨过本地自然日时,会在下一次状态更新或受支持 provider 响应统计前自动按本地日期清零。
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 3: model-scoped stats + legacy family fallback
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
- async function writePersistedCacheStats(state: CacheStatsState): Promise<void> {
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
- const payload: PersistedCacheStatsV3 = {
2742
- version: 3,
2743
- statsByModel: state.statsByModel,
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 = modelKey(model);
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
- cacheStatsByModel = {};
3440
- cacheStatsLegacyFamily = emptyAllCacheStats();
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
- await flushPersistCacheStats(ctx);
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 only per-model scoped stats. A model that has never been
3469
- // used in this session shows 0/0 rather than falling back to legacy
3470
- // family aggregated stats (which could span different providers with
3471
- // the same model-family name). The message_end hook populates
3472
- // cacheStatsByModel[key] on first use with that model.
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 key = modelKey(ctx.model);
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(key, usage ?? { cacheRead: 0, cacheWrite: 0, totalInput: 0 }, missingFields);
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 the active model (provider/id key).
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 key = modelKey(ctx.model);
3647
- addUsageToCacheStats(getOrCreateStatsByModelKey(key), usage);
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 statsState = model ? cacheStatsByModel[modelKey(model)] : undefined;
3679
- const samples = model ? getRecentSamples(modelKey(model)) : [];
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 key = model ? modelKey(model) : undefined;
3692
- const statsState = key ? cacheStatsByModel[key] : undefined;
3693
- const samples = model ? getRecentSamples(modelKey(model)) : [];
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 statsState = model ? cacheStatsByModel[modelKey(model)] : undefined;
3729
- const samples = model ? getRecentSamples(modelKey(model)) : [];
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 key = model ? modelKey(model) : undefined;
3742
- const statsState = key ? cacheStatsByModel[key] : undefined;
3743
- const samples = model ? getRecentSamples(modelKey(model)) : [];
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 "${modelKey(model)}" missing compat: ${missing.join(", ")}`);
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 "${modelKey(model)}": compat fully configured.`);
4113
+ diagnosis.push(`✅ Active model "${displayKey}": compat fully configured.`);
3782
4114
  } else {
3783
- diagnosis.push(`ℹ️ Active model "${modelKey(model)}": compat check not applicable.`);
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.4.9",
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",