pi-diagnostics 0.0.0 → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tianren Dong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,5 +1,93 @@
1
1
  # pi-diagnostics
2
2
 
3
- Placeholder package published by `tianrendong` for upcoming development.
3
+ Pi extension for prompt-cache visibility:
4
4
 
5
- Version `0.0.0` provides no functionality. Do not use it in production.
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.
7
+
8
+ ## Provider diagnostics
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.
17
+
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.
19
+
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
+ ```
35
+
36
+ ## Install
37
+
38
+ Requires [Pi](https://pi.dev) and Node.js 22.19 or newer.
39
+
40
+ ```bash
41
+ pi install npm:pi-diagnostics
42
+ ```
43
+
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
+ ```
90
+
91
+ ## License
92
+
93
+ MIT
package/package.json CHANGED
@@ -1,16 +1,61 @@
1
1
  {
2
2
  "name": "pi-diagnostics",
3
- "version": "0.0.0",
4
- "description": "Placeholder for pi-diagnostics. Implementation coming soon.",
5
- "license": "UNLICENSED",
6
- "type": "module",
7
- "exports": "./index.js",
8
- "files": [
9
- "index.js",
10
- "README.md"
3
+ "version": "0.2.0",
4
+ "description": "Prompt-cache diagnostics and expiry reminders for pi",
5
+ "keywords": [
6
+ "pi",
7
+ "pi-package",
8
+ "prompt-cache",
9
+ "diagnostics",
10
+ "anthropic",
11
+ "openai",
12
+ "cache-expiry"
11
13
  ],
14
+ "license": "MIT",
15
+ "author": "Tianren Dong",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/tianrendong/pi-diagnostics.git"
19
+ },
20
+ "homepage": "https://github.com/tianrendong/pi-diagnostics#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/tianrendong/pi-diagnostics/issues"
23
+ },
12
24
  "publishConfig": {
13
25
  "access": "public",
14
26
  "registry": "https://registry.npmjs.org/"
27
+ },
28
+ "engines": {
29
+ "node": ">=22.19.0"
30
+ },
31
+ "type": "module",
32
+ "files": [
33
+ "src",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "pi": {
38
+ "extensions": [
39
+ "./src/index.ts",
40
+ "./src/expiry.ts"
41
+ ]
42
+ },
43
+ "peerDependencies": {
44
+ "@earendil-works/pi-coding-agent": "*",
45
+ "@earendil-works/pi-ai": "*",
46
+ "@earendil-works/pi-tui": "*"
47
+ },
48
+ "devDependencies": {
49
+ "@earendil-works/pi-coding-agent": "*",
50
+ "@earendil-works/pi-ai": "*",
51
+ "@earendil-works/pi-tui": "*",
52
+ "@types/node": "^22.19.0",
53
+ "typescript": "^5.9.3",
54
+ "jiti": "^2.7.0"
55
+ },
56
+ "scripts": {
57
+ "check": "tsc --noEmit",
58
+ "test": "node --test test/*.test.mjs test/*.test.ts",
59
+ "prepublishOnly": "npm run check && npm test"
15
60
  }
16
61
  }
package/src/core.ts ADDED
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Provider prompt-cache diagnostics for pi.
3
+ *
4
+ * Anthropic Messages: https://platform.claude.com/docs/en/build-with-claude/cache-diagnostics
5
+ * request: diagnostics: { previous_message_id: string | null } (every turn; null = opt in)
6
+ * response: message_start.message.diagnostics
7
+ *
8
+ * OpenAI Responses: https://developers.openai.com/api/docs/guides/prompt-caching/diagnostics
9
+ * request: prompt_cache_options: { comparison_response_id: string } (omit on first turn)
10
+ * response: response.completed.response.prompt_cache_diagnostics
11
+ *
12
+ * Pure logic lives here so it can be tested without a pi runtime.
13
+ */
14
+
15
+ export type DiagnosticsKind = "anthropic" | "openai";
16
+
17
+ export const DIAGNOSTIC_TYPE: Record<DiagnosticsKind, string> = {
18
+ anthropic: "anthropic_cache_diagnostics",
19
+ openai: "openai_prompt_cache_diagnostics",
20
+ };
21
+
22
+ /** Pi API ids whose request payload shape we know how to extend. */
23
+ export const API_KIND: Record<string, DiagnosticsKind> = {
24
+ "anthropic-messages": "anthropic",
25
+ "openai-responses": "openai",
26
+ "azure-openai-responses": "openai",
27
+ "openai-codex-responses": "openai",
28
+ };
29
+
30
+ export const DEFAULT_PROVIDERS = ["anthropic", "openai", "ramp-router"];
31
+
32
+ export interface ModelRef {
33
+ provider: string;
34
+ api: string;
35
+ id: string;
36
+ }
37
+
38
+ export interface Config {
39
+ enabled: boolean;
40
+ /** Provider names allowed to receive diagnostics fields. "*" allows every provider on a supported API. */
41
+ providers: string[];
42
+ /** When to show a UI notification. */
43
+ notify: "miss" | "all" | "off";
44
+ }
45
+
46
+ export function parseConfig(env: Record<string, string | undefined>): Config {
47
+ const providers = (env.PI_DIAGNOSTICS_PROVIDERS ?? DEFAULT_PROVIDERS.join(","))
48
+ .split(",")
49
+ .map((p) => p.trim())
50
+ .filter(Boolean);
51
+ const notifyRaw = (env.PI_DIAGNOSTICS_NOTIFY ?? "miss").trim().toLowerCase();
52
+ const notify = notifyRaw === "all" || notifyRaw === "off" ? notifyRaw : "miss";
53
+ const enabledRaw = (env.PI_DIAGNOSTICS ?? "1").trim().toLowerCase();
54
+ const enabled = !["0", "false", "off", "no"].includes(enabledRaw);
55
+ return { enabled, providers, notify };
56
+ }
57
+
58
+ /** Returns the diagnostics dialect for this model, or undefined when diagnostics should not be sent. */
59
+ export function kindFor(model: ModelRef | undefined, config: Config): DiagnosticsKind | undefined {
60
+ if (!config.enabled || !model) return undefined;
61
+ const kind = API_KIND[model.api];
62
+ if (!kind) return undefined;
63
+ const allowed = config.providers.includes("*") || config.providers.includes(model.provider);
64
+ return allowed ? kind : undefined;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Baseline selection
69
+ // ---------------------------------------------------------------------------
70
+
71
+ export interface Baseline {
72
+ responseId: string;
73
+ modelId: string;
74
+ /** A compaction/branch summary sits between the baseline and now, so a miss is expected. */
75
+ afterSummary: boolean;
76
+ /** Baseline prompt size (input + cacheRead + cacheWrite); 0 when usage is unknown. */
77
+ promptTokens: number;
78
+ }
79
+
80
+ interface EntryLike {
81
+ type: string;
82
+ message?: {
83
+ role?: string;
84
+ provider?: string;
85
+ api?: string;
86
+ model?: string;
87
+ responseId?: string;
88
+ stopReason?: string;
89
+ usage?: { input?: number; cacheRead?: number; cacheWrite?: number };
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Most recent successful assistant response on the active branch from the same provider + API.
95
+ * Response ids are provider-scoped, so other providers' ids are never used.
96
+ */
97
+ export function findBaseline(branch: readonly EntryLike[], model: ModelRef): Baseline | undefined {
98
+ let afterSummary = false;
99
+ for (let i = branch.length - 1; i >= 0; i--) {
100
+ const entry = branch[i];
101
+ if (entry.type === "compaction" || entry.type === "branch_summary") {
102
+ afterSummary = true;
103
+ continue;
104
+ }
105
+ const message = entry.type === "message" ? entry.message : undefined;
106
+ if (!message || message.role !== "assistant") continue;
107
+ if (message.provider !== model.provider || message.api !== model.api) continue;
108
+ if (!message.responseId || message.stopReason === "error" || message.stopReason === "aborted") continue;
109
+ const usage = message.usage;
110
+ const promptTokens = usage ? (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) : 0;
111
+ return { responseId: message.responseId, modelId: message.model ?? "", afterSummary, promptTokens };
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Request payload injection
118
+ // ---------------------------------------------------------------------------
119
+
120
+ type JsonRecord = Record<string, unknown>;
121
+
122
+ function isRecord(value: unknown): value is JsonRecord {
123
+ return typeof value === "object" && value !== null && !Array.isArray(value);
124
+ }
125
+
126
+ /**
127
+ * Returns a payload with diagnostics fields added, or undefined to leave the payload unchanged.
128
+ * `previousId` of undefined means no baseline (first turn).
129
+ */
130
+ export function injectDiagnostics(
131
+ payload: unknown,
132
+ kind: DiagnosticsKind,
133
+ previousId: string | undefined,
134
+ ): JsonRecord | undefined {
135
+ if (!isRecord(payload)) return undefined;
136
+
137
+ if (kind === "anthropic") {
138
+ if (!Array.isArray(payload.messages)) return undefined;
139
+ // Don't overwrite a caller that already opted in explicitly.
140
+ if (isRecord(payload.diagnostics)) return undefined;
141
+ return { ...payload, diagnostics: { previous_message_id: previousId ?? null } };
142
+ }
143
+
144
+ // OpenAI Responses: records are stored by default; only the comparison needs opting in.
145
+ if (!("input" in payload)) return undefined;
146
+ if (!previousId) return undefined;
147
+ const existing = isRecord(payload.prompt_cache_options) ? payload.prompt_cache_options : {};
148
+ if (typeof existing.comparison_response_id === "string") return undefined;
149
+ return { ...payload, prompt_cache_options: { ...existing, comparison_response_id: previousId } };
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Request sniffing (inside fetch)
154
+ // ---------------------------------------------------------------------------
155
+
156
+ const ANTHROPIC_KEY = '"previous_message_id":"';
157
+ const OPENAI_KEY = '"comparison_response_id":"';
158
+
159
+ export interface Probe {
160
+ kind: DiagnosticsKind;
161
+ comparedTo: string;
162
+ }
163
+
164
+ /**
165
+ * Detect a serialized request body carrying one of our comparison ids. JSON keys inside
166
+ * string content are escaped (\"), so an unescaped match is a real top-level-ish key. Callers
167
+ * must still confirm the id was armed by this extension.
168
+ */
169
+ export function sniffBody(body: unknown): Probe | undefined {
170
+ if (typeof body !== "string") return undefined;
171
+ for (const [kind, key] of [
172
+ ["anthropic", ANTHROPIC_KEY],
173
+ ["openai", OPENAI_KEY],
174
+ ] as const) {
175
+ const at = body.indexOf(key);
176
+ if (at === -1) continue;
177
+ const start = at + key.length;
178
+ const end = body.indexOf('"', start);
179
+ if (end === -1 || end - start > 256) continue;
180
+ return { kind, comparedTo: body.slice(start, end) };
181
+ }
182
+ return undefined;
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // SSE tap
187
+ // ---------------------------------------------------------------------------
188
+
189
+ export interface RawResult {
190
+ kind: DiagnosticsKind;
191
+ responseId: string;
192
+ /** Raw provider diagnostics value (null when the provider reported none). */
193
+ raw: unknown;
194
+ }
195
+
196
+ const OPENAI_TERMINAL = new Set(["response.completed", "response.incomplete", "response.failed"]);
197
+
198
+ /**
199
+ * Inspect SSE data lines for the diagnostics-bearing event. Returns a result, "stop" when the
200
+ * relevant event can no longer appear, or undefined to keep reading.
201
+ */
202
+ export function inspectSseData(kind: DiagnosticsKind, data: string): RawResult | "stop" | undefined {
203
+ if (kind === "anthropic") {
204
+ // message_start carries diagnostics. Ignore ping and other events until it arrives.
205
+ if (!data.includes('"message_start"')) return undefined;
206
+ const event = safeParse(data);
207
+ if (!isRecord(event) || event.type !== "message_start" || !isRecord(event.message)) return "stop";
208
+ const id = event.message.id;
209
+ if (typeof id !== "string") return "stop";
210
+ return { kind, responseId: id, raw: event.message.diagnostics ?? null };
211
+ }
212
+
213
+ // OpenAI: terminal event carries the full response. Cheap substring check before parsing.
214
+ if (!data.includes('"response.completed"') && !data.includes('"response.incomplete"') && !data.includes('"response.failed"')) {
215
+ return undefined;
216
+ }
217
+ const event = safeParse(data);
218
+ if (!isRecord(event) || typeof event.type !== "string" || !OPENAI_TERMINAL.has(event.type)) return undefined;
219
+ if (!isRecord(event.response) || typeof event.response.id !== "string") return "stop";
220
+ return { kind, responseId: event.response.id, raw: event.response.prompt_cache_diagnostics ?? null };
221
+ }
222
+
223
+ function safeParse(text: string): unknown {
224
+ try {
225
+ return JSON.parse(text);
226
+ } catch {
227
+ return undefined;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Pass-through stream that forwards every byte unchanged and reports diagnostics once seen.
233
+ * Preserves backpressure and cancellation; inspection errors never affect the stream.
234
+ */
235
+ export function createSseTap(
236
+ kind: DiagnosticsKind,
237
+ onResult: (result: RawResult) => void,
238
+ ): TransformStream<Uint8Array, Uint8Array> {
239
+ const decoder = new TextDecoder();
240
+ let buffer = "";
241
+ let done = false;
242
+
243
+ const consume = (text: string, final: boolean) => {
244
+ buffer += text;
245
+ let newline = buffer.indexOf("\n");
246
+ while (newline !== -1 && !done) {
247
+ handleLine(buffer.slice(0, newline));
248
+ buffer = buffer.slice(newline + 1);
249
+ newline = buffer.indexOf("\n");
250
+ }
251
+ if (final && !done && buffer) handleLine(buffer);
252
+ if (done || final) buffer = "";
253
+ };
254
+
255
+ const handleLine = (rawLine: string) => {
256
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
257
+ if (!line.startsWith("data:")) return;
258
+ const outcome = inspectSseData(kind, line.slice(5).trimStart());
259
+ if (outcome === undefined) return;
260
+ done = true;
261
+ if (outcome !== "stop") onResult(outcome);
262
+ };
263
+
264
+ return new TransformStream<Uint8Array, Uint8Array>({
265
+ transform(chunk, controller) {
266
+ controller.enqueue(chunk);
267
+ if (done) return;
268
+ try {
269
+ consume(decoder.decode(chunk, { stream: true }), false);
270
+ } catch {
271
+ done = true;
272
+ }
273
+ },
274
+ flush() {
275
+ if (done) return;
276
+ try {
277
+ consume(decoder.decode(), true);
278
+ } catch {
279
+ // ignore
280
+ }
281
+ },
282
+ });
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Normalization
287
+ // ---------------------------------------------------------------------------
288
+
289
+ export type Outcome = "hit" | "miss" | "pending" | "not_found" | "unavailable" | "none";
290
+
291
+ export interface Summary {
292
+ outcome: Outcome;
293
+ reason?: string;
294
+ missedTokens?: number;
295
+ }
296
+
297
+ export function summarize(kind: DiagnosticsKind, raw: unknown): Summary {
298
+ 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" };
301
+ if (!isRecord(raw)) return { outcome: "unavailable" };
302
+ const reason = raw.cache_miss_reason;
303
+ if (reason === null || reason === undefined) return { outcome: "pending" };
304
+ if (!isRecord(reason) || typeof reason.type !== "string") return { outcome: "unavailable" };
305
+ if (reason.type === "previous_message_not_found") return { outcome: "not_found", reason: reason.type };
306
+ if (reason.type === "unavailable") return { outcome: "unavailable", reason: reason.type };
307
+ const missed = reason.cache_missed_input_tokens;
308
+ return { outcome: "miss", reason: reason.type, ...(typeof missed === "number" ? { missedTokens: missed } : {}) };
309
+ }
310
+
311
+ if (!isRecord(raw) || typeof raw.type !== "string") return { outcome: "none" };
312
+ switch (raw.type) {
313
+ case "cache_hit":
314
+ return { outcome: "hit" };
315
+ case "cache_miss": {
316
+ const missed = raw.cache_missed_tokens;
317
+ return {
318
+ outcome: "miss",
319
+ ...(typeof raw.reason === "string" ? { reason: raw.reason } : {}),
320
+ ...(typeof missed === "number" ? { missedTokens: missed } : {}),
321
+ };
322
+ }
323
+ case "comparison_response_not_found":
324
+ return { outcome: "not_found", reason: raw.type };
325
+ default:
326
+ return { outcome: "unavailable", reason: raw.type };
327
+ }
328
+ }
329
+
330
+ /** Reasons that the conversation itself explains (model switch, compaction). */
331
+ export function isExpectedMiss(summary: Summary, baseline: { modelId: string; afterSummary: boolean } | undefined, modelId: string): boolean {
332
+ if (summary.outcome !== "miss" || !baseline) return false;
333
+ if (summary.reason === "model_changed" && baseline.modelId && baseline.modelId !== modelId) return true;
334
+ if (baseline.afterSummary && (summary.reason === "messages_changed" || summary.reason === "input_changed" || summary.reason === "context_compacted")) {
335
+ return true;
336
+ }
337
+ return false;
338
+ }
339
+
340
+ /** Per-turn drops at or below this are cache breakpoint granularity noise (matches pi's native notice). */
341
+ const DROP_NOISE_FLOOR_TOKENS = 1024;
342
+
343
+ /**
344
+ * Tokens from the baseline prompt that were not read from cache this turn, or undefined when
345
+ * cached tokens did not actually drop. A drop means less than half the baseline prompt was read
346
+ * from cache and the shortfall is above the noise floor.
347
+ */
348
+ export function cacheDrop(baselinePromptTokens: number, cacheRead: number): number | undefined {
349
+ if (baselinePromptTokens <= 0) return undefined;
350
+ const dropped = baselinePromptTokens - cacheRead;
351
+ if (dropped <= DROP_NOISE_FLOOR_TOKENS || cacheRead >= baselinePromptTokens / 2) return undefined;
352
+ return dropped;
353
+ }
354
+
355
+ export function formatNotification(summary: Summary, droppedTokens?: number): string {
356
+ if (summary.outcome === "miss") {
357
+ return `Cache miss, provider diagnostics reason: ${summary.reason ?? "unknown"}`;
358
+ }
359
+ if (summary.outcome === "unavailable" && droppedTokens !== undefined) {
360
+ return "Cache miss, provider diagnostics reason: unavailable";
361
+ }
362
+ switch (summary.outcome) {
363
+ case "hit":
364
+ return "Provider diagnostics result: cache hit";
365
+ case "pending":
366
+ return "Provider diagnostics result: comparison pending";
367
+ case "not_found":
368
+ return "Provider diagnostics result: comparison response not found";
369
+ case "unavailable":
370
+ return "Provider diagnostics reason: unavailable";
371
+ case "none":
372
+ return "Provider diagnostics result: no diagnostics returned";
373
+ }
374
+ }