pi-diagnostics 0.0.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/LICENSE +21 -0
- package/README.md +24 -2
- package/package.json +53 -8
- package/src/core.ts +408 -0
- package/src/expiry-core.ts +734 -0
- package/src/expiry.ts +267 -0
- package/src/index.ts +333 -0
- package/src/native.ts +181 -0
- package/index.js +0 -2
package/src/expiry.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getAgentDir, keyText, type ExtensionAPI, type ExtensionContext, type ModelSelectEvent, type ThinkingLevelSelectEvent } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Box, Spacer, Text } from "@earendil-works/pi-tui";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_WARM_GRACE_MS,
|
|
8
|
+
REMINDER_TYPE,
|
|
9
|
+
cacheKeyScope,
|
|
10
|
+
type CacheWarmingMode,
|
|
11
|
+
type EntryLike,
|
|
12
|
+
type ModelLike,
|
|
13
|
+
type ReminderData,
|
|
14
|
+
type WarmingDecisionObservation,
|
|
15
|
+
buildReminder,
|
|
16
|
+
evaluate,
|
|
17
|
+
inspectPayload,
|
|
18
|
+
normalizeWarmingMode,
|
|
19
|
+
predictPiWarming,
|
|
20
|
+
prefixCacheMode,
|
|
21
|
+
reminderView,
|
|
22
|
+
resolveTtl,
|
|
23
|
+
scanBranch,
|
|
24
|
+
} from "./expiry-core.ts";
|
|
25
|
+
|
|
26
|
+
interface SettingsFile {
|
|
27
|
+
cacheWarming?: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Set PI_EXPIRY_REMINDER_DEBUG=/path/to/log to trace scheduling decisions. */
|
|
31
|
+
function debug(message: string): void {
|
|
32
|
+
const file = process.env.PI_EXPIRY_REMINDER_DEBUG;
|
|
33
|
+
if (!file) return;
|
|
34
|
+
try {
|
|
35
|
+
appendFileSync(file, `${new Date().toISOString()} ${message}\n`);
|
|
36
|
+
} catch {
|
|
37
|
+
// Debug logging must never affect the session.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Display text for Pi's "toggle tool output" key (ctrl+o unless remapped). */
|
|
42
|
+
function expandKeyText(): string {
|
|
43
|
+
try {
|
|
44
|
+
return keyText("app.tools.expand") || "ctrl+o";
|
|
45
|
+
} catch {
|
|
46
|
+
return "ctrl+o";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function modelKey(model: ModelLike | undefined): string | undefined {
|
|
51
|
+
return model ? `${model.provider}/${model.id}` : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function asModel(value: ExtensionContext["model"]): ModelLike | undefined {
|
|
55
|
+
if (!value) return undefined;
|
|
56
|
+
return value as ModelLike;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readSettings(): Promise<SettingsFile> {
|
|
60
|
+
try {
|
|
61
|
+
const text = await readFile(join(getAgentDir(), "settings.json"), "utf8");
|
|
62
|
+
const value: unknown = JSON.parse(text);
|
|
63
|
+
return typeof value === "object" && value !== null ? value as SettingsFile : {};
|
|
64
|
+
} catch {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function entryLikeBranch(ctx: ExtensionContext): EntryLike[] {
|
|
70
|
+
return ctx.sessionManager.getBranch() as unknown as EntryLike[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function entryLikeAll(ctx: ExtensionContext): EntryLike[] {
|
|
74
|
+
return ctx.sessionManager.getEntries() as unknown as EntryLike[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Show one durable transcript entry after expiry. `appendEntry`, unlike
|
|
79
|
+
* `sendMessage`, is never projected into provider context.
|
|
80
|
+
*/
|
|
81
|
+
export default function cacheExpiryReminder(pi: ExtensionAPI): void {
|
|
82
|
+
let settings: SettingsFile = {};
|
|
83
|
+
let warmingMode: CacheWarmingMode = "streaming";
|
|
84
|
+
let envRetention = process.env.PI_CACHE_RETENTION;
|
|
85
|
+
// Pi builds a fresh ExtensionContext object for every event, so never
|
|
86
|
+
// compare contexts by identity. Track the active session by id instead and
|
|
87
|
+
// keep the most recent context for timer callbacks.
|
|
88
|
+
let latestContext: ExtensionContext | undefined;
|
|
89
|
+
let activeSessionId: string | undefined;
|
|
90
|
+
let sessionNamespaceStartAt: number | undefined;
|
|
91
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
92
|
+
let payloadHints = new Map<string, ReturnType<typeof inspectPayload>>();
|
|
93
|
+
let lastDecision: WarmingDecisionObservation | undefined;
|
|
94
|
+
let generation = 0;
|
|
95
|
+
|
|
96
|
+
pi.registerEntryRenderer<ReminderData>(REMINDER_TYPE, (entry, { expanded }, theme) => {
|
|
97
|
+
const data = entry.data;
|
|
98
|
+
if (!data) return undefined;
|
|
99
|
+
const view = reminderView(data);
|
|
100
|
+
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
101
|
+
// Pi's app.tools.expand (ctrl+o) toggles `expanded` on every custom entry,
|
|
102
|
+
// same as tool output. Hint the toggle in both states.
|
|
103
|
+
const hint = ` ${theme.fg("dim", expandKeyText())}${theme.fg("muted", expanded ? " collapse" : " details")}`;
|
|
104
|
+
box.addChild(new Text(`${theme.fg("warning", `⏱ ${view.title}`)}${hint}`, 0, 0));
|
|
105
|
+
if (expanded) {
|
|
106
|
+
const width = Math.max(...view.rows.map(([label]) => label.length));
|
|
107
|
+
box.addChild(new Spacer(1));
|
|
108
|
+
for (const [label, value] of view.rows) {
|
|
109
|
+
box.addChild(new Text(`${theme.fg("dim", label.padEnd(width))} ${theme.fg("customMessageText", value)}`, 2, 0));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return box;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const clearTimer = (): void => {
|
|
116
|
+
if (timer) clearTimeout(timer);
|
|
117
|
+
timer = undefined;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const track = (ctx: ExtensionContext): boolean => {
|
|
121
|
+
const id = ctx.sessionManager.getSessionId();
|
|
122
|
+
if (activeSessionId !== undefined && id !== activeSessionId) return false;
|
|
123
|
+
latestContext = ctx;
|
|
124
|
+
return true;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const schedule = (delayMs: number): void => {
|
|
128
|
+
clearTimer();
|
|
129
|
+
const token = generation;
|
|
130
|
+
timer = setTimeout(() => {
|
|
131
|
+
timer = undefined;
|
|
132
|
+
if (token !== generation || !latestContext) return;
|
|
133
|
+
void check(latestContext).catch((error: unknown) => {
|
|
134
|
+
// Stale context after reload/session replacement; next session_start re-arms.
|
|
135
|
+
debug(`error: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
136
|
+
});
|
|
137
|
+
}, Math.max(100, delayMs));
|
|
138
|
+
timer.unref?.();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const check = async (ctx: ExtensionContext): Promise<void> => {
|
|
142
|
+
if (ctx.sessionManager.getSessionId() !== activeSessionId) return debug("skip: inactive session");
|
|
143
|
+
settings = await readSettings();
|
|
144
|
+
warmingMode = normalizeWarmingMode(settings.cacheWarming);
|
|
145
|
+
const model = asModel(ctx.model);
|
|
146
|
+
if (!model) return debug("skip: no model");
|
|
147
|
+
const hints = payloadHints.get(modelKey(model)!) ?? {};
|
|
148
|
+
const namespaceStartAt = cacheKeyScope(model, hints) === "session" ? sessionNamespaceStartAt : undefined;
|
|
149
|
+
const scan = scanBranch(entryLikeBranch(ctx), {
|
|
150
|
+
allEntries: entryLikeAll(ctx),
|
|
151
|
+
mode: prefixCacheMode(model, hints),
|
|
152
|
+
namespaceStartAt,
|
|
153
|
+
});
|
|
154
|
+
if (!scan.lastRequest) return debug("skip: no request on branch");
|
|
155
|
+
if (scan.inheritedFrom !== undefined) return debug("skip: fork copied request into new cache namespace");
|
|
156
|
+
if (scan.contextReset) return debug("skip: context reset after last request");
|
|
157
|
+
|
|
158
|
+
// Do not report an old provider cache after model switching. New request
|
|
159
|
+
// will establish a new cache baseline, then this loop starts again.
|
|
160
|
+
if (modelKey(model) !== `${scan.lastRequest.provider}/${scan.lastRequest.model}`) {
|
|
161
|
+
return debug(`skip: model ${modelKey(model)} != last request ${scan.lastRequest.provider}/${scan.lastRequest.model}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const ttl = resolveTtl({
|
|
165
|
+
model,
|
|
166
|
+
hints,
|
|
167
|
+
lastUsage: scan.lastRequest.usage,
|
|
168
|
+
envRetention,
|
|
169
|
+
});
|
|
170
|
+
if (!ttl) return debug("skip: prompt caching disabled or unknown");
|
|
171
|
+
|
|
172
|
+
const warming = predictPiWarming(warmingMode, model, ctx.thinkingLevel, envRetention);
|
|
173
|
+
const result = evaluate({
|
|
174
|
+
scan,
|
|
175
|
+
model,
|
|
176
|
+
ttl,
|
|
177
|
+
warming,
|
|
178
|
+
lastDecision,
|
|
179
|
+
graceMs: DEFAULT_WARM_GRACE_MS,
|
|
180
|
+
});
|
|
181
|
+
if (!result) return debug("skip: no evaluation");
|
|
182
|
+
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
if (now < result.expiresAt) {
|
|
185
|
+
debug(`wait: expires ${new Date(result.expiresAt).toISOString()} ttl=${ttl.ttlMs} (${ttl.source}); ${result.warmingSummary}`);
|
|
186
|
+
schedule(result.checkAt - now);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (scan.remindedForTouchAt === result.lastTouchAt) return debug("skip: already reminded");
|
|
190
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
|
|
191
|
+
debug("defer: agent busy");
|
|
192
|
+
schedule(5_000);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
debug(`remind: expired ${new Date(result.expiresAt).toISOString()}`);
|
|
196
|
+
|
|
197
|
+
pi.appendEntry<ReminderData>(REMINDER_TYPE, buildReminder(model, ttl, result));
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const reset = (): void => {
|
|
201
|
+
generation++;
|
|
202
|
+
clearTimer();
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
pi.on("session_start", async (event, ctx) => {
|
|
206
|
+
activeSessionId = ctx.sessionManager.getSessionId();
|
|
207
|
+
latestContext = ctx;
|
|
208
|
+
const header = ctx.sessionManager.getHeader?.();
|
|
209
|
+
sessionNamespaceStartAt = event.reason === "fork"
|
|
210
|
+
? (Date.parse(header?.timestamp ?? "") || Date.now())
|
|
211
|
+
: undefined;
|
|
212
|
+
settings = await readSettings();
|
|
213
|
+
warmingMode = normalizeWarmingMode(settings.cacheWarming);
|
|
214
|
+
envRetention = process.env.PI_CACHE_RETENTION;
|
|
215
|
+
reset();
|
|
216
|
+
// Existing sessions get checked immediately; fresh sessions have no request.
|
|
217
|
+
schedule(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
221
|
+
const model = asModel(ctx.model);
|
|
222
|
+
if (model) payloadHints.set(modelKey(model)!, inspectPayload(event.payload));
|
|
223
|
+
return undefined;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
pi.on("cache_warming_decision", (event) => {
|
|
227
|
+
lastDecision = {
|
|
228
|
+
at: Date.now(),
|
|
229
|
+
action: event.action,
|
|
230
|
+
expectedSavings: event.continuationProbability * event.missCost - event.warmCost,
|
|
231
|
+
};
|
|
232
|
+
return undefined;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
236
|
+
if (track(ctx)) reset();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
240
|
+
if (!track(ctx)) return;
|
|
241
|
+
// Global setting can change mid-session via /settings.
|
|
242
|
+
settings = await readSettings();
|
|
243
|
+
warmingMode = normalizeWarmingMode(settings.cacheWarming);
|
|
244
|
+
schedule(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
pi.on("model_select", (event: ModelSelectEvent, ctx) => {
|
|
248
|
+
if (track(ctx)) reset();
|
|
249
|
+
payloadHints.delete(modelKey(asModel(event.previousModel))!);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
pi.on("thinking_level_select", (_event: ThinkingLevelSelectEvent, ctx) => {
|
|
253
|
+
if (track(ctx)) reset();
|
|
254
|
+
});
|
|
255
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
256
|
+
// New leaf may resend a different prefix; re-evaluate from the new branch.
|
|
257
|
+
if (!track(ctx)) return;
|
|
258
|
+
reset();
|
|
259
|
+
schedule(0);
|
|
260
|
+
});
|
|
261
|
+
pi.on("session_shutdown", () => {
|
|
262
|
+
reset();
|
|
263
|
+
latestContext = undefined;
|
|
264
|
+
activeSessionId = undefined;
|
|
265
|
+
sessionNamespaceStartAt = undefined;
|
|
266
|
+
});
|
|
267
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { JsonValue } from "@earendil-works/pi-ai";
|
|
3
|
+
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
4
|
+
import {
|
|
5
|
+
cacheDrop,
|
|
6
|
+
createSseTap,
|
|
7
|
+
formatComplement,
|
|
8
|
+
formatNotification,
|
|
9
|
+
DIAGNOSTIC_TYPE,
|
|
10
|
+
findBaseline,
|
|
11
|
+
injectDiagnostics,
|
|
12
|
+
isExpectedMiss,
|
|
13
|
+
kindFor,
|
|
14
|
+
parseConfig,
|
|
15
|
+
sniffBody,
|
|
16
|
+
summarize,
|
|
17
|
+
type DiagnosticsKind,
|
|
18
|
+
type ModelRef,
|
|
19
|
+
type RawResult,
|
|
20
|
+
} from "./core.ts";
|
|
21
|
+
import { createNativeNoticeSetting, detectNativeMiss, isNativeNoticeShown, type AssistantLike } from "./native.ts";
|
|
22
|
+
|
|
23
|
+
/** A request we modified whose response diagnostics should be captured. */
|
|
24
|
+
interface Pending {
|
|
25
|
+
kind: DiagnosticsKind;
|
|
26
|
+
modelId: string;
|
|
27
|
+
baselineId: string;
|
|
28
|
+
baselineModelId: string;
|
|
29
|
+
afterSummary: boolean;
|
|
30
|
+
baselinePromptTokens: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Captured = RawResult & Pending;
|
|
34
|
+
|
|
35
|
+
interface Notice {
|
|
36
|
+
/** Standalone text. */
|
|
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
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const NOTICE_TYPE = "pi-diagnostics";
|
|
76
|
+
const MAX_TRACKED = 64;
|
|
77
|
+
|
|
78
|
+
function isObject(value: unknown): value is Record<string, any> {
|
|
79
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function modelRef(value: unknown): ModelRef | undefined {
|
|
83
|
+
if (!isObject(value)) return undefined;
|
|
84
|
+
const { provider, api, id } = value;
|
|
85
|
+
if (typeof provider !== "string" || typeof api !== "string" || typeof id !== "string") return undefined;
|
|
86
|
+
return { provider, api, id };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function trim<T>(collection: Map<string, T> | Set<T>): void {
|
|
90
|
+
while (collection.size > MAX_TRACKED) {
|
|
91
|
+
const first = collection.keys().next().value;
|
|
92
|
+
if (first === undefined) return;
|
|
93
|
+
collection.delete(first as never);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Turns on provider prompt-cache diagnostics for Anthropic Messages and OpenAI Responses.
|
|
99
|
+
*
|
|
100
|
+
* Request side: `before_provider_request` adds the provider's opt-in field, referencing the
|
|
101
|
+
* previous assistant response on the active branch.
|
|
102
|
+
* Response side: pi's adapters drop the diagnostics field, so a narrowly scoped fetch wrapper
|
|
103
|
+
* tees only requests this extension armed, reads the diagnostics-bearing SSE event, and
|
|
104
|
+
* `message_end` attaches the result to the assistant message's `diagnostics` array.
|
|
105
|
+
*/
|
|
106
|
+
export default function (pi: ExtensionAPI) {
|
|
107
|
+
const config = parseConfig(process.env);
|
|
108
|
+
const pending = new Set<Pending>();
|
|
109
|
+
const captured = new Map<string, Captured>();
|
|
110
|
+
let innerFetch: typeof globalThis.fetch | undefined;
|
|
111
|
+
let wrapper: typeof globalThis.fetch | undefined;
|
|
112
|
+
const nativeSetting = createNativeNoticeSetting();
|
|
113
|
+
|
|
114
|
+
pi.registerEntryRenderer<Notice>(NOTICE_TYPE, (entry, _options, theme) => {
|
|
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());
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const persistNotice = (notice: Notice, ctx: ExtensionContext) => {
|
|
125
|
+
// Custom entries survive transcript rebuilds without entering model context or starting a turn.
|
|
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
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const takePending = (body: unknown): Pending | undefined => {
|
|
152
|
+
if (pending.size === 0) return undefined;
|
|
153
|
+
const probe = sniffBody(body);
|
|
154
|
+
if (!probe) return undefined;
|
|
155
|
+
for (const item of pending) {
|
|
156
|
+
if (item.kind === probe.kind && item.baselineId === probe.comparedTo) {
|
|
157
|
+
pending.delete(item);
|
|
158
|
+
return item;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const installFetch = () => {
|
|
165
|
+
if (wrapper && globalThis.fetch === wrapper) return;
|
|
166
|
+
const inner = globalThis.fetch;
|
|
167
|
+
innerFetch = inner;
|
|
168
|
+
wrapper = async (input, init) => {
|
|
169
|
+
const match = takePending(init?.body);
|
|
170
|
+
const response = await inner(input, init);
|
|
171
|
+
if (!match || !response.body) return response;
|
|
172
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
173
|
+
if (!contentType.includes("text/event-stream")) return response;
|
|
174
|
+
|
|
175
|
+
const body = response.body.pipeThrough(
|
|
176
|
+
createSseTap(match.kind, (raw) => {
|
|
177
|
+
captured.set(raw.responseId, { ...match, ...raw });
|
|
178
|
+
trim(captured);
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
const tapped = new Response(body, {
|
|
182
|
+
status: response.status,
|
|
183
|
+
statusText: response.statusText,
|
|
184
|
+
headers: response.headers,
|
|
185
|
+
});
|
|
186
|
+
Object.defineProperty(tapped, "url", { value: response.url });
|
|
187
|
+
return tapped;
|
|
188
|
+
};
|
|
189
|
+
globalThis.fetch = wrapper;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const uninstallFetch = () => {
|
|
193
|
+
if (wrapper && globalThis.fetch === wrapper && innerFetch) globalThis.fetch = innerFetch;
|
|
194
|
+
wrapper = undefined;
|
|
195
|
+
innerFetch = undefined;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
pi.on("session_start", (_event, ctx) => {
|
|
199
|
+
syncSettingScope(ctx);
|
|
200
|
+
if (config.enabled) installFetch();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
pi.on("session_shutdown", () => {
|
|
204
|
+
uninstallFetch();
|
|
205
|
+
pending.clear();
|
|
206
|
+
captured.clear();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
210
|
+
const model = modelRef(ctx.model);
|
|
211
|
+
const kind = kindFor(model, config);
|
|
212
|
+
if (!model || !kind) return undefined;
|
|
213
|
+
|
|
214
|
+
// Nested requests on another model (e.g. compaction overrides) are not ours to label.
|
|
215
|
+
const payload = event.payload;
|
|
216
|
+
if (model.api !== "azure-openai-responses" && isObject(payload) && typeof payload.model === "string" && payload.model !== model.id) {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const baseline = findBaseline(ctx.sessionManager.getBranch(), model);
|
|
221
|
+
const next = injectDiagnostics(payload, kind, baseline?.responseId);
|
|
222
|
+
if (!next) return undefined;
|
|
223
|
+
|
|
224
|
+
if (baseline) {
|
|
225
|
+
// Re-install if pi replaced global fetch after session start (e.g. HTTP settings change).
|
|
226
|
+
installFetch();
|
|
227
|
+
pending.add({
|
|
228
|
+
kind,
|
|
229
|
+
modelId: model.id,
|
|
230
|
+
baselineId: baseline.responseId,
|
|
231
|
+
baselineModelId: baseline.modelId,
|
|
232
|
+
afterSummary: baseline.afterSummary,
|
|
233
|
+
baselinePromptTokens: baseline.promptTokens,
|
|
234
|
+
});
|
|
235
|
+
trim(pending);
|
|
236
|
+
}
|
|
237
|
+
return next;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
pi.on("message_end", (event) => {
|
|
241
|
+
const message = event.message;
|
|
242
|
+
if (message.role !== "assistant" || !message.responseId) return undefined;
|
|
243
|
+
const result = captured.get(message.responseId);
|
|
244
|
+
if (!result) return undefined;
|
|
245
|
+
captured.delete(message.responseId);
|
|
246
|
+
|
|
247
|
+
const drop = cacheDrop(result.baselinePromptTokens, message.usage.cacheRead);
|
|
248
|
+
const summary = summarize(result.kind, result.raw, { cacheDropped: drop !== undefined });
|
|
249
|
+
const expected = isExpectedMiss(
|
|
250
|
+
summary,
|
|
251
|
+
{ modelId: result.baselineModelId, afterSummary: result.afterSummary },
|
|
252
|
+
result.modelId,
|
|
253
|
+
);
|
|
254
|
+
// `unavailable` alone says nothing about hit/miss; only surface it when cached tokens actually
|
|
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.
|
|
257
|
+
const explainedDrop = result.afterSummary || (result.baselineModelId !== "" && result.baselineModelId !== result.modelId);
|
|
258
|
+
const droppedTokens = summary.outcome === "expired" || (summary.outcome === "unavailable" && !explainedDrop)
|
|
259
|
+
? drop
|
|
260
|
+
: undefined;
|
|
261
|
+
|
|
262
|
+
const diagnostic = {
|
|
263
|
+
type: DIAGNOSTIC_TYPE[result.kind],
|
|
264
|
+
timestamp: Date.now(),
|
|
265
|
+
details: {
|
|
266
|
+
outcome: summary.outcome,
|
|
267
|
+
...(summary.reason ? { reason: summary.reason } : {}),
|
|
268
|
+
...(summary.missedTokens !== undefined ? { missedTokens: summary.missedTokens } : {}),
|
|
269
|
+
comparedTo: result.baselineId,
|
|
270
|
+
...(expected ? { expected: true } : {}),
|
|
271
|
+
...(droppedTokens !== undefined ? { droppedTokens } : {}),
|
|
272
|
+
cacheRead: message.usage.cacheRead,
|
|
273
|
+
input: message.usage.input,
|
|
274
|
+
// Omitted when the response had no diagnostics field, so it stays distinct from `null`.
|
|
275
|
+
...(result.raw === undefined ? {} : { raw: result.raw as JsonValue }),
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
return { message: { ...message, diagnostics: [...(message.diagnostics ?? []), diagnostic] } };
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
pi.on("turn_end", (event, ctx) => {
|
|
283
|
+
// message_end runs before Pi persists the assistant. Wait for turn_end so notices remain
|
|
284
|
+
// after their response in both the live transcript and restored session history.
|
|
285
|
+
const message = event.message;
|
|
286
|
+
if (!config.enabled || config.notify === "off" || message.role !== "assistant") return;
|
|
287
|
+
syncSettingScope(ctx);
|
|
288
|
+
let nativeMiss: boolean | undefined;
|
|
289
|
+
for (const diagnostic of message.diagnostics ?? []) {
|
|
290
|
+
const kind = diagnostic.type === DIAGNOSTIC_TYPE.anthropic ? "anthropic"
|
|
291
|
+
: diagnostic.type === DIAGNOSTIC_TYPE.openai ? "openai" : undefined;
|
|
292
|
+
const details = diagnostic.details;
|
|
293
|
+
if (!kind || !isObject(details)) continue;
|
|
294
|
+
const droppedTokens = typeof details.droppedTokens === "number" ? details.droppedTokens : undefined;
|
|
295
|
+
const summary = summarize(kind, details.raw, { cacheDropped: droppedTokens !== undefined });
|
|
296
|
+
const unexpectedMiss = (summary.outcome === "miss" && details.expected !== true) || droppedTokens !== undefined;
|
|
297
|
+
if (config.notify === "all" || unexpectedMiss) {
|
|
298
|
+
nativeMiss ??= nativeMissFor(event.messageEntryId, message, ctx);
|
|
299
|
+
persistNotice({
|
|
300
|
+
message: formatNotification(summary, droppedTokens),
|
|
301
|
+
...(nativeMiss ? { complement: formatComplement(summary, droppedTokens) } : {}),
|
|
302
|
+
}, ctx);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
pi.registerCommand("diagnostics", {
|
|
308
|
+
description: "Show provider prompt-cache diagnostics for recent turns",
|
|
309
|
+
handler: async (_args, ctx) => {
|
|
310
|
+
const types = new Set(Object.values(DIAGNOSTIC_TYPE));
|
|
311
|
+
const lines: string[] = [];
|
|
312
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
313
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
314
|
+
const message = entry.message;
|
|
315
|
+
for (const diagnostic of message.diagnostics ?? []) {
|
|
316
|
+
if (!types.has(diagnostic.type as never)) continue;
|
|
317
|
+
const d = (diagnostic.details ?? {}) as Record<string, unknown>;
|
|
318
|
+
const reason = d.reason ? ` ${d.reason}` : "";
|
|
319
|
+
const expected = d.expected ? " (expected)" : "";
|
|
320
|
+
const dropped = d.outcome === "unavailable" && typeof d.droppedTokens === "number"
|
|
321
|
+
? " · cache miss, diagnostics unavailable"
|
|
322
|
+
: "";
|
|
323
|
+
lines.push(`${message.model}: ${d.outcome}${reason}${expected}${dropped}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const model = modelRef(ctx.model);
|
|
327
|
+
const active = kindFor(model, config) ? "on" : "off";
|
|
328
|
+
const header = `Provider cache diagnostics: ${active} for ${model ? `${model.provider}/${model.id}` : "no model"} (providers: ${config.providers.join(",")})`;
|
|
329
|
+
const body = lines.length ? lines.slice(-15).join("\n") : "No diagnostics recorded on this branch yet.";
|
|
330
|
+
persistNotice({ message: `${header}\n${body}` }, ctx);
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
}
|