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.
Files changed (71) hide show
  1. package/dist/extensions/dashboard-server/routes-health.js +81 -0
  2. package/dist/extensions/dashboard-server/routes.js +1 -0
  3. package/dist/extensions/dashboard-server/server.js +5 -1
  4. package/dist/extensions/mega-config.js +5 -0
  5. package/dist/extensions/mega-events/agent-handlers.js +5 -0
  6. package/dist/extensions/mega-events/health-handler.js +156 -0
  7. package/dist/extensions/mega-runtime/runtime.js +6 -0
  8. package/dist/src/contextHealth/cachePoison.js +183 -0
  9. package/dist/src/contextHealth/drift.js +89 -0
  10. package/dist/src/contextHealth/outputQuality.js +112 -0
  11. package/dist/src/contextHealth.js +110 -0
  12. package/dist/src/store/sqlite/context-health.js +193 -0
  13. package/dist/src/store/sqlite/schema.js +43 -1
  14. package/dist/src/store/sqlite.js +1 -0
  15. package/extensions/dashboard-client/dist/assets/{AchievementTiles-9KQgKBEh.js → AchievementTiles-DeQ0qnBX.js} +2 -2
  16. package/extensions/dashboard-client/dist/assets/{AchievementTiles-9KQgKBEh.js.map → AchievementTiles-DeQ0qnBX.js.map} +1 -1
  17. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B6xoQTEa.js → AchievementsTab-Dtk_RJfs.js} +2 -2
  18. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B6xoQTEa.js.map → AchievementsTab-Dtk_RJfs.js.map} +1 -1
  19. package/extensions/dashboard-client/dist/assets/{CacheTab-Dkm9rawo.js → CacheTab-kWcQt3PI.js} +2 -2
  20. package/extensions/dashboard-client/dist/assets/{CacheTab-Dkm9rawo.js.map → CacheTab-kWcQt3PI.js.map} +1 -1
  21. package/extensions/dashboard-client/dist/assets/{ConfigTab-Bd_v_OUF.js → ConfigTab-UFlo73fR.js} +2 -2
  22. package/extensions/dashboard-client/dist/assets/{ConfigTab-Bd_v_OUF.js.map → ConfigTab-UFlo73fR.js.map} +1 -1
  23. package/extensions/dashboard-client/dist/assets/{EventsTab-CYWoFOdI.js → EventsTab-BENO2N0y.js} +2 -2
  24. package/extensions/dashboard-client/dist/assets/{EventsTab-CYWoFOdI.js.map → EventsTab-BENO2N0y.js.map} +1 -1
  25. package/extensions/dashboard-client/dist/assets/{GameTab-CH7lecNL.js → GameTab-BCJavOML.js} +2 -2
  26. package/extensions/dashboard-client/dist/assets/{GameTab-CH7lecNL.js.map → GameTab-BCJavOML.js.map} +1 -1
  27. package/extensions/dashboard-client/dist/assets/HealthTab-C7iw7rhx.js +2 -0
  28. package/extensions/dashboard-client/dist/assets/HealthTab-C7iw7rhx.js.map +1 -0
  29. package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DNZ1IF4t.js → MaintenanceTab-C0rdAMWc.js} +2 -2
  30. package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DNZ1IF4t.js.map → MaintenanceTab-C0rdAMWc.js.map} +1 -1
  31. package/extensions/dashboard-client/dist/assets/{MemoryMapTab-tVRREYiG.js → MemoryMapTab-w5BZP3i-.js} +2 -2
  32. package/extensions/dashboard-client/dist/assets/{MemoryMapTab-tVRREYiG.js.map → MemoryMapTab-w5BZP3i-.js.map} +1 -1
  33. package/extensions/dashboard-client/dist/assets/{MetricsTab-CHblYuTY.js → MetricsTab-BBCXa3xS.js} +2 -2
  34. package/extensions/dashboard-client/dist/assets/{MetricsTab-CHblYuTY.js.map → MetricsTab-BBCXa3xS.js.map} +1 -1
  35. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dj0TgFvU.js → OverviewTab-CzABMSAQ.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dj0TgFvU.js.map → OverviewTab-CzABMSAQ.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/{ReposTab-BTJcfc1W.js → ReposTab-DFiM_Ubg.js} +2 -2
  38. package/extensions/dashboard-client/dist/assets/{ReposTab-BTJcfc1W.js.map → ReposTab-DFiM_Ubg.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/{SessionsTab-C8tU8K5u.js → SessionsTab-BtW4bees.js} +2 -2
  40. package/extensions/dashboard-client/dist/assets/{SessionsTab-C8tU8K5u.js.map → SessionsTab-BtW4bees.js.map} +1 -1
  41. package/extensions/dashboard-client/dist/assets/{SetupTab-BdxGMo6O.js → SetupTab-Duy1LJhg.js} +2 -2
  42. package/extensions/dashboard-client/dist/assets/{SetupTab-BdxGMo6O.js.map → SetupTab-Duy1LJhg.js.map} +1 -1
  43. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-BWZSARzT.js → TimeSavedCard-CC13EXij.js} +2 -2
  44. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-BWZSARzT.js.map → TimeSavedCard-CC13EXij.js.map} +1 -1
  45. package/extensions/dashboard-client/dist/assets/{TopicsTab-BCJwPYl8.js → TopicsTab-BrTB8uEK.js} +2 -2
  46. package/extensions/dashboard-client/dist/assets/{TopicsTab-BCJwPYl8.js.map → TopicsTab-BrTB8uEK.js.map} +1 -1
  47. package/extensions/dashboard-client/dist/assets/{TurnsTab-DQFTSDpz.js → TurnsTab-BJK7FIHu.js} +2 -2
  48. package/extensions/dashboard-client/dist/assets/{TurnsTab-DQFTSDpz.js.map → TurnsTab-BJK7FIHu.js.map} +1 -1
  49. package/extensions/dashboard-client/dist/assets/{index-DrrDswj-.js → index-B8k19B9g.js} +11 -11
  50. package/extensions/dashboard-client/dist/assets/{index-DrrDswj-.js.map → index-B8k19B9g.js.map} +1 -1
  51. package/extensions/dashboard-client/dist/assets/{useSSE-o4tteeTs.js → useSSE-CWBB4Me7.js} +2 -2
  52. package/extensions/dashboard-client/dist/assets/{useSSE-o4tteeTs.js.map → useSSE-CWBB4Me7.js.map} +1 -1
  53. package/extensions/dashboard-client/dist/index.html +1 -1
  54. package/extensions/dashboard-client/src/App.tsx +5 -1
  55. package/extensions/dashboard-client/src/api/health.ts +64 -0
  56. package/extensions/dashboard-client/src/tabs/HealthTab.tsx +165 -0
  57. package/extensions/dashboard-server/routes-health.ts +104 -0
  58. package/extensions/dashboard-server/routes.ts +1 -0
  59. package/extensions/dashboard-server/server.ts +4 -0
  60. package/extensions/mega-config.ts +15 -0
  61. package/extensions/mega-events/agent-handlers.ts +6 -0
  62. package/extensions/mega-events/health-handler.ts +190 -0
  63. package/extensions/mega-runtime/runtime.ts +7 -0
  64. package/package.json +1 -1
  65. package/src/contextHealth/cachePoison.ts +243 -0
  66. package/src/contextHealth/drift.ts +107 -0
  67. package/src/contextHealth/outputQuality.ts +139 -0
  68. package/src/contextHealth.ts +191 -0
  69. package/src/store/sqlite/context-health.ts +342 -0
  70. package/src/store/sqlite/schema.ts +43 -1
  71. package/src/store/sqlite.ts +1 -0
@@ -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
+ }
@@ -4,7 +4,7 @@
4
4
  import type { DatabaseSync } from "node:sqlite";
5
5
  import { ACHIEVEMENT_DEFS } from "../../game/scoring.js";
6
6
 
7
- const SCHEMA_VERSION = 4;
7
+ const SCHEMA_VERSION = 5;
8
8
 
9
9
  /**
10
10
  * Add `column` (with `decl`, e.g. "INTEGER") to `table` if it does not already
@@ -425,6 +425,48 @@ export function initSchema(db: DatabaseSync): void {
425
425
  );
426
426
  CREATE INDEX IF NOT EXISTS idx_emb_cache_computed
427
427
  ON embedding_cache(computed_at DESC);
428
+
429
+ -- A2 PLAN_V2 Phase 2 (SCHEMA_VERSION 5): Context Health metrics. One row
430
+ -- per turn recorded during compactSession. drift_score / output_quality /
431
+ -- error_score / cache_health / cache_poison / composite are the core signal
432
+ -- columns; repetition_ratio / coherence_score / prefix_hash are optional
433
+ -- enrichment. Parameterized accessors in context-health.ts (PREVENT-002).
434
+ CREATE TABLE IF NOT EXISTS context_health (
435
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
436
+ ts INTEGER NOT NULL,
437
+ turn_index INTEGER NOT NULL,
438
+ session_id TEXT NOT NULL,
439
+ drift_score REAL,
440
+ output_quality REAL,
441
+ error_score REAL,
442
+ cache_health REAL,
443
+ cache_poison REAL,
444
+ composite REAL,
445
+ model_id TEXT,
446
+ repetition_ratio REAL,
447
+ coherence_score REAL,
448
+ prefix_hash TEXT
449
+ );
450
+ CREATE INDEX IF NOT EXISTS idx_ctx_health_ts
451
+ ON context_health(ts);
452
+ CREATE INDEX IF NOT EXISTS idx_ctx_health_session
453
+ ON context_health(session_id, ts);
454
+
455
+ -- A2 PLAN_V2 Phase 2 (SCHEMA_VERSION 5): Cache poison advisory events.
456
+ -- Emitted when cache corruption / inconsistency is detected at a given
457
+ -- layer during compactSession. Drives the R13 advisory channel redesign.
458
+ -- Parameterized accessors in context-health.ts (PREVENT-002).
459
+ CREATE TABLE IF NOT EXISTS cache_poison_events (
460
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
461
+ ts INTEGER NOT NULL,
462
+ turn_index INTEGER NOT NULL,
463
+ session_id TEXT NOT NULL,
464
+ layer INTEGER,
465
+ detail TEXT,
466
+ severity TEXT
467
+ );
468
+ CREATE INDEX IF NOT EXISTS idx_cache_poison_ts
469
+ ON cache_poison_events(ts);
428
470
  `);
429
471
  // Idempotent column migrations. `CREATE TABLE IF NOT EXISTS` is a no-op on a
430
472
  // pre-existing table, so new columns added to context_chunks after a store was
@@ -24,6 +24,7 @@ export * from "./sqlite/game-state.js";
24
24
  export * from "./sqlite/game-scores.js";
25
25
  export * from "./sqlite/game-achievements.js";
26
26
  export * from "./sqlite/perf-samples.js";
27
+ export * from "./sqlite/context-health.js";
27
28
  export * from "./sqlite/cache-stripes.js";
28
29
  export * from "./sqlite/turns.js";
29
30
  export * from "./sqlite/embedding-cache.js";