pi-mega-compact 0.11.13 → 0.12.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/dist/extensions/dashboard-server/routes-health.js +81 -0
- package/dist/extensions/dashboard-server/routes.js +1 -0
- package/dist/extensions/dashboard-server/server.js +5 -1
- package/dist/extensions/mega-config.js +5 -0
- package/dist/extensions/mega-events/agent-handlers.js +5 -0
- package/dist/extensions/mega-events/health-handler.js +156 -0
- package/dist/extensions/mega-runtime/runtime.js +6 -0
- package/dist/src/contextHealth/cachePoison.js +183 -0
- package/dist/src/contextHealth/drift.js +89 -0
- package/dist/src/contextHealth/outputQuality.js +112 -0
- package/dist/src/contextHealth.js +110 -0
- package/dist/src/store/sqlite/context-health.js +193 -0
- package/dist/src/store/sqlite/schema.js +43 -1
- package/dist/src/store/sqlite.js +1 -0
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-9KQgKBEh.js → AchievementTiles-DeQ0qnBX.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-9KQgKBEh.js.map → AchievementTiles-DeQ0qnBX.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-B6xoQTEa.js → AchievementsTab-Dtk_RJfs.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-B6xoQTEa.js.map → AchievementsTab-Dtk_RJfs.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{CacheTab-Dkm9rawo.js → CacheTab-kWcQt3PI.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{CacheTab-Dkm9rawo.js.map → CacheTab-kWcQt3PI.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ConfigTab-Bd_v_OUF.js → ConfigTab-UFlo73fR.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ConfigTab-Bd_v_OUF.js.map → ConfigTab-UFlo73fR.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{EventsTab-CYWoFOdI.js → EventsTab-BENO2N0y.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{EventsTab-CYWoFOdI.js.map → EventsTab-BENO2N0y.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{GameTab-CH7lecNL.js → GameTab-BCJavOML.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{GameTab-CH7lecNL.js.map → GameTab-BCJavOML.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/HealthTab-C7iw7rhx.js +2 -0
- package/extensions/dashboard-client/dist/assets/HealthTab-C7iw7rhx.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DNZ1IF4t.js → MaintenanceTab-C0rdAMWc.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DNZ1IF4t.js.map → MaintenanceTab-C0rdAMWc.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MemoryMapTab-tVRREYiG.js → MemoryMapTab-w5BZP3i-.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MemoryMapTab-tVRREYiG.js.map → MemoryMapTab-w5BZP3i-.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CHblYuTY.js → MetricsTab-BBCXa3xS.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CHblYuTY.js.map → MetricsTab-BBCXa3xS.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-Dj0TgFvU.js → OverviewTab-CzABMSAQ.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-Dj0TgFvU.js.map → OverviewTab-CzABMSAQ.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ReposTab-BTJcfc1W.js → ReposTab-DFiM_Ubg.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-BTJcfc1W.js.map → ReposTab-DFiM_Ubg.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SessionsTab-C8tU8K5u.js → SessionsTab-BtW4bees.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SessionsTab-C8tU8K5u.js.map → SessionsTab-BtW4bees.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SetupTab-BdxGMo6O.js → SetupTab-Duy1LJhg.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SetupTab-BdxGMo6O.js.map → SetupTab-Duy1LJhg.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-BWZSARzT.js → TimeSavedCard-CC13EXij.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-BWZSARzT.js.map → TimeSavedCard-CC13EXij.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TopicsTab-BCJwPYl8.js → TopicsTab-BrTB8uEK.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TopicsTab-BCJwPYl8.js.map → TopicsTab-BrTB8uEK.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TurnsTab-DQFTSDpz.js → TurnsTab-BJK7FIHu.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TurnsTab-DQFTSDpz.js.map → TurnsTab-BJK7FIHu.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{index-DrrDswj-.js → index-B8k19B9g.js} +11 -11
- package/extensions/dashboard-client/dist/assets/{index-DrrDswj-.js.map → index-B8k19B9g.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{useSSE-o4tteeTs.js → useSSE-CWBB4Me7.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{useSSE-o4tteeTs.js.map → useSSE-CWBB4Me7.js.map} +1 -1
- package/extensions/dashboard-client/dist/index.html +1 -1
- package/extensions/dashboard-client/src/App.tsx +5 -1
- package/extensions/dashboard-client/src/api/health.ts +64 -0
- package/extensions/dashboard-client/src/tabs/HealthTab.tsx +165 -0
- package/extensions/dashboard-server/routes-health.ts +104 -0
- package/extensions/dashboard-server/routes.ts +1 -0
- package/extensions/dashboard-server/server.ts +4 -0
- package/extensions/mega-config.ts +15 -0
- package/extensions/mega-events/agent-handlers.ts +6 -0
- package/extensions/mega-events/health-handler.ts +190 -0
- package/extensions/mega-runtime/runtime.ts +7 -0
- package/package.json +1 -1
- package/src/contextHealth/cachePoison.ts +243 -0
- package/src/contextHealth/drift.ts +107 -0
- package/src/contextHealth/outputQuality.ts +139 -0
- package/src/contextHealth.ts +191 -0
- package/src/store/sqlite/context-health.ts +342 -0
- package/src/store/sqlite/schema.ts +43 -1
- package/src/store/sqlite.ts +1 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* routes-health.ts — Context Health + KV Cache Poison route handlers.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/context-health — recent health rows, latest composite, trend,
|
|
5
|
+
* per-model averages, alert flags. Accepts ?minutes=N (default 30).
|
|
6
|
+
* GET /api/cache-poison — recent cache poison events (last 30 min).
|
|
7
|
+
*
|
|
8
|
+
* Guardrails: PREVENT-PI-004 (loopback-only), PREVENT-001 (null-safe),
|
|
9
|
+
* PREVENT-002 (parameterized SQL via accessors). Localhost dashboard.
|
|
10
|
+
*/
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
export function handleContextHealth(req, res, ctx) {
|
|
13
|
+
if (!req.url?.startsWith("/api/context-health"))
|
|
14
|
+
return false;
|
|
15
|
+
try {
|
|
16
|
+
if (req.method !== "GET") {
|
|
17
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
18
|
+
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
const url = new URL(req.url, "http://localhost");
|
|
22
|
+
const minutes = Math.max(1, Number(url.searchParams.get("minutes") ?? 30));
|
|
23
|
+
const sinceTs = Date.now() - minutes * 60_000;
|
|
24
|
+
const pfReq = createRequire(import.meta.url);
|
|
25
|
+
const { readLatestContextHealth, readContextHealthTrend, readContextHealthRows, } = pfReq("../../src/store/sqlite.js");
|
|
26
|
+
const latest = readLatestContextHealth(ctx.stateDir);
|
|
27
|
+
const trend = readContextHealthTrend(ctx.stateDir, 50);
|
|
28
|
+
const rows = readContextHealthRows(ctx.stateDir, sinceTs);
|
|
29
|
+
const perModel = new Map();
|
|
30
|
+
for (const r of rows) {
|
|
31
|
+
const mid = r.modelId ?? "(unknown)";
|
|
32
|
+
const entry = perModel.get(mid) ?? { sum: 0, count: 0 };
|
|
33
|
+
entry.sum += r.composite;
|
|
34
|
+
entry.count += 1;
|
|
35
|
+
perModel.set(mid, entry);
|
|
36
|
+
}
|
|
37
|
+
const perModelAverages = [...perModel.entries()].map(([modelId, v]) => ({
|
|
38
|
+
modelId,
|
|
39
|
+
avgComposite: v.count > 0 ? v.sum / v.count : 0,
|
|
40
|
+
sampleCount: v.count,
|
|
41
|
+
}));
|
|
42
|
+
const alerts = rows.filter((r) => r.composite < 0.5 || r.cachePoison < 0.3);
|
|
43
|
+
const body = {
|
|
44
|
+
updatedAt: new Date().toISOString(),
|
|
45
|
+
latest,
|
|
46
|
+
trend,
|
|
47
|
+
rows: rows.slice(-200),
|
|
48
|
+
perModel: perModelAverages,
|
|
49
|
+
alerts,
|
|
50
|
+
};
|
|
51
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
52
|
+
res.end(JSON.stringify(body));
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
56
|
+
res.end(JSON.stringify({ error: String(e) }));
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
export function handleCachePoison(req, res, ctx) {
|
|
61
|
+
if (!req.url?.startsWith("/api/cache-poison"))
|
|
62
|
+
return false;
|
|
63
|
+
try {
|
|
64
|
+
if (req.method !== "GET") {
|
|
65
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
66
|
+
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
const sinceTs = Date.now() - 30 * 60_000;
|
|
70
|
+
const pfReq = createRequire(import.meta.url);
|
|
71
|
+
const { readCachePoisonEvents } = pfReq("../../src/store/sqlite.js");
|
|
72
|
+
const events = readCachePoisonEvents(ctx.stateDir, sinceTs);
|
|
73
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
74
|
+
res.end(JSON.stringify({ updatedAt: new Date().toISOString(), events }));
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
78
|
+
res.end(JSON.stringify({ error: String(e) }));
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
@@ -16,3 +16,4 @@ export { handleMemoryStatus } from "./routes-memory.js";
|
|
|
16
16
|
export { handleSetupStatus, handleSetupDetect, handleSetupConfigure } from "./routes-setup.js";
|
|
17
17
|
export { handleMemoryMap } from "./routes-memory-map.js";
|
|
18
18
|
export { handleCacheStripes } from "./routes-cache.js";
|
|
19
|
+
export { handleContextHealth, handleCachePoison } from "./routes-health.js";
|
|
@@ -12,7 +12,7 @@ import { join, dirname } from "node:path";
|
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { createRequire } from "node:module";
|
|
14
14
|
import { log, setLogPath, setDashboardServerVersion } from "./state.js";
|
|
15
|
-
import { buildRouteContext, handleIndex, handleRepoIndex, handleEvents, handleGameState, handleGameScores, handlePerf, handleAchievements, handleSessions, handleTopics, handleTurns, handleMaintenance, handleProviderCache, handleCacheStripes, handleMemoryStatus, handleSetupStatus, handleSetupDetect, handleSetupConfigure, handleMemoryMap, handleStatic, } from "./routes.js";
|
|
15
|
+
import { buildRouteContext, handleIndex, handleRepoIndex, handleEvents, handleGameState, handleGameScores, handlePerf, handleAchievements, handleSessions, handleTopics, handleTurns, handleMaintenance, handleProviderCache, handleCacheStripes, handleMemoryStatus, handleSetupStatus, handleSetupDetect, handleSetupConfigure, handleMemoryMap, handleContextHealth, handleCachePoison, handleStatic, } from "./routes.js";
|
|
16
16
|
export async function launchDashboardServer(stateDir) {
|
|
17
17
|
// Our own package version — exposed at /api/version so the launcher can
|
|
18
18
|
// detect a stale server (started by an older build) and replace it on
|
|
@@ -207,6 +207,10 @@ export async function launchDashboardServer(stateDir) {
|
|
|
207
207
|
return;
|
|
208
208
|
if (handleMemoryMap(req, res, ctx))
|
|
209
209
|
return;
|
|
210
|
+
if (handleContextHealth(req, res, ctx))
|
|
211
|
+
return;
|
|
212
|
+
if (handleCachePoison(req, res, ctx))
|
|
213
|
+
return;
|
|
210
214
|
handleStatic(req, res, ctx);
|
|
211
215
|
});
|
|
212
216
|
// Bind base + range are env-configurable so tests can use a private,
|
|
@@ -180,6 +180,11 @@ export function loadConfig() {
|
|
|
180
180
|
memoryGraphSeedMemories: envBool("MEGACOMPACT_MEMORY_GRAPH_SEED_MEMORIES", true),
|
|
181
181
|
memoryGraphCrossTypeThreshold: envFlag("MEGACOMPACT_MEMORY_GRAPH_CROSS_TYPE_THRESHOLD", 0.85),
|
|
182
182
|
memoryGraphWithinTypeThreshold: envFlag("MEGACOMPACT_MEMORY_GRAPH_WITHIN_TYPE_THRESHOLD", 0.7),
|
|
183
|
+
contextHealth: envBool("MEGACOMPACT_CONTEXT_HEALTH", true),
|
|
184
|
+
contextHealthDrift: envBool("MEGACOMPACT_CONTEXT_HEALTH_DRIFT", true),
|
|
185
|
+
contextHealthOutputQuality: envBool("MEGACOMPACT_CONTEXT_HEALTH_OUTPUT_QUALITY", true),
|
|
186
|
+
contextHealthCachePoison: envBool("MEGACOMPACT_CONTEXT_HEALTH_CACHE_POISON", true),
|
|
187
|
+
contextHealthMitigate: envBool("MEGACOMPACT_CONTEXT_HEALTH_MITIGATE", false),
|
|
183
188
|
// D.1: env-overridable recompact delta (minimum context growth % before
|
|
184
189
|
// re-compacting instead of replaying the cached live trim). Default 50.
|
|
185
190
|
recompactPctDelta: envFlag("MEGACOMPACT_RECOMPACT_PCT_DELTA", 50),
|
|
@@ -12,6 +12,7 @@ import { vectorStats } from "../../src/vectorStore.js";
|
|
|
12
12
|
import { defaultEmbedder } from "../../src/embedder.js";
|
|
13
13
|
import { storeTopicEmbedding, loadTopicEmbedding, detectTopicShift, } from "./separated-prompt.js";
|
|
14
14
|
import { refreshStripeAssignments as writeStripeAssignments, } from "../../src/cache-stripe-impl.js";
|
|
15
|
+
import { handleTurnEndHealth } from "./health-handler.js";
|
|
15
16
|
/** Register agent/turn tracking event handlers. */
|
|
16
17
|
export function registerAgentHandlers(pi, runtime, config) {
|
|
17
18
|
// ---- Agent tracking for real-time widget + status-line updates ---------
|
|
@@ -252,6 +253,7 @@ export function registerAgentHandlers(pi, runtime, config) {
|
|
|
252
253
|
pi.on("turn_end", async (event, ctx) => {
|
|
253
254
|
runtime.dashboard.event("turn_end", { turnIndex: event.turnIndex });
|
|
254
255
|
runtime.snapshot(ctx);
|
|
256
|
+
runtime.lastErrorCategory = null;
|
|
255
257
|
// S53: consume staged recall blocks ONLY if they were actually injected
|
|
256
258
|
// into a view this turn. If no context event fired (edge: turn ended
|
|
257
259
|
// before any LLM call), recallInjectedThisTurn stays false and the blocks
|
|
@@ -372,6 +374,7 @@ export function registerAgentHandlers(pi, runtime, config) {
|
|
|
372
374
|
else {
|
|
373
375
|
const category = classifyError(event.message);
|
|
374
376
|
const detail = classifyErrorDetailed(event.message);
|
|
377
|
+
runtime.lastErrorCategory = category;
|
|
375
378
|
// R3: stateful poisoned-context signal — repeated identical error text
|
|
376
379
|
// across consecutive turns. The classifier is stateless, so this
|
|
377
380
|
// upgrade happens here. A 'transient' (or 'poisoned-context') turn
|
|
@@ -781,5 +784,7 @@ export function registerAgentHandlers(pi, runtime, config) {
|
|
|
781
784
|
/* non-fatal: stripe refresh never breaks the agent loop */
|
|
782
785
|
}
|
|
783
786
|
}
|
|
787
|
+
// v0.12: Context Health — compute + persist health score. Non-fatal.
|
|
788
|
+
handleTurnEndHealth(event, runtime, config);
|
|
784
789
|
});
|
|
785
790
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* health-handler.ts — Context Health turn_end hook (v0.12).
|
|
3
|
+
*
|
|
4
|
+
* Computes a composite 0-1 health score per turn from five sub-scores
|
|
5
|
+
* (drift, output quality, error rate, cache health, cache poison),
|
|
6
|
+
* persists it to the context_health SQLite table, and emits a dashboard
|
|
7
|
+
* event. All paths non-fatal — health monitoring never breaks the agent loop.
|
|
8
|
+
*
|
|
9
|
+
* PREVENT-PI-004: zero network (local TrigramEmbedder + SQLite only).
|
|
10
|
+
*/
|
|
11
|
+
import { defaultEmbedder } from "../../src/embedder.js";
|
|
12
|
+
import { computeOutputQuality } from "../../src/contextHealth/outputQuality.js";
|
|
13
|
+
import { computeTopicDrift, computeErrorEscalation, computePrefixInstability, computeDriftScore, } from "../../src/contextHealth/drift.js";
|
|
14
|
+
import { computePrefixHash, checkPrefixHash, evaluateCachePoison, } from "../../src/contextHealth/cachePoison.js";
|
|
15
|
+
import { computeHealthScore, recordContextHealth, } from "../../src/contextHealth.js";
|
|
16
|
+
import { recordCachePoisonEvent, } from "../../src/store/sqlite/context-health.js";
|
|
17
|
+
const RING_MAX = 5;
|
|
18
|
+
/** Extract assistant text from the turn_end event message. */
|
|
19
|
+
function extractAssistantText(event) {
|
|
20
|
+
const msg = event.message;
|
|
21
|
+
if (!msg || msg.role !== "assistant")
|
|
22
|
+
return "";
|
|
23
|
+
const c = msg.content;
|
|
24
|
+
if (typeof c === "string")
|
|
25
|
+
return c;
|
|
26
|
+
if (Array.isArray(c)) {
|
|
27
|
+
return c
|
|
28
|
+
.map((p) => (p && typeof p === "object" && "text" in p ? String(p.text ?? "") : ""))
|
|
29
|
+
.join(" ");
|
|
30
|
+
}
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
/** Extract messages as string array for prefix hash (cache poison Layer 1). */
|
|
34
|
+
function extractMessageTexts(event) {
|
|
35
|
+
const c = event?.message?.content;
|
|
36
|
+
if (typeof c === "string")
|
|
37
|
+
return [c];
|
|
38
|
+
if (Array.isArray(c)) {
|
|
39
|
+
return c.map((p) => (p && typeof p === "object" && "text" in p ? String(p.text ?? "") : ""));
|
|
40
|
+
}
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
/** Handle turn_end: compute + persist context health. Non-fatal. */
|
|
44
|
+
export function handleTurnEndHealth(event, runtime, config) {
|
|
45
|
+
if (!config.contextHealth)
|
|
46
|
+
return;
|
|
47
|
+
try {
|
|
48
|
+
const text = extractAssistantText(event);
|
|
49
|
+
const embedder = defaultEmbedder();
|
|
50
|
+
const emb = text.length > 0 ? embedder.embed(text) : [];
|
|
51
|
+
// Output quality sub-score
|
|
52
|
+
let outputQuality = 1.0;
|
|
53
|
+
let repetitionRatio = 0;
|
|
54
|
+
let coherenceScore = 1.0;
|
|
55
|
+
if (config.contextHealthOutputQuality && text.length > 0) {
|
|
56
|
+
const oq = computeOutputQuality(text, embedder);
|
|
57
|
+
outputQuality = oq.score;
|
|
58
|
+
repetitionRatio = oq.repetitionRatio;
|
|
59
|
+
coherenceScore = oq.coherenceScore;
|
|
60
|
+
}
|
|
61
|
+
// Drift sub-score
|
|
62
|
+
let drift = 1.0;
|
|
63
|
+
if (config.contextHealthDrift) {
|
|
64
|
+
const topic = computeTopicDrift(emb, runtime.recentTurnEmbeddings);
|
|
65
|
+
const error = computeErrorEscalation(runtime.recentErrorCategories);
|
|
66
|
+
const prefix = computePrefixInstability(0, 5);
|
|
67
|
+
drift = computeDriftScore(topic, error, prefix);
|
|
68
|
+
}
|
|
69
|
+
// Error rate sub-score (1 = no errors)
|
|
70
|
+
const errorScore = computeErrorEscalation(runtime.recentErrorCategories);
|
|
71
|
+
// Cache health (from perf-handler)
|
|
72
|
+
const cacheHealth = runtime.rt._lastCacheHealthScore ?? 1.0;
|
|
73
|
+
// Cache poison sub-score
|
|
74
|
+
let cachePoison = 1.0;
|
|
75
|
+
if (config.contextHealthCachePoison) {
|
|
76
|
+
const msgTexts = extractMessageTexts(event);
|
|
77
|
+
const currentHash = computePrefixHash(msgTexts);
|
|
78
|
+
const cacheRead = event.message?.usage?.cacheRead ?? 0;
|
|
79
|
+
const l1 = checkPrefixHash(currentHash, runtime.lastPrefixHash, cacheRead);
|
|
80
|
+
const result = evaluateCachePoison({
|
|
81
|
+
currentHash,
|
|
82
|
+
storedHash: runtime.lastPrefixHash,
|
|
83
|
+
cacheRead,
|
|
84
|
+
qualityByCacheHit: [],
|
|
85
|
+
qualityByCacheMiss: [],
|
|
86
|
+
errorRateCacheHit: 0,
|
|
87
|
+
errorRateCacheMiss: 0,
|
|
88
|
+
sampleCount: 0,
|
|
89
|
+
});
|
|
90
|
+
cachePoison = result.score;
|
|
91
|
+
if (l1.poisoned) {
|
|
92
|
+
try {
|
|
93
|
+
recordCachePoisonEvent(runtime.currentStateDir, {
|
|
94
|
+
ts: Date.now(),
|
|
95
|
+
turnIndex: event.turnIndex,
|
|
96
|
+
sessionId: runtime.rt.sessionId,
|
|
97
|
+
layer: 1,
|
|
98
|
+
detail: l1.detail,
|
|
99
|
+
severity: "warn",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch { /* non-fatal */ }
|
|
103
|
+
}
|
|
104
|
+
runtime.lastPrefixHash = currentHash;
|
|
105
|
+
}
|
|
106
|
+
// Composite
|
|
107
|
+
const sub = {
|
|
108
|
+
drift,
|
|
109
|
+
outputQuality,
|
|
110
|
+
errorRate: errorScore,
|
|
111
|
+
cacheHealth,
|
|
112
|
+
cachePoison,
|
|
113
|
+
};
|
|
114
|
+
const composite = computeHealthScore(sub);
|
|
115
|
+
// Persist
|
|
116
|
+
recordContextHealth(runtime.currentStateDir, {
|
|
117
|
+
ts: Date.now(),
|
|
118
|
+
turnIndex: event.turnIndex,
|
|
119
|
+
sessionId: runtime.rt.sessionId,
|
|
120
|
+
driftScore: drift,
|
|
121
|
+
outputQuality,
|
|
122
|
+
errorScore,
|
|
123
|
+
cacheHealth,
|
|
124
|
+
cachePoison,
|
|
125
|
+
composite,
|
|
126
|
+
modelId: runtime.currentModel?.modelId,
|
|
127
|
+
repetitionRatio,
|
|
128
|
+
coherenceScore,
|
|
129
|
+
prefixHash: runtime.lastPrefixHash ?? undefined,
|
|
130
|
+
});
|
|
131
|
+
// Emit dashboard event
|
|
132
|
+
runtime.dashboard.event("context_health", {
|
|
133
|
+
composite,
|
|
134
|
+
drift,
|
|
135
|
+
outputQuality,
|
|
136
|
+
errorRate: errorScore,
|
|
137
|
+
cacheHealth,
|
|
138
|
+
cachePoison,
|
|
139
|
+
turnIndex: event.turnIndex,
|
|
140
|
+
});
|
|
141
|
+
// Update ring buffers
|
|
142
|
+
runtime.recentTurnEmbeddings.push(emb);
|
|
143
|
+
if (runtime.recentTurnEmbeddings.length > RING_MAX) {
|
|
144
|
+
runtime.recentTurnEmbeddings.shift();
|
|
145
|
+
}
|
|
146
|
+
runtime.recentErrorCategories.push(runtime.lastErrorCategory);
|
|
147
|
+
if (runtime.recentErrorCategories.length > RING_MAX) {
|
|
148
|
+
runtime.recentErrorCategories.shift();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
runtime.logger?.error("context_health_failed", {
|
|
153
|
+
error: String(e instanceof Error ? e.message : e),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -215,6 +215,12 @@ export class MegaRuntime {
|
|
|
215
215
|
diagCtxRunSkipped = 0; // runCompact() returned skipped
|
|
216
216
|
diagCtxCutNull = 0; // computeLiveTrimCut returned null (anchor/boundary)
|
|
217
217
|
diagCtxThrown = 0; // live-trim try threw (caught)
|
|
218
|
+
// Context health instrumentation (v0.12): rolling ring buffers for
|
|
219
|
+
// drift detection + cache poison Layer 1 hash baseline.
|
|
220
|
+
recentTurnEmbeddings = [];
|
|
221
|
+
recentErrorCategories = [];
|
|
222
|
+
lastPrefixHash = null;
|
|
223
|
+
lastErrorCategory = null;
|
|
218
224
|
/**
|
|
219
225
|
* S26 capture instrumentation: the "model_snapshots empty → $0.00 cost card"
|
|
220
226
|
* bug was invisible because captureModel swallowed the DB write in a silent
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contextHealth/cachePoison.ts — tri-layer KV cache poison validator (v0.9.1).
|
|
3
|
+
*
|
|
4
|
+
* Three validation layers for detecting corrupted KV cache state. Each layer
|
|
5
|
+
* triggers independently; the composite score maps the number of triggered
|
|
6
|
+
* layers to a 0-1 health metric (1 = healthy, 0 = poisoned).
|
|
7
|
+
*
|
|
8
|
+
* Pure functions — zero I/O, zero SQLite, zero network. All inputs are
|
|
9
|
+
* primitive values or arrays passed in; this module makes no external calls
|
|
10
|
+
* and carries no state between invocations.
|
|
11
|
+
*
|
|
12
|
+
* Guardrails: no `any` (PREVENT-011), no un-null-checked JSON.parse
|
|
13
|
+
* (PREVENT-001), zero network (PREVENT-PI-004 trivially satisfied — this
|
|
14
|
+
* module never calls fetch or opens sockets).
|
|
15
|
+
*/
|
|
16
|
+
/** FNV-1a 32-bit offset basis (must match TrigramEmbedder._embedRaw). */
|
|
17
|
+
const FNV_OFFSET_BASIS = 0x811c9dc5;
|
|
18
|
+
/** FNV-1a 32-bit prime (must match TrigramEmbedder._embedRaw). */
|
|
19
|
+
const FNV_PRIME = 0x01000193;
|
|
20
|
+
/** Max input characters for prefix hash (4 KB of UTF-16). */
|
|
21
|
+
const PREFIX_HASH_CAP = 4096;
|
|
22
|
+
// ─── Layer 1 — Hash validation ──────────────────────────────────────────────
|
|
23
|
+
/**
|
|
24
|
+
* Compute a stable 32-bit FNV-1a hash over the first 4 KB of the concatenated
|
|
25
|
+
* message text. Returns a lower-case hex string.
|
|
26
|
+
*
|
|
27
|
+
* The algorithm is identical to `TrigramEmbedder._embedRaw`'s internal hash
|
|
28
|
+
* (offset basis 0x811c9dc5, prime 0x01000193), so any change visible to the
|
|
29
|
+
* embedder is also visible here.
|
|
30
|
+
*/
|
|
31
|
+
export function computePrefixHash(messages) {
|
|
32
|
+
let combined = "";
|
|
33
|
+
for (let i = 0; i < messages.length; i++) {
|
|
34
|
+
combined += messages[i];
|
|
35
|
+
if (combined.length >= PREFIX_HASH_CAP)
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
const input = combined.length > PREFIX_HASH_CAP
|
|
39
|
+
? combined.slice(0, PREFIX_HASH_CAP)
|
|
40
|
+
: combined;
|
|
41
|
+
let h = FNV_OFFSET_BASIS;
|
|
42
|
+
for (let i = 0; i < input.length; i++) {
|
|
43
|
+
h ^= input.charCodeAt(i);
|
|
44
|
+
h = Math.imul(h, FNV_PRIME);
|
|
45
|
+
}
|
|
46
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Layer 1: detect when the KV cache prefix has been silently mutated.
|
|
50
|
+
*
|
|
51
|
+
* Trigger conditions:
|
|
52
|
+
* - cacheRead > 0 (cache was actually used)
|
|
53
|
+
* - storedHash !== null (we have a baseline from a prior turn)
|
|
54
|
+
* - currentHash !== storedHash (prefix text has drifted)
|
|
55
|
+
*
|
|
56
|
+
* Not triggered when: cache was never read, or no baseline exists (first turn).
|
|
57
|
+
*/
|
|
58
|
+
export function checkPrefixHash(currentHash, storedHash, cacheRead) {
|
|
59
|
+
if (cacheRead === 0) {
|
|
60
|
+
return { poisoned: false, detail: "no cache read" };
|
|
61
|
+
}
|
|
62
|
+
if (storedHash === null) {
|
|
63
|
+
return { poisoned: false, detail: "no stored hash baseline" };
|
|
64
|
+
}
|
|
65
|
+
if (currentHash !== storedHash) {
|
|
66
|
+
return {
|
|
67
|
+
poisoned: true,
|
|
68
|
+
detail: `prefix hash mismatch: cached prefix changed without cache invalidation (current=${currentHash} stored=${storedHash})`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { poisoned: false, detail: "prefix hash matches baseline" };
|
|
72
|
+
}
|
|
73
|
+
// ─── Layer 2 — Semantic validation ──────────────────────────────────────────
|
|
74
|
+
/** Arithmetic mean of a non-empty number array. */
|
|
75
|
+
function mean(values) {
|
|
76
|
+
let sum = 0;
|
|
77
|
+
for (const v of values)
|
|
78
|
+
sum += v;
|
|
79
|
+
return sum / values.length;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Layer 2: detect when cache-hit outputs are semantically worse than cache-miss
|
|
83
|
+
* outputs.
|
|
84
|
+
*
|
|
85
|
+
* Trigger conditions:
|
|
86
|
+
* - both groups have at least 3 samples (statistical floor)
|
|
87
|
+
* - cache-hit mean quality is more than 0.15 below cache-miss mean quality
|
|
88
|
+
*
|
|
89
|
+
* Not triggered when: insufficient data in either group.
|
|
90
|
+
*/
|
|
91
|
+
export function compareOutputQualityByCacheHit(qualityByCacheHit, qualityByCacheMiss) {
|
|
92
|
+
const hitN = qualityByCacheHit.length;
|
|
93
|
+
const missN = qualityByCacheMiss.length;
|
|
94
|
+
if (hitN < 3 || missN < 3) {
|
|
95
|
+
return {
|
|
96
|
+
poisoned: false,
|
|
97
|
+
detail: `insufficient quality samples: hit=${hitN} miss=${missN} (need >= 3 each)`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const hitMean = mean(qualityByCacheHit);
|
|
101
|
+
const missMean = mean(qualityByCacheMiss);
|
|
102
|
+
const threshold = missMean - 0.15;
|
|
103
|
+
if (hitMean < threshold) {
|
|
104
|
+
return {
|
|
105
|
+
poisoned: true,
|
|
106
|
+
detail: `cache-hit quality degraded: hitMean=${hitMean.toFixed(3)} missMean=${missMean.toFixed(3)} (threshold=${threshold.toFixed(3)})`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
poisoned: false,
|
|
111
|
+
detail: `quality comparable: hitMean=${hitMean.toFixed(3)} missMean=${missMean.toFixed(3)}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// ─── Layer 3 — Behavioral validation ────────────────────────────────────────
|
|
115
|
+
/**
|
|
116
|
+
* Layer 3: detect when errors cluster on cache-hit turns (indicating the cache
|
|
117
|
+
* is returning corrupt state rather than fresh computation catching them).
|
|
118
|
+
*
|
|
119
|
+
* Trigger conditions (all three must hold):
|
|
120
|
+
* - sampleCount >= 5
|
|
121
|
+
* - errorRateCacheHit > errorRateCacheMiss * 2 (hit error rate is >2x miss rate)
|
|
122
|
+
* - errorRateCacheHit > 0.1 (absolute hit error rate is non-trivial)
|
|
123
|
+
*
|
|
124
|
+
* Not triggered when: insufficient observations.
|
|
125
|
+
*/
|
|
126
|
+
export function correlateErrorsWithCacheHits(errorRateCacheHit, errorRateCacheMiss, sampleCount) {
|
|
127
|
+
if (sampleCount < 5) {
|
|
128
|
+
return {
|
|
129
|
+
poisoned: false,
|
|
130
|
+
detail: `insufficient behavioral samples: n=${sampleCount} (need >= 5)`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (errorRateCacheHit > errorRateCacheMiss * 2 &&
|
|
134
|
+
errorRateCacheHit > 0.1) {
|
|
135
|
+
return {
|
|
136
|
+
poisoned: true,
|
|
137
|
+
detail: `error rate inflated on cache hits: hit=${errorRateCacheHit.toFixed(3)} miss=${errorRateCacheMiss.toFixed(3)} (2x breach + absolute floor)`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
poisoned: false,
|
|
142
|
+
detail: `error rates normal: hit=${errorRateCacheHit.toFixed(3)} miss=${errorRateCacheMiss.toFixed(3)}`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// ─── Composite score ─────────────────────────────────────────────────────────
|
|
146
|
+
/**
|
|
147
|
+
* Compute a 0-1 composite cache poison score from layer triggers.
|
|
148
|
+
*
|
|
149
|
+
* | triggered layers | score |
|
|
150
|
+
* |-----------------|-------|
|
|
151
|
+
* | 0 | 1.0 |
|
|
152
|
+
* | 1 | 0.3 |
|
|
153
|
+
* | 2 | 0.15 |
|
|
154
|
+
* | 3 | 0.0 |
|
|
155
|
+
*
|
|
156
|
+
* Higher = healthier. Scores below ~0.3 warrant immediate cache invalidation.
|
|
157
|
+
*/
|
|
158
|
+
export function computeCachePoisonScore(l1, l2, l3) {
|
|
159
|
+
const triggers = (l1 ? 1 : 0) + (l2 ? 1 : 0) + (l3 ? 1 : 0);
|
|
160
|
+
switch (triggers) {
|
|
161
|
+
case 0: return 1.0;
|
|
162
|
+
case 1: return 0.3;
|
|
163
|
+
case 2: return 0.15;
|
|
164
|
+
case 3: return 0.0;
|
|
165
|
+
default: return 0.0; // should never reach
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Run all three validation layers and return a composite result.
|
|
170
|
+
*
|
|
171
|
+
* All arguments are primitive values; this function is pure and stateless.
|
|
172
|
+
*/
|
|
173
|
+
export function evaluateCachePoison(args) {
|
|
174
|
+
const l1 = checkPrefixHash(args.currentHash, args.storedHash, args.cacheRead);
|
|
175
|
+
const l2 = compareOutputQualityByCacheHit(args.qualityByCacheHit, args.qualityByCacheMiss);
|
|
176
|
+
const l3 = correlateErrorsWithCacheHits(args.errorRateCacheHit, args.errorRateCacheMiss, args.sampleCount);
|
|
177
|
+
return {
|
|
178
|
+
score: computeCachePoisonScore(l1.poisoned, l2.poisoned, l3.poisoned),
|
|
179
|
+
layer1: l1,
|
|
180
|
+
layer2: l2,
|
|
181
|
+
layer3: l3,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { cosineSimilarity } from "../embedder.js";
|
|
2
|
+
// ─── Topic drift ────────────────────────────────────────────────────────────
|
|
3
|
+
/**
|
|
4
|
+
* Compute topic drift as cosine similarity between the current embedding and
|
|
5
|
+
* the centroid of recent embeddings.
|
|
6
|
+
*
|
|
7
|
+
* Returns 0–1: 1 = no drift (current topic matches the recent window exactly),
|
|
8
|
+
* 0 = completely unrelated. Empty recentEmbs returns 1.0 (baseline, no prior).
|
|
9
|
+
*
|
|
10
|
+
* Centroid: element-wise arithmetic mean of recentEmbs.
|
|
11
|
+
* cosineSimilarity normalizes internally; centroid need not be unit-normalized.
|
|
12
|
+
*/
|
|
13
|
+
export function computeTopicDrift(currentEmb, recentEmbs) {
|
|
14
|
+
if (recentEmbs.length === 0)
|
|
15
|
+
return 1.0;
|
|
16
|
+
const dim = currentEmb.length;
|
|
17
|
+
// Element-wise mean of recent embeddings.
|
|
18
|
+
const centroid = new Array(dim).fill(0);
|
|
19
|
+
for (const emb of recentEmbs) {
|
|
20
|
+
if (emb.length !== dim)
|
|
21
|
+
continue; // defensive: skip mismatched-dim rows
|
|
22
|
+
for (let i = 0; i < dim; i++) {
|
|
23
|
+
centroid[i] += emb[i];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (let i = 0; i < dim; i++) {
|
|
27
|
+
centroid[i] /= recentEmbs.length;
|
|
28
|
+
}
|
|
29
|
+
return cosineSimilarity(currentEmb, centroid);
|
|
30
|
+
}
|
|
31
|
+
// ─── Error escalation ───────────────────────────────────────────────────────
|
|
32
|
+
/**
|
|
33
|
+
* Compute error-rate score from recent error categories.
|
|
34
|
+
*
|
|
35
|
+
* `recentErrorCategories` is a list of error-class strings, or null for
|
|
36
|
+
* non-error turns. Returns 1.0 when the list is empty (no data → assume healthy).
|
|
37
|
+
* The score is 1 - (non-null / total), so an entirely-error list scores 0.
|
|
38
|
+
*/
|
|
39
|
+
export function computeErrorEscalation(recentErrorCategories) {
|
|
40
|
+
if (recentErrorCategories.length === 0)
|
|
41
|
+
return 1.0;
|
|
42
|
+
let errors = 0;
|
|
43
|
+
for (const cat of recentErrorCategories) {
|
|
44
|
+
if (cat !== null)
|
|
45
|
+
errors++;
|
|
46
|
+
}
|
|
47
|
+
return 1 - errors / recentErrorCategories.length;
|
|
48
|
+
}
|
|
49
|
+
// ─── Prefix instability ─────────────────────────────────────────────────────
|
|
50
|
+
/**
|
|
51
|
+
* Compute prefix-stability score using the cache-health formula from
|
|
52
|
+
* perf-handler.ts (tryComputeCacheHealth → stabilityScore).
|
|
53
|
+
*
|
|
54
|
+
* The stability formula from perf-handler.ts:
|
|
55
|
+
* instability = min(1, prefixBreakCount / (windowMinutes / 5 * 2))
|
|
56
|
+
* stabilityScore = max(0, 1 - breakSamples.length / maxExpectedBreaks)
|
|
57
|
+
*
|
|
58
|
+
* Here we expose a direct formula:
|
|
59
|
+
* instability = min(1, prefixBreakCount / (windowMinutes / 5 * 2))
|
|
60
|
+
* score = 1 - instability
|
|
61
|
+
* = max(0, 1 - min(1, prefixBreakCount / (windowMinutes / 5 * 2)))
|
|
62
|
+
*
|
|
63
|
+
* Returns 0–1: 1 = perfectly stable (no prefix breaks), 0 = maximally unstable.
|
|
64
|
+
* The window is expressed in minutes so the caller controls the lookback.
|
|
65
|
+
*/
|
|
66
|
+
export function computePrefixInstability(prefixBreakCount, windowMinutes) {
|
|
67
|
+
if (prefixBreakCount <= 0)
|
|
68
|
+
return 1.0;
|
|
69
|
+
if (windowMinutes <= 0)
|
|
70
|
+
return 1.0; // defensive: avoid div-by-zero
|
|
71
|
+
const maxExpectedBreaks = (windowMinutes / 5) * 2;
|
|
72
|
+
const instability = Math.min(1, prefixBreakCount / maxExpectedBreaks);
|
|
73
|
+
return Math.max(0, 1 - instability);
|
|
74
|
+
}
|
|
75
|
+
// ─── Composite drift score ───────────────────────────────────────────────────
|
|
76
|
+
/**
|
|
77
|
+
* Weighted composite drift score from three sub-components.
|
|
78
|
+
*
|
|
79
|
+
* Weights are domain-informed:
|
|
80
|
+
* topic drift (40%) — the strongest signal of context-switching
|
|
81
|
+
* error rate (35%) — errors correlate strongly with confusion
|
|
82
|
+
* prefix stability (25%) — cache instability is secondary to content quality
|
|
83
|
+
*
|
|
84
|
+
* Returns 0–1: 1 = no drift / perfectly healthy, 0 = severe drift.
|
|
85
|
+
*/
|
|
86
|
+
export function computeDriftScore(topic, error, prefix) {
|
|
87
|
+
const raw = topic * 0.4 + error * 0.35 + prefix * 0.25;
|
|
88
|
+
return Math.max(0, Math.min(1, raw));
|
|
89
|
+
}
|