pi-mega-compact 0.11.13 → 0.12.1
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 +125 -0
- package/dist/extensions/dashboard-server/routes.js +1 -0
- package/dist/extensions/dashboard-server/server.js +7 -1
- package/dist/extensions/mega-config.js +5 -0
- package/dist/extensions/mega-events/agent-handlers.js +31 -0
- package/dist/extensions/mega-events/health-handler.js +173 -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/meta.js +11 -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-Cwg_Ab8R.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-9KQgKBEh.js.map → AchievementTiles-Cwg_Ab8R.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-B6xoQTEa.js → AchievementsTab-Dk32-4Ha.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-B6xoQTEa.js.map → AchievementsTab-Dk32-4Ha.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{CacheTab-Dkm9rawo.js → CacheTab-B9zR8UgD.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{CacheTab-Dkm9rawo.js.map → CacheTab-B9zR8UgD.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ConfigTab-Bd_v_OUF.js → ConfigTab-DYRl3mJZ.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ConfigTab-Bd_v_OUF.js.map → ConfigTab-DYRl3mJZ.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{EventsTab-CYWoFOdI.js → EventsTab-r_zXcrmZ.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{EventsTab-CYWoFOdI.js.map → EventsTab-r_zXcrmZ.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{GameTab-CH7lecNL.js → GameTab-DjwA_Q1v.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{GameTab-CH7lecNL.js.map → GameTab-DjwA_Q1v.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/HealthTab-BjH73kQr.js +2 -0
- package/extensions/dashboard-client/dist/assets/HealthTab-BjH73kQr.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/MaintenanceTab-DzeSq9vK.js +2 -0
- package/extensions/dashboard-client/dist/assets/MaintenanceTab-DzeSq9vK.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{MemoryMapTab-tVRREYiG.js → MemoryMapTab-BU9iM3ir.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MemoryMapTab-tVRREYiG.js.map → MemoryMapTab-BU9iM3ir.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CHblYuTY.js → MetricsTab-lMVodOwY.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CHblYuTY.js.map → MetricsTab-lMVodOwY.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-Dj0TgFvU.js → OverviewTab-D-laa6CW.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-Dj0TgFvU.js.map → OverviewTab-D-laa6CW.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ReposTab-BTJcfc1W.js → ReposTab-5Pn2cOOO.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-BTJcfc1W.js.map → ReposTab-5Pn2cOOO.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SessionsTab-C8tU8K5u.js → SessionsTab-Dtdmvl2C.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SessionsTab-C8tU8K5u.js.map → SessionsTab-Dtdmvl2C.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SetupTab-BdxGMo6O.js → SetupTab-rKGgWpI0.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SetupTab-BdxGMo6O.js.map → SetupTab-rKGgWpI0.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-BWZSARzT.js → TimeSavedCard-B5Lw6fTp.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-BWZSARzT.js.map → TimeSavedCard-B5Lw6fTp.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TopicsTab-BCJwPYl8.js → TopicsTab-Ds1IHhPw.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TopicsTab-BCJwPYl8.js.map → TopicsTab-Ds1IHhPw.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TurnsTab-DQFTSDpz.js → TurnsTab-CG2RDRmh.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TurnsTab-DQFTSDpz.js.map → TurnsTab-CG2RDRmh.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/health-lSeDFTz7.js +2 -0
- package/extensions/dashboard-client/dist/assets/health-lSeDFTz7.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{index-DrrDswj-.js → index-C9dTXvDQ.js} +11 -11
- package/extensions/dashboard-client/dist/assets/{index-DrrDswj-.js.map → index-C9dTXvDQ.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{useSSE-o4tteeTs.js → useSSE-B6l8O-yi.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{useSSE-o4tteeTs.js.map → useSSE-B6l8O-yi.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 +80 -0
- package/extensions/dashboard-client/src/tabs/HealthTab.tsx +165 -0
- package/extensions/dashboard-client/src/tabs/MaintenanceTab.tsx +91 -1
- package/extensions/dashboard-server/routes-health.ts +155 -0
- package/extensions/dashboard-server/routes.ts +1 -0
- package/extensions/dashboard-server/server.ts +6 -0
- package/extensions/mega-config.ts +15 -0
- package/extensions/mega-events/agent-handlers.ts +31 -0
- package/extensions/mega-events/health-handler.ts +218 -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/meta.ts +16 -0
- package/src/store/sqlite/schema.ts +43 -1
- package/src/store/sqlite.ts +1 -0
- package/extensions/dashboard-client/dist/assets/MaintenanceTab-DNZ1IF4t.js +0 -2
- package/extensions/dashboard-client/dist/assets/MaintenanceTab-DNZ1IF4t.js.map +0 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outputQuality.ts — pure assistant-output text quality detectors.
|
|
3
|
+
*
|
|
4
|
+
* No I/O beyond the injected Embedder (which is fully local / PREVENT-PI-004).
|
|
5
|
+
* All functions are deterministic and side-effect free.
|
|
6
|
+
*/
|
|
7
|
+
import type { Embedder } from "../embedder.js";
|
|
8
|
+
import { cosineSimilarity } from "../embedder.js";
|
|
9
|
+
|
|
10
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/** Tokenize on whitespace; skip empty tokens. */
|
|
13
|
+
function tokenizeWords(text: string): string[] {
|
|
14
|
+
return text.split(/\s+/).filter((w) => w.length > 0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Split on sentence-terminating punctuation; skip empty. */
|
|
18
|
+
function splitSentences(text: string): string[] {
|
|
19
|
+
return text.split(/[.!?]+/).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Detectors ─────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Repetition ratio via 3-gram overlap.
|
|
26
|
+
*
|
|
27
|
+
* Builds sliding 3-word windows over the token stream. Returns the fraction
|
|
28
|
+
* of 3-grams that appear more than once (i.e. are repeated). Range 0–1;
|
|
29
|
+
* values above 0.30 signal likely garbled / looping output.
|
|
30
|
+
*/
|
|
31
|
+
export function detectRepetition(text: string): number {
|
|
32
|
+
const words = tokenizeWords(text);
|
|
33
|
+
if (words.length < 3) return 0;
|
|
34
|
+
const count = new Map<string, number>();
|
|
35
|
+
for (let i = 0; i <= words.length - 3; i++) {
|
|
36
|
+
const gram = words.slice(i, i + 3).join(" ");
|
|
37
|
+
count.set(gram, (count.get(gram) ?? 0) + 1);
|
|
38
|
+
}
|
|
39
|
+
const total = words.length - 2;
|
|
40
|
+
if (total === 0) return 0;
|
|
41
|
+
let repeated = 0;
|
|
42
|
+
for (const n of count.values()) {
|
|
43
|
+
if (n > 1) repeated += n;
|
|
44
|
+
}
|
|
45
|
+
// Each duplicate occurrence beyond the first adds to the repeated count.
|
|
46
|
+
// Clamp total to total_3grams so the ratio stays in [0,1].
|
|
47
|
+
return Math.min(1, repeated / total);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sentence-level coherence score via adjacent cosine similarity.
|
|
52
|
+
*
|
|
53
|
+
* Splits text into sentences, embeds each, and computes the cosine similarity
|
|
54
|
+
* between every consecutive pair. Returns the average. Range 0–1 (higher =
|
|
55
|
+
* more coherent); fewer than 2 sentences returns 1.0 (cannot measure).
|
|
56
|
+
*/
|
|
57
|
+
export function detectCoherence(text: string, embedder: Embedder): number {
|
|
58
|
+
const sentences = splitSentences(text);
|
|
59
|
+
if (sentences.length < 2) return 1.0;
|
|
60
|
+
|
|
61
|
+
let sum = 0;
|
|
62
|
+
let pairCount = 0;
|
|
63
|
+
for (let i = 0; i < sentences.length - 1; i++) {
|
|
64
|
+
const a = embedder.embed(sentences[i]);
|
|
65
|
+
const b = embedder.embed(sentences[i + 1]);
|
|
66
|
+
sum += cosineSimilarity(a, b);
|
|
67
|
+
pairCount++;
|
|
68
|
+
}
|
|
69
|
+
return pairCount > 0 ? sum / pairCount : 1.0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Token-salad detector.
|
|
74
|
+
*
|
|
75
|
+
* A word is "recognized" if it has length ≥ 2 AND contains at least one vowel
|
|
76
|
+
* (a / e / i / o / u, case-insensitive). Returns the fraction of unrecognized
|
|
77
|
+
* words. Values above 0.6 suggest scrambled / token-confused output.
|
|
78
|
+
*/
|
|
79
|
+
export function detectTokenSalad(text: string): number {
|
|
80
|
+
const words = tokenizeWords(text);
|
|
81
|
+
if (words.length === 0) return 0;
|
|
82
|
+
|
|
83
|
+
const VOWELS = /[aeiou]/i;
|
|
84
|
+
let unrecognized = 0;
|
|
85
|
+
for (const w of words) {
|
|
86
|
+
if (w.length < 2 || !VOWELS.test(w)) unrecognized++;
|
|
87
|
+
}
|
|
88
|
+
return unrecognized / words.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* True when the output is entirely absent or whitespace-only.
|
|
93
|
+
*/
|
|
94
|
+
export function detectEmptyOutput(text: string): boolean {
|
|
95
|
+
return text.trim().length === 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Composite ─────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Compute the full output-quality profile for assistant output text.
|
|
102
|
+
*
|
|
103
|
+
* Returns a score 0–1 (1 = healthy) and the three sub-diagnostics.
|
|
104
|
+
*
|
|
105
|
+
* Penalty weights (sum to 1.0):
|
|
106
|
+
* repetition 40% — repeated 3-grams are the strongest corruption signal
|
|
107
|
+
* coherence 30% — incoherent sentence transitions
|
|
108
|
+
* salad 30% — garbage tokens
|
|
109
|
+
*
|
|
110
|
+
* Empty output is a hard 0.0 (nothing to evaluate).
|
|
111
|
+
*/
|
|
112
|
+
export function computeOutputQuality(
|
|
113
|
+
text: string,
|
|
114
|
+
embedder: Embedder,
|
|
115
|
+
): {
|
|
116
|
+
score: number;
|
|
117
|
+
repetitionRatio: number;
|
|
118
|
+
coherenceScore: number;
|
|
119
|
+
isEmpty: boolean;
|
|
120
|
+
} {
|
|
121
|
+
const isEmpty = detectEmptyOutput(text);
|
|
122
|
+
if (isEmpty) {
|
|
123
|
+
return { score: 0.0, repetitionRatio: 0, coherenceScore: 1.0, isEmpty: true };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const repetitionRatio = detectRepetition(text);
|
|
127
|
+
const coherenceScore = detectCoherence(text, embedder);
|
|
128
|
+
const saladRatio = detectTokenSalad(text);
|
|
129
|
+
|
|
130
|
+
const repetitionPenalty = repetitionRatio * 0.4;
|
|
131
|
+
const coherencePenalty = (1 - coherenceScore) * 0.3;
|
|
132
|
+
const saladPenalty = saladRatio * 0.3;
|
|
133
|
+
|
|
134
|
+
const score = Math.max(0, Math.min(1,
|
|
135
|
+
1.0 - repetitionPenalty - coherencePenalty - saladPenalty,
|
|
136
|
+
));
|
|
137
|
+
|
|
138
|
+
return { score, repetitionRatio, coherenceScore, isEmpty: false };
|
|
139
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contextHealth.ts — Context Health scoring engine.
|
|
3
|
+
*
|
|
4
|
+
* Composite scoring + SQLite persistence for session-level context quality
|
|
5
|
+
* telemetry. The three sub-scoring modules are:
|
|
6
|
+
* outputQuality.ts — assistant output text analysis
|
|
7
|
+
* drift.ts — rolling-window topic / error / prefix drift
|
|
8
|
+
*
|
|
9
|
+
* All SQL is fully parameterized (PREVENT-002). The `context_health` table
|
|
10
|
+
* is created inline on first write so this module is self-contained and does
|
|
11
|
+
* not require schema.ts to be modified (no existing files touched).
|
|
12
|
+
*
|
|
13
|
+
* PREVENT-PI-004: local SQLite only, zero network. All I/O is node:sqlite.
|
|
14
|
+
* PREVENT-011: no `any` — all row shapes are typed.
|
|
15
|
+
*/
|
|
16
|
+
import { openStore } from "./store/sqlite/utils.js";
|
|
17
|
+
|
|
18
|
+
// ─── Composite score ─────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** Sub-scores fed into the composite health score. */
|
|
21
|
+
export interface ContextHealthSubScores {
|
|
22
|
+
/** Topic drift score (0–1, higher = less drift). */
|
|
23
|
+
drift: number;
|
|
24
|
+
/** Output quality score (0–1, higher = healthier output). */
|
|
25
|
+
outputQuality: number;
|
|
26
|
+
/** Error-rate score (0–1, higher = fewer errors). */
|
|
27
|
+
errorRate: number;
|
|
28
|
+
/** Cache health score (0–1, higher = healthier cache). */
|
|
29
|
+
cacheHealth: number;
|
|
30
|
+
/** Cache poison score (0–1, higher = less poison). */
|
|
31
|
+
cachePoison: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Weighted composite health score from five sub-dimensions.
|
|
36
|
+
*
|
|
37
|
+
* Weights:
|
|
38
|
+
* output quality 22% — primary measure of model output health
|
|
39
|
+
* drift 22% — session-level coherence
|
|
40
|
+
* cache poison 20% — prompt-cache integrity
|
|
41
|
+
* cache health 18% — cache hit rate stability
|
|
42
|
+
* error rate 18% — error frequency
|
|
43
|
+
*
|
|
44
|
+
* Returns 0–1: 1 = fully healthy, 0 = severely degraded.
|
|
45
|
+
*/
|
|
46
|
+
export function computeHealthScore(sub: ContextHealthSubScores): number {
|
|
47
|
+
const raw =
|
|
48
|
+
sub.outputQuality * 0.22 +
|
|
49
|
+
sub.drift * 0.22 +
|
|
50
|
+
sub.cachePoison * 0.20 +
|
|
51
|
+
sub.cacheHealth * 0.18 +
|
|
52
|
+
sub.errorRate * 0.18;
|
|
53
|
+
return Math.max(0, Math.min(1, raw));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── SQLite persistence ───────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/** A single row as stored in + returned from the context_health table. */
|
|
59
|
+
export interface ContextHealthRow {
|
|
60
|
+
ts: number;
|
|
61
|
+
turnIndex: number;
|
|
62
|
+
sessionId: string;
|
|
63
|
+
driftScore: number;
|
|
64
|
+
outputQuality: number;
|
|
65
|
+
errorScore: number;
|
|
66
|
+
cacheHealth: number;
|
|
67
|
+
cachePoison: number;
|
|
68
|
+
composite: number;
|
|
69
|
+
modelId?: string;
|
|
70
|
+
repetitionRatio?: number;
|
|
71
|
+
coherenceScore?: number;
|
|
72
|
+
prefixHash?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Record one context health sample into the SQLite store.
|
|
77
|
+
*
|
|
78
|
+
* Non-fatal: errors are caught and silently ignored so instrumentation never
|
|
79
|
+
* blocks the agent loop. Table schema is owned by schema.ts (SCHEMA_VERSION 5).
|
|
80
|
+
*
|
|
81
|
+
* PREVENT-002: all values are bound as parameters (? placeholders).
|
|
82
|
+
*/
|
|
83
|
+
export function recordContextHealth(
|
|
84
|
+
stateDir: string,
|
|
85
|
+
row: ContextHealthRow,
|
|
86
|
+
): void {
|
|
87
|
+
try {
|
|
88
|
+
const db = openStore(stateDir);
|
|
89
|
+
db.prepare(
|
|
90
|
+
`INSERT INTO context_health
|
|
91
|
+
(ts, session_id, turn_index, drift_score, output_quality,
|
|
92
|
+
error_score, cache_health, cache_poison, composite,
|
|
93
|
+
model_id, repetition_ratio, coherence_score, prefix_hash)
|
|
94
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
95
|
+
).run(
|
|
96
|
+
row.ts,
|
|
97
|
+
row.sessionId,
|
|
98
|
+
row.turnIndex,
|
|
99
|
+
row.driftScore,
|
|
100
|
+
row.outputQuality,
|
|
101
|
+
row.errorScore,
|
|
102
|
+
row.cacheHealth,
|
|
103
|
+
row.cachePoison,
|
|
104
|
+
row.composite,
|
|
105
|
+
row.modelId ?? null,
|
|
106
|
+
row.repetitionRatio ?? null,
|
|
107
|
+
row.coherenceScore ?? null,
|
|
108
|
+
row.prefixHash ?? null,
|
|
109
|
+
);
|
|
110
|
+
} catch {
|
|
111
|
+
/* non-fatal: context health instrumentation never blocks the agent loop */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Read context health samples since `sinceTs`, optionally filtered by modelId.
|
|
117
|
+
*
|
|
118
|
+
* Returns rows in ascending ts order. Non-fatal (empty array on error).
|
|
119
|
+
*
|
|
120
|
+
* PREVENT-002: modelId is bound as a parameter (? placeholder).
|
|
121
|
+
* PREVENT-001: row.meta column (if added later) is handled by null-safe reads.
|
|
122
|
+
*/
|
|
123
|
+
export function readContextHealth(
|
|
124
|
+
stateDir: string,
|
|
125
|
+
sinceTs: number,
|
|
126
|
+
modelId?: string,
|
|
127
|
+
): ContextHealthRow[] {
|
|
128
|
+
try {
|
|
129
|
+
const db = openStore(stateDir);
|
|
130
|
+
const sql = modelId
|
|
131
|
+
? `SELECT ts, session_id, turn_index, drift_score, output_quality,
|
|
132
|
+
error_score, cache_health, cache_poison, composite,
|
|
133
|
+
model_id, repetition_ratio, coherence_score, prefix_hash
|
|
134
|
+
FROM context_health
|
|
135
|
+
WHERE ts >= ? AND model_id = ?
|
|
136
|
+
ORDER BY ts ASC`
|
|
137
|
+
: `SELECT ts, session_id, turn_index, drift_score, output_quality,
|
|
138
|
+
error_score, cache_health, cache_poison, composite,
|
|
139
|
+
model_id, repetition_ratio, coherence_score, prefix_hash
|
|
140
|
+
FROM context_health
|
|
141
|
+
WHERE ts >= ?
|
|
142
|
+
ORDER BY ts ASC`;
|
|
143
|
+
const params = modelId ? [sinceTs, modelId] : [sinceTs];
|
|
144
|
+
const rows = db.prepare(sql).all(...params) as Array<{
|
|
145
|
+
ts: number;
|
|
146
|
+
session_id: string;
|
|
147
|
+
turn_index: number;
|
|
148
|
+
drift_score: number;
|
|
149
|
+
output_quality: number;
|
|
150
|
+
error_score: number;
|
|
151
|
+
cache_health: number;
|
|
152
|
+
cache_poison: number;
|
|
153
|
+
composite: number;
|
|
154
|
+
model_id: string | null;
|
|
155
|
+
repetition_ratio: number | null;
|
|
156
|
+
coherence_score: number | null;
|
|
157
|
+
prefix_hash: string | null;
|
|
158
|
+
}>;
|
|
159
|
+
const out: ContextHealthRow[] = [];
|
|
160
|
+
for (const r of rows) {
|
|
161
|
+
out.push({
|
|
162
|
+
ts: r.ts,
|
|
163
|
+
sessionId: r.session_id,
|
|
164
|
+
turnIndex: r.turn_index,
|
|
165
|
+
driftScore: r.drift_score,
|
|
166
|
+
outputQuality: r.output_quality,
|
|
167
|
+
errorScore: r.error_score,
|
|
168
|
+
cacheHealth: r.cache_health,
|
|
169
|
+
cachePoison: r.cache_poison,
|
|
170
|
+
composite: r.composite,
|
|
171
|
+
modelId: r.model_id ?? undefined,
|
|
172
|
+
repetitionRatio: r.repetition_ratio ?? undefined,
|
|
173
|
+
coherenceScore: r.coherence_score ?? undefined,
|
|
174
|
+
prefixHash: r.prefix_hash ?? undefined,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
} catch {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Re-exports for consumers ────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
export { computeOutputQuality } from "./contextHealth/outputQuality.js";
|
|
186
|
+
export {
|
|
187
|
+
computeTopicDrift,
|
|
188
|
+
computeErrorEscalation,
|
|
189
|
+
computePrefixInstability,
|
|
190
|
+
computeDriftScore,
|
|
191
|
+
} from "./contextHealth/drift.js";
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context-health.ts — `context_health` and `cache_poison_events` table accessors.
|
|
3
|
+
*
|
|
4
|
+
* Append-only per-turn Context Health metrics recorded during compactSession:
|
|
5
|
+
* drift_score, output_quality, error_score, cache_health, cache_poison,
|
|
6
|
+
* composite, repetition_ratio, coherence_score, and prefix_hash.
|
|
7
|
+
*
|
|
8
|
+
* Also records cache-poison advisory events when cache corruption/inconsistency
|
|
9
|
+
* is detected at a given cache layer.
|
|
10
|
+
*
|
|
11
|
+
* PREVENT-PI-004: local SQLite only, zero network.
|
|
12
|
+
* PREVENT-002: all SQL uses ? placeholders, no string-concatenated values.
|
|
13
|
+
* Pi-agnostic: no pi runtime types.
|
|
14
|
+
*/
|
|
15
|
+
import { getStateDir } from "../../store.js";
|
|
16
|
+
import { openStore } from "./utils.js";
|
|
17
|
+
|
|
18
|
+
// ─── Shared row types ────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** A single context_health row (as stored + returned). */
|
|
21
|
+
export interface ContextHealthRow {
|
|
22
|
+
id: number;
|
|
23
|
+
ts: number;
|
|
24
|
+
turnIndex: number;
|
|
25
|
+
sessionId: string;
|
|
26
|
+
driftScore: number;
|
|
27
|
+
outputQuality: number;
|
|
28
|
+
errorScore: number;
|
|
29
|
+
cacheHealth: number;
|
|
30
|
+
cachePoison: number;
|
|
31
|
+
composite: number;
|
|
32
|
+
modelId: string | null;
|
|
33
|
+
repetitionRatio: number | null;
|
|
34
|
+
coherenceScore: number | null;
|
|
35
|
+
prefixHash: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Latest summary row (most recent by ts). */
|
|
39
|
+
export interface LatestContextHealth {
|
|
40
|
+
composite: number;
|
|
41
|
+
driftScore: number;
|
|
42
|
+
outputQuality: number;
|
|
43
|
+
errorScore: number;
|
|
44
|
+
cacheHealth: number;
|
|
45
|
+
cachePoison: number;
|
|
46
|
+
ts: number;
|
|
47
|
+
modelId: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A single cache_poison_events row (as stored + returned). */
|
|
51
|
+
export interface CachePoisonEvent {
|
|
52
|
+
id: number;
|
|
53
|
+
ts: number;
|
|
54
|
+
turnIndex: number;
|
|
55
|
+
sessionId: string;
|
|
56
|
+
layer: number | null;
|
|
57
|
+
detail: string | null;
|
|
58
|
+
severity: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── context_health accessors ────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Record one context health sample. All values are fully parameterized
|
|
65
|
+
* (PREVENT-002). Optional columns (model_id, repetition_ratio, coherence_score,
|
|
66
|
+
* prefix_hash) are passed as-is; the column list enumerates every field so
|
|
67
|
+
* missing optional fields are NULL in the row.
|
|
68
|
+
*
|
|
69
|
+
* Non-fatal: any write error is logged to stderr and silently swallowed so
|
|
70
|
+
* instrumentation can never block the agent loop.
|
|
71
|
+
*/
|
|
72
|
+
export function recordContextHealthRow(
|
|
73
|
+
stateDir: string = getStateDir(),
|
|
74
|
+
row: {
|
|
75
|
+
ts: number;
|
|
76
|
+
turnIndex: number;
|
|
77
|
+
sessionId: string;
|
|
78
|
+
driftScore: number;
|
|
79
|
+
outputQuality: number;
|
|
80
|
+
errorScore: number;
|
|
81
|
+
cacheHealth: number;
|
|
82
|
+
cachePoison: number;
|
|
83
|
+
composite: number;
|
|
84
|
+
modelId?: string;
|
|
85
|
+
repetitionRatio?: number;
|
|
86
|
+
coherenceScore?: number;
|
|
87
|
+
prefixHash?: string;
|
|
88
|
+
},
|
|
89
|
+
): void {
|
|
90
|
+
try {
|
|
91
|
+
const db = openStore(stateDir);
|
|
92
|
+
db.prepare(
|
|
93
|
+
`INSERT INTO context_health
|
|
94
|
+
(ts, turn_index, session_id, drift_score, output_quality,
|
|
95
|
+
error_score, cache_health, cache_poison, composite,
|
|
96
|
+
model_id, repetition_ratio, coherence_score, prefix_hash)
|
|
97
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
98
|
+
).run(
|
|
99
|
+
row.ts,
|
|
100
|
+
row.turnIndex,
|
|
101
|
+
row.sessionId,
|
|
102
|
+
row.driftScore,
|
|
103
|
+
row.outputQuality,
|
|
104
|
+
row.errorScore,
|
|
105
|
+
row.cacheHealth,
|
|
106
|
+
row.cachePoison,
|
|
107
|
+
row.composite,
|
|
108
|
+
row.modelId ?? null,
|
|
109
|
+
row.repetitionRatio ?? null,
|
|
110
|
+
row.coherenceScore ?? null,
|
|
111
|
+
row.prefixHash ?? null,
|
|
112
|
+
);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
// Non-fatal: log and continue — health sampling must never block the agent.
|
|
115
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
116
|
+
process.stderr.write(`[context-health] recordContextHealthRow error: ${msg}\n`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read context health rows since `sinceTs`. When `modelId` is provided the
|
|
122
|
+
* query adds an AND filter; otherwise all models are returned. Results are
|
|
123
|
+
* ordered ascending by ts. Returns an empty array on any error (non-fatal).
|
|
124
|
+
*
|
|
125
|
+
* SQL is fully parameterized (PREVENT-002).
|
|
126
|
+
*/
|
|
127
|
+
export function readContextHealthRows(
|
|
128
|
+
stateDir: string = getStateDir(),
|
|
129
|
+
sinceTs: number,
|
|
130
|
+
modelId?: string,
|
|
131
|
+
): ContextHealthRow[] {
|
|
132
|
+
try {
|
|
133
|
+
const db = openStore(stateDir);
|
|
134
|
+
let rows: Array<{
|
|
135
|
+
id: number;
|
|
136
|
+
ts: number;
|
|
137
|
+
turn_index: number;
|
|
138
|
+
session_id: string;
|
|
139
|
+
drift_score: number;
|
|
140
|
+
output_quality: number;
|
|
141
|
+
error_score: number;
|
|
142
|
+
cache_health: number;
|
|
143
|
+
cache_poison: number;
|
|
144
|
+
composite: number;
|
|
145
|
+
model_id: string | null;
|
|
146
|
+
repetition_ratio: number | null;
|
|
147
|
+
coherence_score: number | null;
|
|
148
|
+
prefix_hash: string | null;
|
|
149
|
+
}>;
|
|
150
|
+
|
|
151
|
+
if (modelId != null) {
|
|
152
|
+
rows = db
|
|
153
|
+
.prepare(
|
|
154
|
+
`SELECT id, ts, turn_index, session_id, drift_score, output_quality,
|
|
155
|
+
error_score, cache_health, cache_poison, composite,
|
|
156
|
+
model_id, repetition_ratio, coherence_score, prefix_hash
|
|
157
|
+
FROM context_health
|
|
158
|
+
WHERE ts >= ? AND model_id = ?
|
|
159
|
+
ORDER BY ts ASC`,
|
|
160
|
+
)
|
|
161
|
+
.all(sinceTs, modelId) as typeof rows;
|
|
162
|
+
} else {
|
|
163
|
+
rows = db
|
|
164
|
+
.prepare(
|
|
165
|
+
`SELECT id, ts, turn_index, session_id, drift_score, output_quality,
|
|
166
|
+
error_score, cache_health, cache_poison, composite,
|
|
167
|
+
model_id, repetition_ratio, coherence_score, prefix_hash
|
|
168
|
+
FROM context_health
|
|
169
|
+
WHERE ts >= ?
|
|
170
|
+
ORDER BY ts ASC`,
|
|
171
|
+
)
|
|
172
|
+
.all(sinceTs) as typeof rows;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return rows.map((r) => ({
|
|
176
|
+
id: r.id,
|
|
177
|
+
ts: r.ts,
|
|
178
|
+
turnIndex: r.turn_index,
|
|
179
|
+
sessionId: r.session_id,
|
|
180
|
+
driftScore: r.drift_score,
|
|
181
|
+
outputQuality: r.output_quality,
|
|
182
|
+
errorScore: r.error_score,
|
|
183
|
+
cacheHealth: r.cache_health,
|
|
184
|
+
cachePoison: r.cache_poison,
|
|
185
|
+
composite: r.composite,
|
|
186
|
+
modelId: r.model_id,
|
|
187
|
+
repetitionRatio: r.repetition_ratio,
|
|
188
|
+
coherenceScore: r.coherence_score,
|
|
189
|
+
prefixHash: r.prefix_hash,
|
|
190
|
+
}));
|
|
191
|
+
} catch {
|
|
192
|
+
// Non-fatal: return empty array on DB error.
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Read the most recent context_health row (ORDER BY ts DESC LIMIT 1).
|
|
199
|
+
* Returns null when no rows exist or on any error.
|
|
200
|
+
*/
|
|
201
|
+
export function readLatestContextHealth(
|
|
202
|
+
stateDir: string = getStateDir(),
|
|
203
|
+
): LatestContextHealth | null {
|
|
204
|
+
try {
|
|
205
|
+
const db = openStore(stateDir);
|
|
206
|
+
const row = db
|
|
207
|
+
.prepare(
|
|
208
|
+
`SELECT composite, drift_score, output_quality, error_score,
|
|
209
|
+
cache_health, cache_poison, ts, model_id
|
|
210
|
+
FROM context_health
|
|
211
|
+
ORDER BY ts DESC
|
|
212
|
+
LIMIT 1`,
|
|
213
|
+
)
|
|
214
|
+
.get() as
|
|
215
|
+
| {
|
|
216
|
+
composite: number;
|
|
217
|
+
drift_score: number;
|
|
218
|
+
output_quality: number;
|
|
219
|
+
error_score: number;
|
|
220
|
+
cache_health: number;
|
|
221
|
+
cache_poison: number;
|
|
222
|
+
ts: number;
|
|
223
|
+
model_id: string | null;
|
|
224
|
+
}
|
|
225
|
+
| undefined;
|
|
226
|
+
|
|
227
|
+
if (!row) return null;
|
|
228
|
+
return {
|
|
229
|
+
composite: row.composite,
|
|
230
|
+
driftScore: row.drift_score,
|
|
231
|
+
outputQuality: row.output_quality,
|
|
232
|
+
errorScore: row.error_score,
|
|
233
|
+
cacheHealth: row.cache_health,
|
|
234
|
+
cachePoison: row.cache_poison,
|
|
235
|
+
ts: row.ts,
|
|
236
|
+
modelId: row.model_id,
|
|
237
|
+
};
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Read the trailing `limit` composite scores (most recent first), then reverse
|
|
245
|
+
* to chronological order for sparkline rendering. Returns an empty array when
|
|
246
|
+
* no rows exist or on any error.
|
|
247
|
+
*
|
|
248
|
+
* SQL is fully parameterized (PREVENT-002).
|
|
249
|
+
*/
|
|
250
|
+
export function readContextHealthTrend(
|
|
251
|
+
stateDir: string = getStateDir(),
|
|
252
|
+
limit: number,
|
|
253
|
+
): number[] {
|
|
254
|
+
try {
|
|
255
|
+
const db = openStore(stateDir);
|
|
256
|
+
const rows = db
|
|
257
|
+
.prepare(
|
|
258
|
+
`SELECT composite FROM context_health
|
|
259
|
+
ORDER BY ts DESC LIMIT ?`,
|
|
260
|
+
)
|
|
261
|
+
.all(limit) as Array<{ composite: number }>;
|
|
262
|
+
|
|
263
|
+
// Reverse to chronological (oldest first) for sparkline display.
|
|
264
|
+
return rows.map((r) => r.composite).reverse();
|
|
265
|
+
} catch {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ─── cache_poison_events accessors ───────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Record one cache poison advisory event. Fully parameterized (PREVENT-002).
|
|
274
|
+
* Non-fatal: write errors are logged to stderr and silently swallowed.
|
|
275
|
+
*/
|
|
276
|
+
export function recordCachePoisonEvent(
|
|
277
|
+
stateDir: string = getStateDir(),
|
|
278
|
+
event: {
|
|
279
|
+
ts: number;
|
|
280
|
+
turnIndex: number;
|
|
281
|
+
sessionId: string;
|
|
282
|
+
layer: number;
|
|
283
|
+
detail: string;
|
|
284
|
+
severity: "warn" | "alert";
|
|
285
|
+
},
|
|
286
|
+
): void {
|
|
287
|
+
try {
|
|
288
|
+
const db = openStore(stateDir);
|
|
289
|
+
db.prepare(
|
|
290
|
+
`INSERT INTO cache_poison_events
|
|
291
|
+
(ts, turn_index, session_id, layer, detail, severity)
|
|
292
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
293
|
+
).run(event.ts, event.turnIndex, event.sessionId, event.layer, event.detail, event.severity);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
// Non-fatal: advisory logging must never block the agent loop.
|
|
296
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
297
|
+
process.stderr.write(`[context-health] recordCachePoisonEvent error: ${msg}\n`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Read cache poison events since `sinceTs`, ordered descending by ts (most
|
|
303
|
+
* recent first), capped at 100 rows. Returns an empty array on any error
|
|
304
|
+
* (non-fatal). SQL is fully parameterized (PREVENT-002).
|
|
305
|
+
*/
|
|
306
|
+
export function readCachePoisonEvents(
|
|
307
|
+
stateDir: string = getStateDir(),
|
|
308
|
+
sinceTs: number,
|
|
309
|
+
): CachePoisonEvent[] {
|
|
310
|
+
try {
|
|
311
|
+
const db = openStore(stateDir);
|
|
312
|
+
const rows = db
|
|
313
|
+
.prepare(
|
|
314
|
+
`SELECT id, ts, turn_index, session_id, layer, detail, severity
|
|
315
|
+
FROM cache_poison_events
|
|
316
|
+
WHERE ts >= ?
|
|
317
|
+
ORDER BY ts DESC
|
|
318
|
+
LIMIT 100`,
|
|
319
|
+
)
|
|
320
|
+
.all(sinceTs) as Array<{
|
|
321
|
+
id: number;
|
|
322
|
+
ts: number;
|
|
323
|
+
turn_index: number;
|
|
324
|
+
session_id: string;
|
|
325
|
+
layer: number | null;
|
|
326
|
+
detail: string | null;
|
|
327
|
+
severity: string | null;
|
|
328
|
+
}>;
|
|
329
|
+
|
|
330
|
+
return rows.map((r) => ({
|
|
331
|
+
id: r.id,
|
|
332
|
+
ts: r.ts,
|
|
333
|
+
turnIndex: r.turn_index,
|
|
334
|
+
sessionId: r.session_id,
|
|
335
|
+
layer: r.layer,
|
|
336
|
+
detail: r.detail,
|
|
337
|
+
severity: r.severity,
|
|
338
|
+
}));
|
|
339
|
+
} catch {
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
}
|
package/src/store/sqlite/meta.ts
CHANGED
|
@@ -79,6 +79,22 @@ export function bumpDedupStats(deduped: boolean, stateDir: string = getStateDir(
|
|
|
79
79
|
if (deduped) incMeta("deduped", 1, stateDir);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// --- Context health mitigation (runtime-toggleable via dashboard) ----------
|
|
83
|
+
|
|
84
|
+
/** Read the context_health_mitigate flag from meta. Returns false if unset. */
|
|
85
|
+
export function getHealthMitigate(stateDir: string = getStateDir()): boolean {
|
|
86
|
+
return getMeta("context_health_mitigate", stateDir) === "true";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Set the context_health_mitigate flag in meta (runtime-toggleable). */
|
|
90
|
+
export function setHealthMitigate(enabled: boolean, stateDir: string = getStateDir()): void {
|
|
91
|
+
const db = openStore(stateDir);
|
|
92
|
+
db.prepare(
|
|
93
|
+
`INSERT INTO meta(key, value) VALUES('context_health_mitigate', ?)
|
|
94
|
+
ON CONFLICT(key) DO UPDATE SET value = ?`,
|
|
95
|
+
).run(enabled ? "true" : "false", enabled ? "true" : "false");
|
|
96
|
+
}
|
|
97
|
+
|
|
82
98
|
// --- Live dashboard counters (schemaless meta key/value — NO migration) -----
|
|
83
99
|
// These reuse the private `incMeta` atomically-incrementing integer counter so
|
|
84
100
|
// all cumulative tallies live in the same `meta` table as tokens_saved etc.
|