pi-diagnostics 0.2.0 → 0.3.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/README.md CHANGED
@@ -1,92 +1,26 @@
1
1
  # pi-diagnostics
2
2
 
3
- Pi extension for prompt-cache visibility:
3
+ Know why your prompt cache missed, and when it's gone.
4
4
 
5
- 1. **Provider diagnostics** — opts into Anthropic/OpenAI prompt-cache diagnostics and stores results in the session transcript.
6
- 2. **Expiry reminders** — adds a display-only transcript entry when the prompt cache likely expired, so you know it is a cheaper moment to `/compact`, switch model, or change tool/skill loadout.
5
+ - **Cache-miss reasons.** When a turn re-bills cached tokens, see the provider's explanation (Anthropic, OpenAI):
7
6
 
8
- ## Provider diagnostics
7
+ ```
8
+ Cache miss: 42k tokens re-billed (~$0.12)
9
9
 
10
- - **Anthropic Messages:** adds `diagnostics.previous_message_id` on every request. First request sends `null`; later requests reference preceding `responseId`.
11
- - **OpenAI Responses:** adds `prompt_cache_options.comparison_response_id` when a previous response exists.
12
- - Captures diagnostics from streaming `message_start` / `response.completed` events without changing response bytes.
13
- - Adds `anthropic_cache_diagnostics` / `openai_prompt_cache_diagnostics` to assistant messages.
14
- - Diagnostics notifications use `Cache miss, provider diagnostics reason: X`, without a `Warning:` prefix or token counts. `unavailable` warns only when cached tokens actually dropped versus previous turn (under half of previous prompt read from cache, more than 1,024 tokens lost).
15
- - Notices are saved as non-context session entries and rendered again on resume, reload, and transcript rebuilds, like Pi's native cache-miss notices. Automatic notices are recorded at turn end, after the assistant response. They never add model context or trigger another turn.
16
- - `/diagnostics` shows recent results and saves its output in session history.
10
+ ↳ Provider diagnostics reason: tools_changed
11
+ ```
17
12
 
18
- Provider diagnostics are free and best-effort. No prompt or output content is persisted by this extension. Provider fingerprints are handled under provider retention policies.
13
+ - **Expiry reminders.** A note appears when your cache has likely expired, so you know it's a cheap moment to `/compact`, switch models, or change tools.
19
14
 
20
- ## Expiry reminders
21
-
22
- - Uses `pi.appendEntry()`, not `sendMessage()`: `cache-expiry-reminder` entries are visible in the session but never sent to the model provider.
23
- - Accounts for `cacheWarming` (`off`, `streaming`, `idle`), Pi's 30-minute idle-warming limit, warming usage entries, replayability limits, model-declared `promptCache` TTLs, `PI_CACHE_RETENTION=long`, explicit provider payload retention, compaction, and system/tool-loadout changes.
24
- - Falls back to provider-family TTL estimates when model metadata omits `promptCache`: Anthropic/Gemini 5m; OpenAI ~30m over Responses, ~40m over Codex (observed from local session data). Routed Claude/Gemini keep family defaults. Explicit model/payload TTLs win.
25
- - Follows cache protocol, not model family: Anthropic Messages forks reuse matching prefixes; OpenAI Responses uses Pi's session-derived `prompt_cache_key`, so `/fork` starts a new cache namespace and the reminder waits for the fork's first request.
26
- - Requests on other branches below the last request keep the shared prefix warm and postpone the reminder.
27
- - One reminder per cache touch. Collapsed, the reminder is one line; press `ctrl+o` (`app.tools.expand`) for model, cache timing, context size, and warming status.
28
- - Always says "may have expired": provider TTLs and eviction are best-effort.
29
-
30
- Trace scheduling decisions:
31
-
32
- ```bash
33
- export PI_EXPIRY_REMINDER_DEBUG=/tmp/expiry-reminder.log
34
- ```
15
+ Both are display-only and never add to model context.
35
16
 
36
17
  ## Install
37
18
 
38
- Requires [Pi](https://pi.dev) and Node.js 22.19 or newer.
39
-
40
19
  ```bash
41
20
  pi install npm:pi-diagnostics
42
21
  ```
43
22
 
44
- Restart Pi or run `/reload` to load the extension.
45
-
46
- Or add the npm source to `~/.pi/agent/settings.json`:
47
-
48
- ```json
49
- {
50
- "packages": ["npm:pi-diagnostics"]
51
- }
52
- ```
53
-
54
- ## Configuration
55
-
56
- Defaults enable providers named `anthropic`, `openai`, and `ramp-router`. This covers Ramp Router's OpenAI Responses adapter while avoiding unrelated proxy routes that may reject provider-specific fields.
57
-
58
- ```bash
59
- # Enable every supported API route, including other routers/proxies.
60
- export PI_DIAGNOSTICS_PROVIDERS='*'
61
-
62
- # Only enable one provider.
63
- export PI_DIAGNOSTICS_PROVIDERS='openai'
64
-
65
- # Notices: miss (default), all, or off. Raw diagnostics remain on assistant messages with off.
66
- # Selected notices persist even in non-interactive sessions; RPC also receives a UI notification.
67
- export PI_DIAGNOSTICS_NOTIFY=all
68
-
69
- # Disable extension behavior without removing package.
70
- export PI_DIAGNOSTICS=0
71
- ```
72
-
73
- Anthropic diagnostics require direct Claude API support. OpenAI diagnostics require Responses API models that support prompt-cache diagnostics (GPT-5.6+ per OpenAI docs). Unsupported routes should be excluded from `PI_DIAGNOSTICS_PROVIDERS`.
74
-
75
- ## Development
76
-
77
- ```bash
78
- git clone https://github.com/tianrendong/pi-diagnostics.git
79
- cd pi-diagnostics
80
- npm ci --ignore-scripts
81
- npm run check
82
- npm test
83
- ```
84
-
85
- Test the local extension without installing the package:
86
-
87
- ```bash
88
- pi --extension ./src/index.ts --extension ./src/expiry.ts
89
- ```
23
+ Run `/diagnostics` to see recent results.
90
24
 
91
25
  ## License
92
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-diagnostics",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Prompt-cache diagnostics and expiry reminders for pi",
5
5
  "keywords": [
6
6
  "pi",
package/src/core.ts CHANGED
@@ -27,7 +27,12 @@ export const API_KIND: Record<string, DiagnosticsKind> = {
27
27
  "openai-codex-responses": "openai",
28
28
  };
29
29
 
30
- export const DEFAULT_PROVIDERS = ["anthropic", "openai", "ramp-router"];
30
+ /**
31
+ * Provider entries are `provider` (every supported API) or `provider:kind` (one dialect).
32
+ * Ramp Router is limited to its OpenAI Responses adapter: its Anthropic Messages route returns
33
+ * `diagnostics: null` for every response, even for requests whose tools and system prompt changed.
34
+ */
35
+ export const DEFAULT_PROVIDERS = ["anthropic", "openai", "ramp-router:openai"];
31
36
 
32
37
  export interface ModelRef {
33
38
  provider: string;
@@ -37,7 +42,10 @@ export interface ModelRef {
37
42
 
38
43
  export interface Config {
39
44
  enabled: boolean;
40
- /** Provider names allowed to receive diagnostics fields. "*" allows every provider on a supported API. */
45
+ /**
46
+ * Provider entries allowed to receive diagnostics fields: `provider`, `provider:anthropic`,
47
+ * `provider:openai`, or "*" for every provider on a supported API.
48
+ */
41
49
  providers: string[];
42
50
  /** When to show a UI notification. */
43
51
  notify: "miss" | "all" | "off";
@@ -60,7 +68,9 @@ export function kindFor(model: ModelRef | undefined, config: Config): Diagnostic
60
68
  if (!config.enabled || !model) return undefined;
61
69
  const kind = API_KIND[model.api];
62
70
  if (!kind) return undefined;
63
- const allowed = config.providers.includes("*") || config.providers.includes(model.provider);
71
+ const allowed = config.providers.includes("*")
72
+ || config.providers.includes(model.provider)
73
+ || config.providers.includes(`${model.provider}:${kind}`);
64
74
  return allowed ? kind : undefined;
65
75
  }
66
76
 
@@ -189,7 +199,10 @@ export function sniffBody(body: unknown): Probe | undefined {
189
199
  export interface RawResult {
190
200
  kind: DiagnosticsKind;
191
201
  responseId: string;
192
- /** Raw provider diagnostics value (null when the provider reported none). */
202
+ /**
203
+ * Raw provider diagnostics value. `undefined` means the response had no diagnostics field at
204
+ * all (e.g. a proxy dropped it), which is different from Anthropic's explicit `null`.
205
+ */
193
206
  raw: unknown;
194
207
  }
195
208
 
@@ -207,7 +220,7 @@ export function inspectSseData(kind: DiagnosticsKind, data: string): RawResult |
207
220
  if (!isRecord(event) || event.type !== "message_start" || !isRecord(event.message)) return "stop";
208
221
  const id = event.message.id;
209
222
  if (typeof id !== "string") return "stop";
210
- return { kind, responseId: id, raw: event.message.diagnostics ?? null };
223
+ return { kind, responseId: id, raw: Object.hasOwn(event.message, "diagnostics") ? event.message.diagnostics : undefined };
211
224
  }
212
225
 
213
226
  // OpenAI: terminal event carries the full response. Cheap substring check before parsing.
@@ -286,7 +299,8 @@ export function createSseTap(
286
299
  // Normalization
287
300
  // ---------------------------------------------------------------------------
288
301
 
289
- export type Outcome = "hit" | "miss" | "pending" | "not_found" | "unavailable" | "none";
302
+ /** `expired`: the provider found no prompt change, but cached tokens dropped (TTL/eviction). */
303
+ export type Outcome = "hit" | "miss" | "expired" | "pending" | "not_found" | "unavailable" | "none";
290
304
 
291
305
  export interface Summary {
292
306
  outcome: Outcome;
@@ -294,10 +308,18 @@ export interface Summary {
294
308
  missedTokens?: number;
295
309
  }
296
310
 
297
- export function summarize(kind: DiagnosticsKind, raw: unknown): Summary {
311
+ export interface SummarizeContext {
312
+ /** Cached tokens dropped versus the baseline turn (see `cacheDrop`). */
313
+ cacheDropped?: boolean;
314
+ }
315
+
316
+ export function summarize(kind: DiagnosticsKind, raw: unknown, context: SummarizeContext = {}): Summary {
298
317
  if (kind === "anthropic") {
299
- // null => no divergence (we always send a real previous id when summarizing).
300
- if (raw === null || raw === undefined) return { outcome: "hit" };
318
+ // Field absent: nothing was reported (proxy dropped it or the route does not support it).
319
+ if (raw === undefined) return { outcome: "none" };
320
+ // null => the comparison found no divergence (we always send a real previous id when
321
+ // summarizing). With low cache reads that means the entry expired, not a hit.
322
+ if (raw === null) return { outcome: context.cacheDropped ? "expired" : "hit" };
301
323
  if (!isRecord(raw)) return { outcome: "unavailable" };
302
324
  const reason = raw.cache_miss_reason;
303
325
  if (reason === null || reason === undefined) return { outcome: "pending" };
@@ -356,6 +378,9 @@ export function formatNotification(summary: Summary, droppedTokens?: number): st
356
378
  if (summary.outcome === "miss") {
357
379
  return `Cache miss, provider diagnostics reason: ${summary.reason ?? "unknown"}`;
358
380
  }
381
+ if (summary.outcome === "expired") {
382
+ return "Cache miss, provider diagnostics: prompt unchanged, cache entry expired";
383
+ }
359
384
  if (summary.outcome === "unavailable" && droppedTokens !== undefined) {
360
385
  return "Cache miss, provider diagnostics reason: unavailable";
361
386
  }
@@ -372,3 +397,12 @@ export function formatNotification(summary: Summary, droppedTokens?: number): st
372
397
  return "Provider diagnostics result: no diagnostics returned";
373
398
  }
374
399
  }
400
+
401
+ /**
402
+ * Follow-up line for Pi's native cache-miss notice, which already says "Cache miss" and how many
403
+ * tokens were re-billed. Only the provider's explanation is added.
404
+ */
405
+ export function formatComplement(summary: Summary, droppedTokens?: number): string {
406
+ const text = formatNotification(summary, droppedTokens).replace(/^Cache miss, provider/, "Provider");
407
+ return `↳ ${text}`;
408
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { Text } from "@earendil-works/pi-tui";
2
+ import type { JsonValue } from "@earendil-works/pi-ai";
3
+ import { Text, type Component } from "@earendil-works/pi-tui";
3
4
  import {
4
5
  cacheDrop,
5
6
  createSseTap,
7
+ formatComplement,
6
8
  formatNotification,
7
9
  DIAGNOSTIC_TYPE,
8
10
  findBaseline,
@@ -16,6 +18,7 @@ import {
16
18
  type ModelRef,
17
19
  type RawResult,
18
20
  } from "./core.ts";
21
+ import { createNativeNoticeSetting, detectNativeMiss, isNativeNoticeShown, type AssistantLike } from "./native.ts";
19
22
 
20
23
  /** A request we modified whose response diagnostics should be captured. */
21
24
  interface Pending {
@@ -30,7 +33,43 @@ interface Pending {
30
33
  type Captured = RawResult & Pending;
31
34
 
32
35
  interface Notice {
36
+ /** Standalone text. */
33
37
  message: string;
38
+ /**
39
+ * Follow-up text used while Pi's native cache-miss notice is visible for the same response.
40
+ * Present only when Pi's own criteria for that notice are met.
41
+ */
42
+ complement?: string;
43
+ }
44
+
45
+ type Theme = Parameters<Parameters<ExtensionAPI["registerEntryRenderer"]>[1]>[2];
46
+
47
+ /**
48
+ * Chooses standalone vs. follow-up text at render time: Pi re-derives its native notice from the
49
+ * current `showCacheMissNotices` value on every transcript rebuild, so this must too.
50
+ */
51
+ class NoticeView implements Component {
52
+ private text?: string;
53
+ private view?: Text;
54
+
55
+ constructor(
56
+ private readonly notice: Notice,
57
+ private readonly theme: Theme,
58
+ private readonly nativeVisible: () => boolean,
59
+ ) {}
60
+
61
+ render(width: number): string[] {
62
+ const text = this.notice.complement && this.nativeVisible() ? this.notice.complement : this.notice.message;
63
+ if (!this.view || text !== this.text) {
64
+ this.text = text;
65
+ this.view = new Text(this.theme.fg("dim", text), 1, 0);
66
+ }
67
+ return this.view.render(width);
68
+ }
69
+
70
+ invalidate(): void {
71
+ this.view = undefined;
72
+ }
34
73
  }
35
74
 
36
75
  const NOTICE_TYPE = "pi-diagnostics";
@@ -70,17 +109,43 @@ export default function (pi: ExtensionAPI) {
70
109
  const captured = new Map<string, Captured>();
71
110
  let innerFetch: typeof globalThis.fetch | undefined;
72
111
  let wrapper: typeof globalThis.fetch | undefined;
112
+ const nativeSetting = createNativeNoticeSetting();
73
113
 
74
114
  pi.registerEntryRenderer<Notice>(NOTICE_TYPE, (entry, _options, theme) => {
75
- if (typeof entry.data?.message !== "string") return undefined;
76
- return new Text(theme.fg("dim", entry.data.message), 1, 0);
115
+ const data = entry.data;
116
+ if (typeof data?.message !== "string") return undefined;
117
+ const notice: Notice = {
118
+ message: data.message,
119
+ ...(typeof data.complement === "string" ? { complement: data.complement } : {}),
120
+ };
121
+ return new NoticeView(notice, theme, () => nativeSetting.enabled());
77
122
  });
78
123
 
79
- const persistNotice = (message: string, ctx: ExtensionContext) => {
124
+ const persistNotice = (notice: Notice, ctx: ExtensionContext) => {
80
125
  // Custom entries survive transcript rebuilds without entering model context or starting a turn.
81
- // TUI renders entry_appended itself; only RPC needs the separate UI notification.
82
- pi.appendEntry<Notice>(NOTICE_TYPE, { message });
83
- if (ctx.hasUI && ctx.mode === "rpc") ctx.ui.notify(message, "info");
126
+ // TUI renders entry_appended itself; only RPC needs the separate UI notification. Pi's native
127
+ // notice is interactive-only, so RPC always gets the standalone text.
128
+ pi.appendEntry<Notice>(NOTICE_TYPE, notice);
129
+ if (ctx.hasUI && ctx.mode === "rpc") ctx.ui.notify(notice.message, "info");
130
+ };
131
+
132
+ const syncSettingScope = (ctx: ExtensionContext) => {
133
+ nativeSetting.setScope({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted?.() ?? false });
134
+ };
135
+
136
+ /** Whether Pi's native cache-miss notice criteria hold for the persisted assistant entry. */
137
+ const nativeMissFor = (messageEntryId: string, message: AssistantLike, ctx: ExtensionContext): boolean => {
138
+ try {
139
+ const entries = ctx.sessionManager.getEntries();
140
+ const index = entries.findIndex((entry) => entry.id === messageEntryId);
141
+ const before = index >= 0 ? entries.slice(0, index) : entries;
142
+ const miss = detectNativeMiss(before as never, message, (provider, model) =>
143
+ ctx.modelRegistry?.find(provider, model)?.cost.cacheRead);
144
+ return isNativeNoticeShown(miss);
145
+ } catch {
146
+ // Best-effort mirror of Pi internals; never lose the notice itself.
147
+ return false;
148
+ }
84
149
  };
85
150
 
86
151
  const takePending = (body: unknown): Pending | undefined => {
@@ -130,7 +195,8 @@ export default function (pi: ExtensionAPI) {
130
195
  innerFetch = undefined;
131
196
  };
132
197
 
133
- pi.on("session_start", () => {
198
+ pi.on("session_start", (_event, ctx) => {
199
+ syncSettingScope(ctx);
134
200
  if (config.enabled) installFetch();
135
201
  });
136
202
 
@@ -178,7 +244,8 @@ export default function (pi: ExtensionAPI) {
178
244
  if (!result) return undefined;
179
245
  captured.delete(message.responseId);
180
246
 
181
- const summary = summarize(result.kind, result.raw);
247
+ const drop = cacheDrop(result.baselinePromptTokens, message.usage.cacheRead);
248
+ const summary = summarize(result.kind, result.raw, { cacheDropped: drop !== undefined });
182
249
  const expected = isExpectedMiss(
183
250
  summary,
184
251
  { modelId: result.baselineModelId, afterSummary: result.afterSummary },
@@ -186,11 +253,11 @@ export default function (pi: ExtensionAPI) {
186
253
  );
187
254
  // `unavailable` alone says nothing about hit/miss; only surface it when cached tokens actually
188
255
  // dropped versus the baseline. Model switches and compaction explain drops, so skip those.
256
+ // `expired` is defined by the drop, so it always carries it.
189
257
  const explainedDrop = result.afterSummary || (result.baselineModelId !== "" && result.baselineModelId !== result.modelId);
190
- const droppedTokens =
191
- summary.outcome === "unavailable" && !explainedDrop
192
- ? cacheDrop(result.baselinePromptTokens, message.usage.cacheRead)
193
- : undefined;
258
+ const droppedTokens = summary.outcome === "expired" || (summary.outcome === "unavailable" && !explainedDrop)
259
+ ? drop
260
+ : undefined;
194
261
 
195
262
  const diagnostic = {
196
263
  type: DIAGNOSTIC_TYPE[result.kind],
@@ -204,7 +271,8 @@ export default function (pi: ExtensionAPI) {
204
271
  ...(droppedTokens !== undefined ? { droppedTokens } : {}),
205
272
  cacheRead: message.usage.cacheRead,
206
273
  input: message.usage.input,
207
- raw: (result.raw ?? null) as never,
274
+ // Omitted when the response had no diagnostics field, so it stays distinct from `null`.
275
+ ...(result.raw === undefined ? {} : { raw: result.raw as JsonValue }),
208
276
  },
209
277
  };
210
278
 
@@ -216,16 +284,22 @@ export default function (pi: ExtensionAPI) {
216
284
  // after their response in both the live transcript and restored session history.
217
285
  const message = event.message;
218
286
  if (!config.enabled || config.notify === "off" || message.role !== "assistant") return;
287
+ syncSettingScope(ctx);
288
+ let nativeMiss: boolean | undefined;
219
289
  for (const diagnostic of message.diagnostics ?? []) {
220
290
  const kind = diagnostic.type === DIAGNOSTIC_TYPE.anthropic ? "anthropic"
221
291
  : diagnostic.type === DIAGNOSTIC_TYPE.openai ? "openai" : undefined;
222
292
  const details = diagnostic.details;
223
293
  if (!kind || !isObject(details)) continue;
224
- const summary = summarize(kind, details.raw);
225
294
  const droppedTokens = typeof details.droppedTokens === "number" ? details.droppedTokens : undefined;
295
+ const summary = summarize(kind, details.raw, { cacheDropped: droppedTokens !== undefined });
226
296
  const unexpectedMiss = (summary.outcome === "miss" && details.expected !== true) || droppedTokens !== undefined;
227
297
  if (config.notify === "all" || unexpectedMiss) {
228
- persistNotice(formatNotification(summary, droppedTokens), ctx);
298
+ nativeMiss ??= nativeMissFor(event.messageEntryId, message, ctx);
299
+ persistNotice({
300
+ message: formatNotification(summary, droppedTokens),
301
+ ...(nativeMiss ? { complement: formatComplement(summary, droppedTokens) } : {}),
302
+ }, ctx);
229
303
  }
230
304
  }
231
305
  });
@@ -243,7 +317,9 @@ export default function (pi: ExtensionAPI) {
243
317
  const d = (diagnostic.details ?? {}) as Record<string, unknown>;
244
318
  const reason = d.reason ? ` ${d.reason}` : "";
245
319
  const expected = d.expected ? " (expected)" : "";
246
- const dropped = typeof d.droppedTokens === "number" ? " · cache miss, diagnostics unavailable" : "";
320
+ const dropped = d.outcome === "unavailable" && typeof d.droppedTokens === "number"
321
+ ? " · cache miss, diagnostics unavailable"
322
+ : "";
247
323
  lines.push(`${message.model}: ${d.outcome}${reason}${expected}${dropped}`);
248
324
  }
249
325
  }
@@ -251,7 +327,7 @@ export default function (pi: ExtensionAPI) {
251
327
  const active = kindFor(model, config) ? "on" : "off";
252
328
  const header = `Provider cache diagnostics: ${active} for ${model ? `${model.provider}/${model.id}` : "no model"} (providers: ${config.providers.join(",")})`;
253
329
  const body = lines.length ? lines.slice(-15).join("\n") : "No diagnostics recorded on this branch yet.";
254
- persistNotice(`${header}\n${body}`, ctx);
330
+ persistNotice({ message: `${header}\n${body}` }, ctx);
255
331
  },
256
332
  });
257
333
  }
package/src/native.ts ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Mirror of Pi's native cache-miss notice (`showCacheMissNotices`).
3
+ *
4
+ * Pi renders its notice straight into the interactive transcript: it is not a session entry and
5
+ * extensions cannot extend it. To complement rather than duplicate it, this module predicts when
6
+ * Pi shows that notice so provider diagnostics can render as a follow-up line underneath it.
7
+ *
8
+ * Ported from Pi's `core/cache-stats.ts` and `InteractiveMode.addCacheMissNotice` (0.87).
9
+ */
10
+
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
14
+
15
+ /** Per-turn misses at or below this are cache breakpoint granularity noise. */
16
+ const NOISE_FLOOR_TOKENS = 1024;
17
+ /** Pi hides misses below both of these. */
18
+ const NOTICE_MIN_TOKENS = 20_000;
19
+ const NOTICE_MIN_COST = 0.1;
20
+
21
+ interface UsageLike {
22
+ input: number;
23
+ cacheRead: number;
24
+ cacheWrite: number;
25
+ cost?: { input?: number; cacheRead?: number; cacheWrite?: number };
26
+ }
27
+
28
+ export interface AssistantLike {
29
+ role: string;
30
+ provider?: string;
31
+ model?: string;
32
+ timestamp?: number;
33
+ usage?: UsageLike;
34
+ }
35
+
36
+ interface EntryLike {
37
+ type: string;
38
+ kind?: string;
39
+ provider?: string;
40
+ model?: string;
41
+ timestamp?: string;
42
+ usage?: UsageLike;
43
+ message?: AssistantLike;
44
+ }
45
+
46
+ interface PreviousRequest {
47
+ promptTokens: number;
48
+ modelKey: string;
49
+ timestamp: number;
50
+ reportedCache: boolean;
51
+ }
52
+
53
+ export interface NativeMiss {
54
+ missedTokens: number;
55
+ missedCost: number;
56
+ idleMs: number;
57
+ modelChanged: boolean;
58
+ }
59
+
60
+ /** Cache-read price per million tokens, used when the missed turn read nothing from cache. */
61
+ export type CacheReadPrice = (provider: string, model: string) => number | undefined;
62
+
63
+ function promptTokens(usage: UsageLike): number {
64
+ return usage.input + usage.cacheRead + usage.cacheWrite;
65
+ }
66
+
67
+ function isUsage(value: unknown): value is UsageLike {
68
+ if (typeof value !== "object" || value === null) return false;
69
+ const usage = value as Record<string, unknown>;
70
+ return typeof usage.input === "number" && typeof usage.cacheRead === "number" && typeof usage.cacheWrite === "number";
71
+ }
72
+
73
+ function previousRequest(entries: readonly EntryLike[]): PreviousRequest | undefined {
74
+ let prev: PreviousRequest | undefined;
75
+ for (const entry of entries) {
76
+ if (entry.type === "compaction" || entry.type === "branch_summary") {
77
+ prev = undefined;
78
+ } else if (entry.type === "usage" && entry.kind === "cache_warm" && isUsage(entry.usage)) {
79
+ const tokens = promptTokens(entry.usage);
80
+ if (tokens > 0) {
81
+ prev = {
82
+ promptTokens: tokens,
83
+ modelKey: `${entry.provider}/${entry.model}`,
84
+ timestamp: Date.parse(entry.timestamp ?? ""),
85
+ reportedCache: true,
86
+ };
87
+ }
88
+ } else if (entry.type === "message" && entry.message?.role === "assistant" && isUsage(entry.message.usage)) {
89
+ const usage = entry.message.usage;
90
+ const tokens = promptTokens(usage);
91
+ if (tokens > 0) {
92
+ prev = {
93
+ promptTokens: tokens,
94
+ modelKey: `${entry.message.provider}/${entry.message.model}`,
95
+ timestamp: entry.message.timestamp ?? 0,
96
+ reportedCache: (prev?.reportedCache ?? false) || usage.cacheRead + usage.cacheWrite > 0,
97
+ };
98
+ }
99
+ }
100
+ }
101
+ return prev;
102
+ }
103
+
104
+ /**
105
+ * Pi's miss detection for `message`. `entriesBefore` are all session entries (file order, every
106
+ * branch, like Pi) that precede the message.
107
+ */
108
+ export function detectNativeMiss(
109
+ entriesBefore: readonly EntryLike[],
110
+ message: AssistantLike,
111
+ cacheReadPrice?: CacheReadPrice,
112
+ ): NativeMiss | undefined {
113
+ const usage = message.usage;
114
+ if (!isUsage(usage)) return undefined;
115
+ const prev = previousRequest(entriesBefore);
116
+ const tokens = promptTokens(usage);
117
+ if (!prev || tokens <= 0 || (usage.cacheRead + usage.cacheWrite === 0 && !prev.reportedCache)) return undefined;
118
+ const missedTokens = Math.min(prev.promptTokens, tokens) - usage.cacheRead;
119
+ if (missedTokens <= NOISE_FLOOR_TOKENS) return undefined;
120
+
121
+ const cost = usage.cost ?? {};
122
+ const paidTokens = usage.input + usage.cacheWrite;
123
+ const paidPerToken = paidTokens > 0 ? ((cost.input ?? 0) + (cost.cacheWrite ?? 0)) / paidTokens : 0;
124
+ const readPerToken = usage.cacheRead > 0
125
+ ? (cost.cacheRead ?? 0) / usage.cacheRead
126
+ : (cacheReadPrice?.(message.provider ?? "", message.model ?? "") ?? 0) / 1_000_000;
127
+ return {
128
+ missedTokens,
129
+ missedCost: missedTokens * Math.max(0, paidPerToken - readPerToken),
130
+ idleMs: Math.max(0, (message.timestamp ?? 0) - prev.timestamp),
131
+ modelChanged: `${message.provider}/${message.model}` !== prev.modelKey,
132
+ };
133
+ }
134
+
135
+ /** Whether Pi renders a notice for this miss (when `showCacheMissNotices` is on). */
136
+ export function isNativeNoticeShown(miss: NativeMiss | undefined): boolean {
137
+ return miss !== undefined && (miss.missedTokens >= NOTICE_MIN_TOKENS || miss.missedCost >= NOTICE_MIN_COST);
138
+ }
139
+
140
+ interface SettingsScope {
141
+ cwd?: string;
142
+ projectTrusted?: boolean;
143
+ }
144
+
145
+ function readSetting(path: string): boolean | undefined {
146
+ try {
147
+ const value: unknown = JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, ""));
148
+ if (typeof value !== "object" || value === null) return undefined;
149
+ const show = (value as Record<string, unknown>).showCacheMissNotices;
150
+ return typeof show === "boolean" ? show : undefined;
151
+ } catch {
152
+ return undefined;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Reads Pi's `showCacheMissNotices` (project overrides global, default off). Extensions cannot
158
+ * reach Pi's SettingsManager, so this reads the files, cached briefly because renderers call it
159
+ * on every frame.
160
+ */
161
+ export function createNativeNoticeSetting(ttlMs = 1_000) {
162
+ let scope: SettingsScope = {};
163
+ let cached: { value: boolean; at: number; key: string } | undefined;
164
+ return {
165
+ setScope(next: SettingsScope): void {
166
+ scope = next;
167
+ cached = undefined;
168
+ },
169
+ enabled(now = Date.now()): boolean {
170
+ const agentDir = getAgentDir();
171
+ const key = `${agentDir}\0${scope.cwd ?? ""}\0${scope.projectTrusted ?? false}`;
172
+ if (cached && cached.key === key && now - cached.at < ttlMs) return cached.value;
173
+ const project = scope.cwd && scope.projectTrusted
174
+ ? readSetting(join(scope.cwd, CONFIG_DIR_NAME, "settings.json"))
175
+ : undefined;
176
+ const value = project ?? readSetting(join(agentDir, "settings.json")) ?? false;
177
+ cached = { value, at: now, key };
178
+ return value;
179
+ },
180
+ };
181
+ }