pi-mega-compact 0.11.12 → 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 (87) hide show
  1. package/dist/extensions/dashboard-server/dashboard-client-repos.js +28 -2
  2. package/dist/extensions/dashboard-server/index-reader.js +14 -13
  3. package/dist/extensions/dashboard-server/routes-health.js +81 -0
  4. package/dist/extensions/dashboard-server/routes-topics.js +18 -0
  5. package/dist/extensions/dashboard-server/routes.js +1 -0
  6. package/dist/extensions/dashboard-server/server.js +5 -1
  7. package/dist/extensions/mega-config.js +5 -0
  8. package/dist/extensions/mega-events/agent-handlers.js +5 -0
  9. package/dist/extensions/mega-events/context-handler.js +10 -5
  10. package/dist/extensions/mega-events/health-handler.js +156 -0
  11. package/dist/extensions/mega-runtime/reset-runtime.js +5 -1
  12. package/dist/extensions/mega-runtime/runtime.js +6 -0
  13. package/dist/src/contextHealth/cachePoison.js +183 -0
  14. package/dist/src/contextHealth/drift.js +89 -0
  15. package/dist/src/contextHealth/outputQuality.js +112 -0
  16. package/dist/src/contextHealth.js +110 -0
  17. package/dist/src/memoryGraph/gates.js +1 -1
  18. package/dist/src/memoryGraph/sources.js +44 -22
  19. package/dist/src/store/sqlite/context-health.js +193 -0
  20. package/dist/src/store/sqlite/schema.js +43 -1
  21. package/dist/src/store/sqlite.js +1 -0
  22. package/dist/src/store/turns/sqlite-store.js +6 -6
  23. package/extensions/dashboard-client/dist/assets/{AchievementTiles-9KQgKBEh.js → AchievementTiles-DeQ0qnBX.js} +2 -2
  24. package/extensions/dashboard-client/dist/assets/{AchievementTiles-9KQgKBEh.js.map → AchievementTiles-DeQ0qnBX.js.map} +1 -1
  25. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B6xoQTEa.js → AchievementsTab-Dtk_RJfs.js} +2 -2
  26. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B6xoQTEa.js.map → AchievementsTab-Dtk_RJfs.js.map} +1 -1
  27. package/extensions/dashboard-client/dist/assets/{CacheTab-Dkm9rawo.js → CacheTab-kWcQt3PI.js} +2 -2
  28. package/extensions/dashboard-client/dist/assets/{CacheTab-Dkm9rawo.js.map → CacheTab-kWcQt3PI.js.map} +1 -1
  29. package/extensions/dashboard-client/dist/assets/{ConfigTab-Bd_v_OUF.js → ConfigTab-UFlo73fR.js} +2 -2
  30. package/extensions/dashboard-client/dist/assets/{ConfigTab-Bd_v_OUF.js.map → ConfigTab-UFlo73fR.js.map} +1 -1
  31. package/extensions/dashboard-client/dist/assets/{EventsTab-CYWoFOdI.js → EventsTab-BENO2N0y.js} +2 -2
  32. package/extensions/dashboard-client/dist/assets/{EventsTab-CYWoFOdI.js.map → EventsTab-BENO2N0y.js.map} +1 -1
  33. package/extensions/dashboard-client/dist/assets/{GameTab-CH7lecNL.js → GameTab-BCJavOML.js} +2 -2
  34. package/extensions/dashboard-client/dist/assets/{GameTab-CH7lecNL.js.map → GameTab-BCJavOML.js.map} +1 -1
  35. package/extensions/dashboard-client/dist/assets/HealthTab-C7iw7rhx.js +2 -0
  36. package/extensions/dashboard-client/dist/assets/HealthTab-C7iw7rhx.js.map +1 -0
  37. package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DNZ1IF4t.js → MaintenanceTab-C0rdAMWc.js} +2 -2
  38. package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DNZ1IF4t.js.map → MaintenanceTab-C0rdAMWc.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/{MemoryMapTab-tVRREYiG.js → MemoryMapTab-w5BZP3i-.js} +2 -2
  40. package/extensions/dashboard-client/dist/assets/{MemoryMapTab-tVRREYiG.js.map → MemoryMapTab-w5BZP3i-.js.map} +1 -1
  41. package/extensions/dashboard-client/dist/assets/{MetricsTab-CHblYuTY.js → MetricsTab-BBCXa3xS.js} +2 -2
  42. package/extensions/dashboard-client/dist/assets/{MetricsTab-CHblYuTY.js.map → MetricsTab-BBCXa3xS.js.map} +1 -1
  43. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dj0TgFvU.js → OverviewTab-CzABMSAQ.js} +2 -2
  44. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dj0TgFvU.js.map → OverviewTab-CzABMSAQ.js.map} +1 -1
  45. package/extensions/dashboard-client/dist/assets/{ReposTab-BTJcfc1W.js → ReposTab-DFiM_Ubg.js} +2 -2
  46. package/extensions/dashboard-client/dist/assets/{ReposTab-BTJcfc1W.js.map → ReposTab-DFiM_Ubg.js.map} +1 -1
  47. package/extensions/dashboard-client/dist/assets/{SessionsTab-C8tU8K5u.js → SessionsTab-BtW4bees.js} +2 -2
  48. package/extensions/dashboard-client/dist/assets/{SessionsTab-C8tU8K5u.js.map → SessionsTab-BtW4bees.js.map} +1 -1
  49. package/extensions/dashboard-client/dist/assets/{SetupTab-BdxGMo6O.js → SetupTab-Duy1LJhg.js} +2 -2
  50. package/extensions/dashboard-client/dist/assets/{SetupTab-BdxGMo6O.js.map → SetupTab-Duy1LJhg.js.map} +1 -1
  51. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-BWZSARzT.js → TimeSavedCard-CC13EXij.js} +2 -2
  52. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-BWZSARzT.js.map → TimeSavedCard-CC13EXij.js.map} +1 -1
  53. package/extensions/dashboard-client/dist/assets/{TopicsTab-BCJwPYl8.js → TopicsTab-BrTB8uEK.js} +2 -2
  54. package/extensions/dashboard-client/dist/assets/{TopicsTab-BCJwPYl8.js.map → TopicsTab-BrTB8uEK.js.map} +1 -1
  55. package/extensions/dashboard-client/dist/assets/{TurnsTab-DQFTSDpz.js → TurnsTab-BJK7FIHu.js} +2 -2
  56. package/extensions/dashboard-client/dist/assets/{TurnsTab-DQFTSDpz.js.map → TurnsTab-BJK7FIHu.js.map} +1 -1
  57. package/extensions/dashboard-client/dist/assets/{index-DrrDswj-.js → index-B8k19B9g.js} +11 -11
  58. package/extensions/dashboard-client/dist/assets/{index-DrrDswj-.js.map → index-B8k19B9g.js.map} +1 -1
  59. package/extensions/dashboard-client/dist/assets/{useSSE-o4tteeTs.js → useSSE-CWBB4Me7.js} +2 -2
  60. package/extensions/dashboard-client/dist/assets/{useSSE-o4tteeTs.js.map → useSSE-CWBB4Me7.js.map} +1 -1
  61. package/extensions/dashboard-client/dist/index.html +1 -1
  62. package/extensions/dashboard-client/src/App.tsx +5 -1
  63. package/extensions/dashboard-client/src/api/health.ts +64 -0
  64. package/extensions/dashboard-client/src/tabs/HealthTab.tsx +165 -0
  65. package/extensions/dashboard-server/dashboard-client-repos.ts +28 -2
  66. package/extensions/dashboard-server/index-reader.ts +16 -17
  67. package/extensions/dashboard-server/routes-health.ts +104 -0
  68. package/extensions/dashboard-server/routes-topics.ts +21 -0
  69. package/extensions/dashboard-server/routes.ts +1 -0
  70. package/extensions/dashboard-server/server.ts +4 -0
  71. package/extensions/mega-config.ts +15 -0
  72. package/extensions/mega-events/agent-handlers.ts +6 -0
  73. package/extensions/mega-events/context-handler.ts +111 -104
  74. package/extensions/mega-events/health-handler.ts +190 -0
  75. package/extensions/mega-runtime/reset-runtime.ts +5 -1
  76. package/extensions/mega-runtime/runtime.ts +7 -0
  77. package/package.json +1 -1
  78. package/src/contextHealth/cachePoison.ts +243 -0
  79. package/src/contextHealth/drift.ts +107 -0
  80. package/src/contextHealth/outputQuality.ts +139 -0
  81. package/src/contextHealth.ts +191 -0
  82. package/src/memoryGraph/gates.ts +1 -1
  83. package/src/memoryGraph/sources.ts +56 -28
  84. package/src/store/sqlite/context-health.ts +342 -0
  85. package/src/store/sqlite/schema.ts +43 -1
  86. package/src/store/sqlite.ts +1 -0
  87. package/src/store/turns/sqlite-store.ts +6 -4
@@ -0,0 +1,107 @@
1
+ /**
2
+ * drift.ts — rolling-window context drift detectors.
3
+ *
4
+ * Pure functions; no I/O. All vectors are number[] (the Embedder output type).
5
+ * Cosine similarity is used throughout (PREVENT-PI-004 safe — no network).
6
+ */
7
+ import type { Vector } from "../embedder.js";
8
+ import { cosineSimilarity } from "../embedder.js";
9
+
10
+ // ─── Topic drift ────────────────────────────────────────────────────────────
11
+
12
+ /**
13
+ * Compute topic drift as cosine similarity between the current embedding and
14
+ * the centroid of recent embeddings.
15
+ *
16
+ * Returns 0–1: 1 = no drift (current topic matches the recent window exactly),
17
+ * 0 = completely unrelated. Empty recentEmbs returns 1.0 (baseline, no prior).
18
+ *
19
+ * Centroid: element-wise arithmetic mean of recentEmbs.
20
+ * cosineSimilarity normalizes internally; centroid need not be unit-normalized.
21
+ */
22
+ export function computeTopicDrift(currentEmb: Vector, recentEmbs: Vector[]): number {
23
+ if (recentEmbs.length === 0) return 1.0;
24
+
25
+ const dim = currentEmb.length;
26
+ // Element-wise mean of recent embeddings.
27
+ const centroid = new Array<number>(dim).fill(0);
28
+ for (const emb of recentEmbs) {
29
+ if (emb.length !== dim) continue; // defensive: skip mismatched-dim rows
30
+ for (let i = 0; i < dim; i++) {
31
+ centroid[i] += emb[i];
32
+ }
33
+ }
34
+ for (let i = 0; i < dim; i++) {
35
+ centroid[i] /= recentEmbs.length;
36
+ }
37
+
38
+ return cosineSimilarity(currentEmb, centroid);
39
+ }
40
+
41
+ // ─── Error escalation ───────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Compute error-rate score from recent error categories.
45
+ *
46
+ * `recentErrorCategories` is a list of error-class strings, or null for
47
+ * non-error turns. Returns 1.0 when the list is empty (no data → assume healthy).
48
+ * The score is 1 - (non-null / total), so an entirely-error list scores 0.
49
+ */
50
+ export function computeErrorEscalation(recentErrorCategories: (string | null)[]): number {
51
+ if (recentErrorCategories.length === 0) return 1.0;
52
+ let errors = 0;
53
+ for (const cat of recentErrorCategories) {
54
+ if (cat !== null) errors++;
55
+ }
56
+ return 1 - errors / recentErrorCategories.length;
57
+ }
58
+
59
+ // ─── Prefix instability ─────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Compute prefix-stability score using the cache-health formula from
63
+ * perf-handler.ts (tryComputeCacheHealth → stabilityScore).
64
+ *
65
+ * The stability formula from perf-handler.ts:
66
+ * instability = min(1, prefixBreakCount / (windowMinutes / 5 * 2))
67
+ * stabilityScore = max(0, 1 - breakSamples.length / maxExpectedBreaks)
68
+ *
69
+ * Here we expose a direct formula:
70
+ * instability = min(1, prefixBreakCount / (windowMinutes / 5 * 2))
71
+ * score = 1 - instability
72
+ * = max(0, 1 - min(1, prefixBreakCount / (windowMinutes / 5 * 2)))
73
+ *
74
+ * Returns 0–1: 1 = perfectly stable (no prefix breaks), 0 = maximally unstable.
75
+ * The window is expressed in minutes so the caller controls the lookback.
76
+ */
77
+ export function computePrefixInstability(
78
+ prefixBreakCount: number,
79
+ windowMinutes: number,
80
+ ): number {
81
+ if (prefixBreakCount <= 0) return 1.0;
82
+ if (windowMinutes <= 0) return 1.0; // defensive: avoid div-by-zero
83
+ const maxExpectedBreaks = (windowMinutes / 5) * 2;
84
+ const instability = Math.min(1, prefixBreakCount / maxExpectedBreaks);
85
+ return Math.max(0, 1 - instability);
86
+ }
87
+
88
+ // ─── Composite drift score ───────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Weighted composite drift score from three sub-components.
92
+ *
93
+ * Weights are domain-informed:
94
+ * topic drift (40%) — the strongest signal of context-switching
95
+ * error rate (35%) — errors correlate strongly with confusion
96
+ * prefix stability (25%) — cache instability is secondary to content quality
97
+ *
98
+ * Returns 0–1: 1 = no drift / perfectly healthy, 0 = severe drift.
99
+ */
100
+ export function computeDriftScore(
101
+ topic: number,
102
+ error: number,
103
+ prefix: number,
104
+ ): number {
105
+ const raw = topic * 0.4 + error * 0.35 + prefix * 0.25;
106
+ return Math.max(0, Math.min(1, raw));
107
+ }
@@ -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";
@@ -112,7 +112,7 @@ export function gatePromotionGuard(ws: GraphWorkingSet): GateResult {
112
112
  continue;
113
113
  }
114
114
  const epochId = n.epochId;
115
- if (epochId && checkpointIds.has(epochId)) {
115
+ if (epochId && checkpointIds.size > 0 && checkpointIds.has(epochId)) {
116
116
  droppedIds.add(n.id);
117
117
  dropped++;
118
118
  log.warn("graph_orphaned_epoch", {
@@ -51,16 +51,26 @@ export function buildCheckpointNodes(
51
51
  nodes: MemoryGraphNode[],
52
52
  edges: MemoryGraphEdge[],
53
53
  ): void {
54
- const rows = db
55
- .prepare(
56
- `SELECT id, session_id, summary, token_estimate, timestamp,
57
- dedup_status, topic_summary, key_decisions,
58
- normalized_text, embedding_blob
59
- FROM context_chunks
60
- WHERE session_id = ?
61
- ORDER BY timestamp ASC`,
62
- )
63
- .all(sessionId) as Array<Record<string, unknown>>;
54
+ const rows = sessionId
55
+ ? (db
56
+ .prepare(
57
+ `SELECT id, session_id, summary, token_estimate, timestamp,
58
+ dedup_status, topic_summary, key_decisions,
59
+ normalized_text, embedding_blob
60
+ FROM context_chunks
61
+ WHERE session_id = ?
62
+ ORDER BY timestamp ASC`,
63
+ )
64
+ .all(sessionId) as Array<Record<string, unknown>>)
65
+ : (db
66
+ .prepare(
67
+ `SELECT id, session_id, summary, token_estimate, timestamp,
68
+ dedup_status, topic_summary, key_decisions,
69
+ normalized_text, embedding_blob
70
+ FROM context_chunks
71
+ ORDER BY timestamp ASC`,
72
+ )
73
+ .all() as Array<Record<string, unknown>>);
64
74
 
65
75
  if (rows.length === 0) return;
66
76
 
@@ -136,15 +146,24 @@ export function buildTurnNodes(
136
146
  edges: MemoryGraphEdge[],
137
147
  ): void {
138
148
  const existingIds = new Set(nodes.map((n) => n.id));
139
- const rows = db
140
- .prepare(
141
- `SELECT turn_index, role, pressure_band, ctx_tokens, ctx_percent,
142
- epoch_id, ended_at
143
- FROM turns
144
- WHERE session_id = ?
145
- ORDER BY turn_index ASC`,
146
- )
147
- .all(sessionId) as Array<Record<string, unknown>>;
149
+ const rows = sessionId
150
+ ? (db
151
+ .prepare(
152
+ `SELECT turn_index, role, pressure_band, ctx_tokens, ctx_percent,
153
+ epoch_id, ended_at
154
+ FROM turns
155
+ WHERE session_id = ?
156
+ ORDER BY turn_index ASC`,
157
+ )
158
+ .all(sessionId) as Array<Record<string, unknown>>)
159
+ : (db
160
+ .prepare(
161
+ `SELECT turn_index, role, pressure_band, ctx_tokens, ctx_percent,
162
+ epoch_id, ended_at
163
+ FROM turns
164
+ ORDER BY turn_index ASC`,
165
+ )
166
+ .all() as Array<Record<string, unknown>>);
148
167
 
149
168
  if (rows.length === 0) return;
150
169
 
@@ -197,15 +216,24 @@ export function buildTurnContentNodes(
197
216
  // (identity_merge) merges them with the richest nodeType winning. Using the
198
217
  // global nodes array here would skip every node (Source A already added them).
199
218
  const seen = new Set<string>();
200
- const rows = db
201
- .prepare(
202
- `SELECT t.turn_index, t.role, t.ended_at, r.content_bytes
203
- FROM turns t
204
- JOIN raw_transcript r ON r.session_id = t.session_id AND r.turn_index = t.turn_index
205
- WHERE t.session_id = ?
206
- ORDER BY t.turn_index ASC`,
207
- )
208
- .all(sessionId) as Array<Record<string, unknown>>;
219
+ const rows = sessionId
220
+ ? (db
221
+ .prepare(
222
+ `SELECT t.turn_index, t.role, t.ended_at, r.content_bytes
223
+ FROM turns t
224
+ JOIN raw_transcript r ON r.session_id = t.session_id AND r.turn_index = t.turn_index
225
+ WHERE t.session_id = ?
226
+ ORDER BY t.turn_index ASC`,
227
+ )
228
+ .all(sessionId) as Array<Record<string, unknown>>)
229
+ : (db
230
+ .prepare(
231
+ `SELECT t.turn_index, t.role, t.ended_at, r.content_bytes
232
+ FROM turns t
233
+ JOIN raw_transcript r ON r.session_id = t.session_id AND r.turn_index = t.turn_index
234
+ ORDER BY t.turn_index ASC`,
235
+ )
236
+ .all() as Array<Record<string, unknown>>);
209
237
 
210
238
  if (rows.length === 0) return;
211
239