@mrclrchtr/supi-cache 0.1.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 +119 -0
- package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +30 -0
- package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
- package/package.json +44 -0
- package/src/config.ts +37 -0
- package/src/fingerprint.ts +187 -0
- package/src/forensics/extract.ts +129 -0
- package/src/forensics/forensics.ts +214 -0
- package/src/forensics/queries.ts +74 -0
- package/src/forensics/redact.ts +61 -0
- package/src/forensics/types.ts +59 -0
- package/src/hash.ts +16 -0
- package/src/index.ts +1 -0
- package/src/monitor/monitor.ts +320 -0
- package/src/monitor/state.ts +308 -0
- package/src/monitor/status.ts +33 -0
- package/src/report/forensics.ts +165 -0
- package/src/report/history.ts +197 -0
- package/src/settings-registration.ts +80 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// supi-cache — continuous prompt cache health monitoring extension.
|
|
2
|
+
//
|
|
3
|
+
// Tracks per-turn cache metrics, detects regressions with cause diagnosis,
|
|
4
|
+
// shows a compact footer status, and provides /supi-cache-history and /supi-cache-forensics commands.
|
|
5
|
+
|
|
6
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
import { loadCacheMonitorConfig } from "../config.ts";
|
|
11
|
+
import { computePromptFingerprint, diffFingerprints, zeroFingerprint } from "../fingerprint.ts";
|
|
12
|
+
import { runForensics } from "../forensics/forensics.ts";
|
|
13
|
+
import { stripHumanDetail } from "../forensics/redact.ts";
|
|
14
|
+
import { formatForensicsReport } from "../report/forensics.ts";
|
|
15
|
+
import { type CacheReportSnapshot, formatCacheReport } from "../report/history.ts";
|
|
16
|
+
import { registerCacheMonitorSettings } from "../settings-registration.ts";
|
|
17
|
+
import { CacheMonitorState, type RegressionResult } from "./state.ts";
|
|
18
|
+
import { formatCacheStatus } from "./status.ts";
|
|
19
|
+
|
|
20
|
+
const STATUS_KEY = "supi-cache";
|
|
21
|
+
const ENTRY_TYPE = "supi-cache-turn";
|
|
22
|
+
const HISTORY_TYPE = "supi-cache-history";
|
|
23
|
+
const FORENSICS_TYPE = "supi-cache-forensics-report";
|
|
24
|
+
|
|
25
|
+
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: extension factory wiring
|
|
26
|
+
export default function cacheMonitorExtension(pi: ExtensionAPI) {
|
|
27
|
+
const state = new CacheMonitorState();
|
|
28
|
+
|
|
29
|
+
// Register settings synchronously during factory
|
|
30
|
+
registerCacheMonitorSettings();
|
|
31
|
+
|
|
32
|
+
// ── Helper: check if extension is enabled ─────────────────
|
|
33
|
+
|
|
34
|
+
function isEnabled(ctx: { cwd: string }): boolean {
|
|
35
|
+
return loadCacheMonitorConfig(ctx.cwd).enabled;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function notificationsEnabled(ctx: { cwd: string }): boolean {
|
|
39
|
+
return loadCacheMonitorConfig(ctx.cwd).notifications;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getThreshold(ctx: { cwd: string }): number {
|
|
43
|
+
return loadCacheMonitorConfig(ctx.cwd).regressionThreshold;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── message_end: record turn + update status + check regression
|
|
47
|
+
|
|
48
|
+
pi.on("message_end", async (event, ctx) => {
|
|
49
|
+
if (!isEnabled(ctx)) return;
|
|
50
|
+
|
|
51
|
+
const msg = event.message;
|
|
52
|
+
if (msg.role !== "assistant") return;
|
|
53
|
+
if (!("usage" in msg) || !msg.usage) return;
|
|
54
|
+
|
|
55
|
+
const { cacheRead, cacheWrite, input } = msg.usage;
|
|
56
|
+
const record = state.recordTurn({ cacheRead, cacheWrite, input }, Date.now());
|
|
57
|
+
|
|
58
|
+
// Persist turn record
|
|
59
|
+
pi.appendEntry(ENTRY_TYPE, record);
|
|
60
|
+
|
|
61
|
+
// Update footer status
|
|
62
|
+
const statusText = formatCacheStatus(state);
|
|
63
|
+
ctx.ui.setStatus(STATUS_KEY, statusText);
|
|
64
|
+
|
|
65
|
+
// Check regression
|
|
66
|
+
const regression = state.detectRegression(getThreshold(ctx));
|
|
67
|
+
if (regression && notificationsEnabled(ctx)) {
|
|
68
|
+
const diffs =
|
|
69
|
+
regression.cause.type === "prompt_change"
|
|
70
|
+
? diffFingerprints(
|
|
71
|
+
state.getPreviousFingerprint() ?? zeroFingerprint(),
|
|
72
|
+
state.getLatestFingerprint() ?? zeroFingerprint(),
|
|
73
|
+
)
|
|
74
|
+
: undefined;
|
|
75
|
+
ctx.ui.notify(formatRegressionMessage(regression, diffs), "warning");
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── session_compact: flag compaction ──────────────────────
|
|
80
|
+
|
|
81
|
+
pi.on("session_compact", async (_event, ctx) => {
|
|
82
|
+
if (!isEnabled(ctx)) return;
|
|
83
|
+
state.flagCompaction();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ── model_select: flag model change ───────────────────────
|
|
87
|
+
|
|
88
|
+
pi.on("model_select", async (event, ctx) => {
|
|
89
|
+
if (!isEnabled(ctx)) return;
|
|
90
|
+
const modelStr = `${event.model.provider}/${event.model.id}`;
|
|
91
|
+
state.flagModelChange(modelStr);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── before_agent_start: fingerprint system prompt ────────
|
|
95
|
+
|
|
96
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
97
|
+
if (!isEnabled(ctx)) return;
|
|
98
|
+
const fp = computePromptFingerprint(event.systemPromptOptions);
|
|
99
|
+
state.updatePromptFingerprint(fp);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── session_start: restore state from entries ─────────────
|
|
103
|
+
|
|
104
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
105
|
+
state.reset();
|
|
106
|
+
|
|
107
|
+
if (!isEnabled(ctx)) {
|
|
108
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const branch = ctx.sessionManager.getBranch();
|
|
113
|
+
state.restoreFromEntries(branch);
|
|
114
|
+
|
|
115
|
+
const statusText = formatCacheStatus(state);
|
|
116
|
+
ctx.ui.setStatus(STATUS_KEY, statusText);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ── session_shutdown: clear state ─────────────────────────
|
|
120
|
+
|
|
121
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
122
|
+
state.reset();
|
|
123
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ── /supi-cache-history command ──────────────────────────
|
|
127
|
+
|
|
128
|
+
pi.registerCommand("supi-cache-history", {
|
|
129
|
+
description: "Show per-turn cache health history",
|
|
130
|
+
handler: async (_args, _ctx) => {
|
|
131
|
+
const turns = state.getTurns();
|
|
132
|
+
const shortContent = turns.length > 0 ? `${turns.length} turns tracked` : "No cache data yet";
|
|
133
|
+
const snapshot: CacheReportSnapshot = {
|
|
134
|
+
turns: [...turns],
|
|
135
|
+
cacheSupported: state.cacheSupported,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
pi.sendMessage({
|
|
139
|
+
customType: HISTORY_TYPE,
|
|
140
|
+
content: shortContent,
|
|
141
|
+
display: true,
|
|
142
|
+
details: snapshot,
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── Message renderer for supi-cache-history ───────────────
|
|
148
|
+
|
|
149
|
+
pi.registerMessageRenderer(HISTORY_TYPE, (message, _renderOptions, theme) => {
|
|
150
|
+
const snapshot = message.details as CacheReportSnapshot | undefined;
|
|
151
|
+
const lines = formatCacheReport(snapshot ?? { turns: [], cacheSupported: false }, theme);
|
|
152
|
+
const container = new Container();
|
|
153
|
+
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
if (line === "") {
|
|
156
|
+
container.addChild(new Spacer(1));
|
|
157
|
+
} else {
|
|
158
|
+
container.addChild(new Text(line, 0, 0));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return container;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── /supi-cache-forensics command ─────────────────────────
|
|
166
|
+
|
|
167
|
+
pi.registerCommand("supi-cache-forensics", {
|
|
168
|
+
description: "Cross-session cache forensics investigation",
|
|
169
|
+
handler: async (args, ctx) => {
|
|
170
|
+
const since = parseArg(args, "--since") ?? "7d";
|
|
171
|
+
const pattern = (parseArg(args, "--pattern") ?? "breakdown") as
|
|
172
|
+
| "hotspots"
|
|
173
|
+
| "breakdown"
|
|
174
|
+
| "correlate"
|
|
175
|
+
| "idle";
|
|
176
|
+
const minDrop = Number.parseInt(parseArg(args, "--min-drop") ?? "0", 10);
|
|
177
|
+
const config = loadCacheMonitorConfig(ctx.cwd);
|
|
178
|
+
|
|
179
|
+
const result = await runForensics({
|
|
180
|
+
pattern,
|
|
181
|
+
since,
|
|
182
|
+
minDrop: Number.isNaN(minDrop) ? 0 : minDrop,
|
|
183
|
+
idleThresholdMinutes: config.idleThresholdMinutes,
|
|
184
|
+
regressionThreshold: config.regressionThreshold,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const shortContent = `${result.sessionsScanned} sessions, ${result.turnsAnalyzed} turns`;
|
|
188
|
+
pi.sendMessage({
|
|
189
|
+
customType: FORENSICS_TYPE,
|
|
190
|
+
content: shortContent,
|
|
191
|
+
display: true,
|
|
192
|
+
details: result,
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ── Message renderer for supi-cache-forensics-report ──────
|
|
198
|
+
|
|
199
|
+
pi.registerMessageRenderer(FORENSICS_TYPE, (message, _renderOptions, theme) => {
|
|
200
|
+
const snapshot = message.details as
|
|
201
|
+
| import("../report/forensics.ts").ForensicsReportSnapshot
|
|
202
|
+
| undefined;
|
|
203
|
+
const lines = formatForensicsReport(
|
|
204
|
+
snapshot ?? {
|
|
205
|
+
pattern: "breakdown",
|
|
206
|
+
sessionsScanned: 0,
|
|
207
|
+
turnsAnalyzed: 0,
|
|
208
|
+
},
|
|
209
|
+
theme,
|
|
210
|
+
);
|
|
211
|
+
const container = new Container();
|
|
212
|
+
|
|
213
|
+
for (const line of lines) {
|
|
214
|
+
if (line === "") {
|
|
215
|
+
container.addChild(new Spacer(1));
|
|
216
|
+
} else {
|
|
217
|
+
container.addChild(new Text(line, 0, 0));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return container;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ── supi_cache_forensics agent tool ───────────────────────
|
|
225
|
+
|
|
226
|
+
pi.registerTool({
|
|
227
|
+
name: "supi_cache_forensics",
|
|
228
|
+
label: "Cache Forensics",
|
|
229
|
+
description:
|
|
230
|
+
"Investigate prompt cache regressions across historical PI sessions. " +
|
|
231
|
+
"Provides four query patterns: hotspots (worst drops), breakdown (cause tally), " +
|
|
232
|
+
"correlate (tools before regressions), and idle (long-gap regressions). " +
|
|
233
|
+
'Example: {"pattern": "hotspots", "since": "7d", "minDrop": 20}',
|
|
234
|
+
parameters: Type.Object({
|
|
235
|
+
pattern: StringEnum(["hotspots", "breakdown", "correlate", "idle"], {
|
|
236
|
+
description: "Query pattern",
|
|
237
|
+
}),
|
|
238
|
+
since: Type.Optional(
|
|
239
|
+
Type.String({
|
|
240
|
+
description: 'Duration string like "7d", "24h", "30m". Default: "7d"',
|
|
241
|
+
default: "7d",
|
|
242
|
+
}),
|
|
243
|
+
),
|
|
244
|
+
minDrop: Type.Optional(
|
|
245
|
+
Type.Number({
|
|
246
|
+
description: "Minimum hit-rate drop in percentage points to include. Default: 0",
|
|
247
|
+
default: 0,
|
|
248
|
+
}),
|
|
249
|
+
),
|
|
250
|
+
maxSessions: Type.Optional(
|
|
251
|
+
Type.Number({
|
|
252
|
+
description: "Maximum sessions to scan. Default: 100",
|
|
253
|
+
default: 100,
|
|
254
|
+
}),
|
|
255
|
+
),
|
|
256
|
+
}),
|
|
257
|
+
promptGuidelines: [
|
|
258
|
+
"Use `supi_cache_forensics` when the user asks about cache performance patterns, suspects idle-time cache expiry, or wants to understand what preceded a cache drop.",
|
|
259
|
+
"Prefer `pattern: 'breakdown'` for a quick overview of regression causes.",
|
|
260
|
+
"Use `pattern: 'hotspots'` with `minDrop: 20` or higher to surface the worst regressions.",
|
|
261
|
+
"Use `pattern: 'idle'` to detect cache drops caused by long gaps between turns.",
|
|
262
|
+
"Use `pattern: 'correlate'` to see which tool calls preceded regressions.",
|
|
263
|
+
"The tool returns shape fingerprints (param types and lengths), not raw file paths or command text.",
|
|
264
|
+
],
|
|
265
|
+
// biome-ignore lint/complexity/useMaxParams: pi tool execute signature
|
|
266
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
267
|
+
const config = loadCacheMonitorConfig(ctx.cwd);
|
|
268
|
+
const result = await runForensics({
|
|
269
|
+
pattern: params.pattern as "hotspots" | "breakdown" | "correlate" | "idle",
|
|
270
|
+
since: (params.since as string) ?? "7d",
|
|
271
|
+
minDrop: (params.minDrop as number) ?? 0,
|
|
272
|
+
maxSessions: (params.maxSessions as number) ?? 100,
|
|
273
|
+
idleThresholdMinutes: config.idleThresholdMinutes,
|
|
274
|
+
regressionThreshold: config.regressionThreshold,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Strip human-only detail before returning to agent
|
|
278
|
+
if (result.findings) {
|
|
279
|
+
result.findings = stripHumanDetail(result.findings);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
284
|
+
details: undefined,
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Parse a simple `--key value` argument from a command string. */
|
|
291
|
+
function parseArg(args: string, key: string): string | undefined {
|
|
292
|
+
const regex = new RegExp(`${key}\\s+([^\\s]+)`);
|
|
293
|
+
const match = args.match(regex);
|
|
294
|
+
return match?.[1];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function formatRegressionMessage(regression: RegressionResult, diffs?: string[]): string {
|
|
298
|
+
if (!regression) return "";
|
|
299
|
+
const { previousRate, currentRate, cause } = regression;
|
|
300
|
+
let causeStr: string;
|
|
301
|
+
|
|
302
|
+
switch (cause.type) {
|
|
303
|
+
case "compaction":
|
|
304
|
+
causeStr = "compaction";
|
|
305
|
+
break;
|
|
306
|
+
case "model_change":
|
|
307
|
+
causeStr = `model changed${cause.model !== "unknown" ? ` to ${cause.model}` : ""}`;
|
|
308
|
+
break;
|
|
309
|
+
case "prompt_change":
|
|
310
|
+
causeStr =
|
|
311
|
+
diffs && diffs.length > 0
|
|
312
|
+
? `system prompt changed (${diffs.join(", ")})`
|
|
313
|
+
: "system prompt changed";
|
|
314
|
+
break;
|
|
315
|
+
default:
|
|
316
|
+
causeStr = "unknown";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return `Cache regression: ${previousRate}% → ${currentRate}%. Likely cause: ${causeStr}`;
|
|
320
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// Per-turn cache state management, regression detection, and session persistence.
|
|
2
|
+
|
|
3
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { diffFingerprints, type PromptFingerprint } from "../fingerprint.ts";
|
|
5
|
+
|
|
6
|
+
/** Persisted per-turn cache record. */
|
|
7
|
+
export interface TurnRecord {
|
|
8
|
+
turnIndex: number;
|
|
9
|
+
cacheRead: number;
|
|
10
|
+
cacheWrite: number;
|
|
11
|
+
input: number;
|
|
12
|
+
/** Hit rate as 0–100, or undefined when cacheRead + input === 0. */
|
|
13
|
+
hitRate: number | undefined;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
/** Annotation for the report Note column. */
|
|
16
|
+
note?: string;
|
|
17
|
+
/** Structured cause metadata for later regression diagnosis. */
|
|
18
|
+
cause?: RegressionCause;
|
|
19
|
+
/** Fingerprint of the system prompt used for this turn, if available. */
|
|
20
|
+
promptFingerprint?: PromptFingerprint;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Cause of a detected regression. */
|
|
24
|
+
export type RegressionCause =
|
|
25
|
+
| { type: "compaction" }
|
|
26
|
+
| { type: "model_change"; model: string }
|
|
27
|
+
| { type: "prompt_change" }
|
|
28
|
+
| { type: "unknown" };
|
|
29
|
+
|
|
30
|
+
/** Result of detectRegression() — null means no regression. */
|
|
31
|
+
export type RegressionResult = {
|
|
32
|
+
previousRate: number;
|
|
33
|
+
currentRate: number;
|
|
34
|
+
cause: RegressionCause;
|
|
35
|
+
} | null;
|
|
36
|
+
|
|
37
|
+
/** Usage data extracted from an assistant message. */
|
|
38
|
+
export interface TurnUsage {
|
|
39
|
+
cacheRead: number;
|
|
40
|
+
cacheWrite: number;
|
|
41
|
+
input: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Manages per-turn cache metrics, cause-tracking flags, and regression detection.
|
|
46
|
+
*
|
|
47
|
+
* Designed to be instantiated once per session and reconstructed from
|
|
48
|
+
* persisted session entries on resume.
|
|
49
|
+
*/
|
|
50
|
+
export class CacheMonitorState {
|
|
51
|
+
private turns: TurnRecord[] = [];
|
|
52
|
+
private nextTurnIndex = 1;
|
|
53
|
+
|
|
54
|
+
/** Whether any turn ever reported non-zero cacheRead or cacheWrite. */
|
|
55
|
+
cacheSupported = false;
|
|
56
|
+
|
|
57
|
+
// ── Cause-tracking flags ──────────────────────────────────
|
|
58
|
+
|
|
59
|
+
private compactionFlag = false;
|
|
60
|
+
private modelChangeFlag: string | undefined;
|
|
61
|
+
private lastPromptFingerprint: PromptFingerprint | undefined;
|
|
62
|
+
private promptChangeFlag = false;
|
|
63
|
+
|
|
64
|
+
// ── Turn recording ────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Record a turn from assistant message usage data.
|
|
68
|
+
* Returns the created TurnRecord (for persistence).
|
|
69
|
+
*
|
|
70
|
+
* Pending cause flags are only consumed by turns with a defined hitRate so
|
|
71
|
+
* that provider/tooling gaps do not steal the attribution from the next
|
|
72
|
+
* comparable cache-capable turn.
|
|
73
|
+
*/
|
|
74
|
+
recordTurn(usage: TurnUsage, timestamp: number): TurnRecord {
|
|
75
|
+
const { cacheRead, cacheWrite, input } = usage;
|
|
76
|
+
const hasCacheMetrics = cacheRead > 0 || cacheWrite > 0;
|
|
77
|
+
|
|
78
|
+
// hitRate is undefined when there are no cache metrics at all (provider
|
|
79
|
+
// doesn't report cache tokens) or when the denominator is zero.
|
|
80
|
+
let hitRate: number | undefined;
|
|
81
|
+
if (hasCacheMetrics) {
|
|
82
|
+
const denominator = cacheRead + input;
|
|
83
|
+
hitRate = denominator === 0 ? undefined : Math.round((cacheRead / denominator) * 100);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (hasCacheMetrics) {
|
|
87
|
+
this.cacheSupported = true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const isFirstTurn = this.turns.length === 0;
|
|
91
|
+
const cause = !isFirstTurn && hitRate !== undefined ? this.getPendingCause() : undefined;
|
|
92
|
+
const note = isFirstTurn ? "cold start" : cause ? formatCauseNote(cause) : undefined;
|
|
93
|
+
|
|
94
|
+
const record: TurnRecord = {
|
|
95
|
+
turnIndex: this.nextTurnIndex++,
|
|
96
|
+
cacheRead,
|
|
97
|
+
cacheWrite,
|
|
98
|
+
input,
|
|
99
|
+
hitRate,
|
|
100
|
+
timestamp,
|
|
101
|
+
...(note ? { note } : {}),
|
|
102
|
+
...(cause ? { cause } : {}),
|
|
103
|
+
...(this.lastPromptFingerprint ? { promptFingerprint: this.lastPromptFingerprint } : {}),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
this.turns.push(record);
|
|
107
|
+
|
|
108
|
+
// Clear one-shot cause flags only after a comparable turn consumed them.
|
|
109
|
+
if (hitRate !== undefined) {
|
|
110
|
+
this.clearPendingCause();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return record;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Cause-tracking methods ────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/** Flag that a compaction occurred (consumed on next comparable recordTurn). */
|
|
119
|
+
flagCompaction(): void {
|
|
120
|
+
this.compactionFlag = true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Flag that the model changed (consumed on next comparable recordTurn). */
|
|
124
|
+
flagModelChange(model: string): void {
|
|
125
|
+
this.modelChangeFlag = model;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Store the computed prompt fingerprint. If it differs from the previous
|
|
130
|
+
* fingerprint, flag a prompt change (consumed on next comparable recordTurn).
|
|
131
|
+
*/
|
|
132
|
+
updatePromptFingerprint(fp: PromptFingerprint): void {
|
|
133
|
+
if (this.lastPromptFingerprint !== undefined) {
|
|
134
|
+
const diffs = diffFingerprints(this.lastPromptFingerprint, fp);
|
|
135
|
+
if (diffs.length > 0) {
|
|
136
|
+
this.promptChangeFlag = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
this.lastPromptFingerprint = fp;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Regression detection ──────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Detect a cache regression comparing the latest two adjacent turns.
|
|
146
|
+
* Returns null if either turn lacks comparable cache data.
|
|
147
|
+
*/
|
|
148
|
+
detectRegression(threshold: number): RegressionResult {
|
|
149
|
+
const current = this.getLatestTurn();
|
|
150
|
+
const previous = this.getPreviousTurn();
|
|
151
|
+
if (!current || !previous) return null;
|
|
152
|
+
if (current.hitRate === undefined || previous.hitRate === undefined) return null;
|
|
153
|
+
|
|
154
|
+
const currentRate = current.hitRate;
|
|
155
|
+
const previousRate = previous.hitRate;
|
|
156
|
+
const drop = previousRate - currentRate;
|
|
157
|
+
|
|
158
|
+
if (drop <= threshold) return null;
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
previousRate,
|
|
162
|
+
currentRate,
|
|
163
|
+
cause: resolveTurnCause(current) ?? { type: "unknown" },
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private getPendingCause(): RegressionCause | undefined {
|
|
168
|
+
if (this.compactionFlag) {
|
|
169
|
+
return { type: "compaction" };
|
|
170
|
+
}
|
|
171
|
+
if (this.modelChangeFlag) {
|
|
172
|
+
return { type: "model_change", model: this.modelChangeFlag };
|
|
173
|
+
}
|
|
174
|
+
if (this.promptChangeFlag) {
|
|
175
|
+
return { type: "prompt_change" };
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private clearPendingCause(): void {
|
|
181
|
+
this.compactionFlag = false;
|
|
182
|
+
this.modelChangeFlag = undefined;
|
|
183
|
+
this.promptChangeFlag = false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Session persistence / restoration ─────────────────────
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Reconstruct state from persisted session entries.
|
|
190
|
+
* Filters for `type === "custom"` and `customType === "supi-cache-turn"`.
|
|
191
|
+
*/
|
|
192
|
+
restoreFromEntries(entries: SessionEntry[]): void {
|
|
193
|
+
this.turns = [];
|
|
194
|
+
this.nextTurnIndex = 1;
|
|
195
|
+
this.cacheSupported = false;
|
|
196
|
+
this.compactionFlag = false;
|
|
197
|
+
this.modelChangeFlag = undefined;
|
|
198
|
+
this.lastPromptFingerprint = undefined;
|
|
199
|
+
this.promptChangeFlag = false;
|
|
200
|
+
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
if (entry.type === "custom" && entry.customType === "supi-cache-turn") {
|
|
203
|
+
const data = entry.data as TurnRecord;
|
|
204
|
+
this.turns.push(data);
|
|
205
|
+
this.nextTurnIndex = data.turnIndex + 1;
|
|
206
|
+
if (data.cacheRead > 0 || data.cacheWrite > 0) {
|
|
207
|
+
this.cacheSupported = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Restore last fingerprint from the most recently restored turn so that
|
|
213
|
+
// cross-session prompt-change detection works correctly.
|
|
214
|
+
const lastTurn = this.turns[this.turns.length - 1];
|
|
215
|
+
if (lastTurn?.promptFingerprint) {
|
|
216
|
+
this.lastPromptFingerprint = lastTurn.promptFingerprint;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Accessors ─────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
/** Get all recorded turns. */
|
|
223
|
+
getTurns(): readonly TurnRecord[] {
|
|
224
|
+
return this.turns;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Get the latest turn, or undefined if no turns recorded. */
|
|
228
|
+
getLatestTurn(): TurnRecord | undefined {
|
|
229
|
+
return this.turns.length > 0 ? this.turns[this.turns.length - 1] : undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Get the previous turn (second-to-last), or undefined. */
|
|
233
|
+
getPreviousTurn(): TurnRecord | undefined {
|
|
234
|
+
return this.turns.length > 1 ? this.turns[this.turns.length - 2] : undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Reset all state (e.g. on session shutdown). */
|
|
238
|
+
reset(): void {
|
|
239
|
+
this.turns = [];
|
|
240
|
+
this.nextTurnIndex = 1;
|
|
241
|
+
this.cacheSupported = false;
|
|
242
|
+
this.compactionFlag = false;
|
|
243
|
+
this.modelChangeFlag = undefined;
|
|
244
|
+
this.lastPromptFingerprint = undefined;
|
|
245
|
+
this.promptChangeFlag = false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Get the latest prompt fingerprint (for diffing in regression messages). */
|
|
249
|
+
getLatestFingerprint(): PromptFingerprint | undefined {
|
|
250
|
+
return this.lastPromptFingerprint;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Get the previous turn's prompt fingerprint (for diffing in regression messages). */
|
|
254
|
+
getPreviousFingerprint(): PromptFingerprint | undefined {
|
|
255
|
+
const prev = this.getPreviousTurn();
|
|
256
|
+
return prev?.promptFingerprint;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Shared note-label constants for cache regression causes.
|
|
262
|
+
*
|
|
263
|
+
* Used by `formatCauseNote` for producing the persisted note and by
|
|
264
|
+
* `resolveTurnCause` for reverse-mapping legacy records that only have
|
|
265
|
+
* the note string rather than a structured `cause` field.
|
|
266
|
+
*/
|
|
267
|
+
export const CAUSE_NOTE = {
|
|
268
|
+
compaction: "\u26a0 compaction",
|
|
269
|
+
model_change: "\u26a0 model changed",
|
|
270
|
+
prompt_change: "\u26a0 prompt changed",
|
|
271
|
+
} as const;
|
|
272
|
+
|
|
273
|
+
function formatCauseNote(cause: RegressionCause): string {
|
|
274
|
+
switch (cause.type) {
|
|
275
|
+
case "compaction":
|
|
276
|
+
return CAUSE_NOTE.compaction;
|
|
277
|
+
case "model_change":
|
|
278
|
+
return CAUSE_NOTE.model_change;
|
|
279
|
+
case "prompt_change":
|
|
280
|
+
return CAUSE_NOTE.prompt_change;
|
|
281
|
+
default:
|
|
282
|
+
return "";
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Resolve a TurnRecord's regression cause, falling back to note-based inference
|
|
288
|
+
* for legacy records that only have a `note` string (no structured `cause`).
|
|
289
|
+
*
|
|
290
|
+
* Legacy ``note: "⚠ model changed"`` records resolve to `model: "unknown"` because
|
|
291
|
+
* the note format does not preserve the model identifier.
|
|
292
|
+
*/
|
|
293
|
+
export function resolveTurnCause(turn: TurnRecord): RegressionCause | undefined {
|
|
294
|
+
if (turn.cause) {
|
|
295
|
+
return turn.cause;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
switch (turn.note) {
|
|
299
|
+
case CAUSE_NOTE.compaction:
|
|
300
|
+
return { type: "compaction" };
|
|
301
|
+
case CAUSE_NOTE.model_change:
|
|
302
|
+
return { type: "model_change", model: "unknown" };
|
|
303
|
+
case CAUSE_NOTE.prompt_change:
|
|
304
|
+
return { type: "prompt_change" };
|
|
305
|
+
default:
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Footer status line formatting for cache health.
|
|
2
|
+
|
|
3
|
+
import type { CacheMonitorState } from "./state.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Format the compact footer status string.
|
|
7
|
+
*
|
|
8
|
+
* - `cache: 87% ↑` — hit rate with trend arrow
|
|
9
|
+
* - `cache: 0%` — first turn (no trend)
|
|
10
|
+
* - `cache: —` — no cache data available
|
|
11
|
+
*/
|
|
12
|
+
export function formatCacheStatus(state: CacheMonitorState): string | undefined {
|
|
13
|
+
const latest = state.getLatestTurn();
|
|
14
|
+
if (!latest) return undefined;
|
|
15
|
+
|
|
16
|
+
// No cache data: unsupported provider or zero denominator
|
|
17
|
+
if (!state.cacheSupported || latest.hitRate === undefined) {
|
|
18
|
+
return "cache: —";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const previous = state.getPreviousTurn();
|
|
22
|
+
let trend = "";
|
|
23
|
+
|
|
24
|
+
if (previous?.hitRate !== undefined) {
|
|
25
|
+
if (latest.hitRate > previous.hitRate) {
|
|
26
|
+
trend = " ↑";
|
|
27
|
+
} else if (latest.hitRate < previous.hitRate) {
|
|
28
|
+
trend = " ↓";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return `cache: ${latest.hitRate}%${trend}`;
|
|
33
|
+
}
|