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,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
+ }
@@ -0,0 +1,112 @@
1
+ import { cosineSimilarity } from "../embedder.js";
2
+ // ─── Helpers ────────────────────────────────────────────────────────────────
3
+ /** Tokenize on whitespace; skip empty tokens. */
4
+ function tokenizeWords(text) {
5
+ return text.split(/\s+/).filter((w) => w.length > 0);
6
+ }
7
+ /** Split on sentence-terminating punctuation; skip empty. */
8
+ function splitSentences(text) {
9
+ return text.split(/[.!?]+/).map((s) => s.trim()).filter((s) => s.length > 0);
10
+ }
11
+ // ─── Detectors ─────────────────────────────────────────────────────────────
12
+ /**
13
+ * Repetition ratio via 3-gram overlap.
14
+ *
15
+ * Builds sliding 3-word windows over the token stream. Returns the fraction
16
+ * of 3-grams that appear more than once (i.e. are repeated). Range 0–1;
17
+ * values above 0.30 signal likely garbled / looping output.
18
+ */
19
+ export function detectRepetition(text) {
20
+ const words = tokenizeWords(text);
21
+ if (words.length < 3)
22
+ return 0;
23
+ const count = new Map();
24
+ for (let i = 0; i <= words.length - 3; i++) {
25
+ const gram = words.slice(i, i + 3).join(" ");
26
+ count.set(gram, (count.get(gram) ?? 0) + 1);
27
+ }
28
+ const total = words.length - 2;
29
+ if (total === 0)
30
+ return 0;
31
+ let repeated = 0;
32
+ for (const n of count.values()) {
33
+ if (n > 1)
34
+ repeated += n;
35
+ }
36
+ // Each duplicate occurrence beyond the first adds to the repeated count.
37
+ // Clamp total to total_3grams so the ratio stays in [0,1].
38
+ return Math.min(1, repeated / total);
39
+ }
40
+ /**
41
+ * Sentence-level coherence score via adjacent cosine similarity.
42
+ *
43
+ * Splits text into sentences, embeds each, and computes the cosine similarity
44
+ * between every consecutive pair. Returns the average. Range 0–1 (higher =
45
+ * more coherent); fewer than 2 sentences returns 1.0 (cannot measure).
46
+ */
47
+ export function detectCoherence(text, embedder) {
48
+ const sentences = splitSentences(text);
49
+ if (sentences.length < 2)
50
+ return 1.0;
51
+ let sum = 0;
52
+ let pairCount = 0;
53
+ for (let i = 0; i < sentences.length - 1; i++) {
54
+ const a = embedder.embed(sentences[i]);
55
+ const b = embedder.embed(sentences[i + 1]);
56
+ sum += cosineSimilarity(a, b);
57
+ pairCount++;
58
+ }
59
+ return pairCount > 0 ? sum / pairCount : 1.0;
60
+ }
61
+ /**
62
+ * Token-salad detector.
63
+ *
64
+ * A word is "recognized" if it has length ≥ 2 AND contains at least one vowel
65
+ * (a / e / i / o / u, case-insensitive). Returns the fraction of unrecognized
66
+ * words. Values above 0.6 suggest scrambled / token-confused output.
67
+ */
68
+ export function detectTokenSalad(text) {
69
+ const words = tokenizeWords(text);
70
+ if (words.length === 0)
71
+ return 0;
72
+ const VOWELS = /[aeiou]/i;
73
+ let unrecognized = 0;
74
+ for (const w of words) {
75
+ if (w.length < 2 || !VOWELS.test(w))
76
+ unrecognized++;
77
+ }
78
+ return unrecognized / words.length;
79
+ }
80
+ /**
81
+ * True when the output is entirely absent or whitespace-only.
82
+ */
83
+ export function detectEmptyOutput(text) {
84
+ return text.trim().length === 0;
85
+ }
86
+ // ─── Composite ─────────────────────────────────────────────────────────────
87
+ /**
88
+ * Compute the full output-quality profile for assistant output text.
89
+ *
90
+ * Returns a score 0–1 (1 = healthy) and the three sub-diagnostics.
91
+ *
92
+ * Penalty weights (sum to 1.0):
93
+ * repetition 40% — repeated 3-grams are the strongest corruption signal
94
+ * coherence 30% — incoherent sentence transitions
95
+ * salad 30% — garbage tokens
96
+ *
97
+ * Empty output is a hard 0.0 (nothing to evaluate).
98
+ */
99
+ export function computeOutputQuality(text, embedder) {
100
+ const isEmpty = detectEmptyOutput(text);
101
+ if (isEmpty) {
102
+ return { score: 0.0, repetitionRatio: 0, coherenceScore: 1.0, isEmpty: true };
103
+ }
104
+ const repetitionRatio = detectRepetition(text);
105
+ const coherenceScore = detectCoherence(text, embedder);
106
+ const saladRatio = detectTokenSalad(text);
107
+ const repetitionPenalty = repetitionRatio * 0.4;
108
+ const coherencePenalty = (1 - coherenceScore) * 0.3;
109
+ const saladPenalty = saladRatio * 0.3;
110
+ const score = Math.max(0, Math.min(1, 1.0 - repetitionPenalty - coherencePenalty - saladPenalty));
111
+ return { score, repetitionRatio, coherenceScore, isEmpty: false };
112
+ }
@@ -0,0 +1,110 @@
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
+ * Weighted composite health score from five sub-dimensions.
19
+ *
20
+ * Weights:
21
+ * output quality 22% — primary measure of model output health
22
+ * drift 22% — session-level coherence
23
+ * cache poison 20% — prompt-cache integrity
24
+ * cache health 18% — cache hit rate stability
25
+ * error rate 18% — error frequency
26
+ *
27
+ * Returns 0–1: 1 = fully healthy, 0 = severely degraded.
28
+ */
29
+ export function computeHealthScore(sub) {
30
+ const raw = sub.outputQuality * 0.22 +
31
+ sub.drift * 0.22 +
32
+ sub.cachePoison * 0.20 +
33
+ sub.cacheHealth * 0.18 +
34
+ sub.errorRate * 0.18;
35
+ return Math.max(0, Math.min(1, raw));
36
+ }
37
+ /**
38
+ * Record one context health sample into the SQLite store.
39
+ *
40
+ * Non-fatal: errors are caught and silently ignored so instrumentation never
41
+ * blocks the agent loop. Table schema is owned by schema.ts (SCHEMA_VERSION 5).
42
+ *
43
+ * PREVENT-002: all values are bound as parameters (? placeholders).
44
+ */
45
+ export function recordContextHealth(stateDir, row) {
46
+ try {
47
+ const db = openStore(stateDir);
48
+ db.prepare(`INSERT INTO context_health
49
+ (ts, session_id, turn_index, drift_score, output_quality,
50
+ error_score, cache_health, cache_poison, composite,
51
+ model_id, repetition_ratio, coherence_score, prefix_hash)
52
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(row.ts, row.sessionId, row.turnIndex, row.driftScore, row.outputQuality, row.errorScore, row.cacheHealth, row.cachePoison, row.composite, row.modelId ?? null, row.repetitionRatio ?? null, row.coherenceScore ?? null, row.prefixHash ?? null);
53
+ }
54
+ catch {
55
+ /* non-fatal: context health instrumentation never blocks the agent loop */
56
+ }
57
+ }
58
+ /**
59
+ * Read context health samples since `sinceTs`, optionally filtered by modelId.
60
+ *
61
+ * Returns rows in ascending ts order. Non-fatal (empty array on error).
62
+ *
63
+ * PREVENT-002: modelId is bound as a parameter (? placeholder).
64
+ * PREVENT-001: row.meta column (if added later) is handled by null-safe reads.
65
+ */
66
+ export function readContextHealth(stateDir, sinceTs, modelId) {
67
+ try {
68
+ const db = openStore(stateDir);
69
+ const sql = modelId
70
+ ? `SELECT ts, session_id, turn_index, drift_score, output_quality,
71
+ error_score, cache_health, cache_poison, composite,
72
+ model_id, repetition_ratio, coherence_score, prefix_hash
73
+ FROM context_health
74
+ WHERE ts >= ? AND model_id = ?
75
+ ORDER BY ts ASC`
76
+ : `SELECT ts, session_id, turn_index, drift_score, output_quality,
77
+ error_score, cache_health, cache_poison, composite,
78
+ model_id, repetition_ratio, coherence_score, prefix_hash
79
+ FROM context_health
80
+ WHERE ts >= ?
81
+ ORDER BY ts ASC`;
82
+ const params = modelId ? [sinceTs, modelId] : [sinceTs];
83
+ const rows = db.prepare(sql).all(...params);
84
+ const out = [];
85
+ for (const r of rows) {
86
+ out.push({
87
+ ts: r.ts,
88
+ sessionId: r.session_id,
89
+ turnIndex: r.turn_index,
90
+ driftScore: r.drift_score,
91
+ outputQuality: r.output_quality,
92
+ errorScore: r.error_score,
93
+ cacheHealth: r.cache_health,
94
+ cachePoison: r.cache_poison,
95
+ composite: r.composite,
96
+ modelId: r.model_id ?? undefined,
97
+ repetitionRatio: r.repetition_ratio ?? undefined,
98
+ coherenceScore: r.coherence_score ?? undefined,
99
+ prefixHash: r.prefix_hash ?? undefined,
100
+ });
101
+ }
102
+ return out;
103
+ }
104
+ catch {
105
+ return [];
106
+ }
107
+ }
108
+ // ─── Re-exports for consumers ────────────────────────────────────────────────
109
+ export { computeOutputQuality } from "./contextHealth/outputQuality.js";
110
+ export { computeTopicDrift, computeErrorEscalation, computePrefixInstability, computeDriftScore, } from "./contextHealth/drift.js";
@@ -89,7 +89,7 @@ export function gatePromotionGuard(ws) {
89
89
  continue;
90
90
  }
91
91
  const epochId = n.epochId;
92
- if (epochId && checkpointIds.has(epochId)) {
92
+ if (epochId && checkpointIds.size > 0 && checkpointIds.has(epochId)) {
93
93
  droppedIds.add(n.id);
94
94
  dropped++;
95
95
  log.warn("graph_orphaned_epoch", {
@@ -27,14 +27,22 @@ export function areMemoriesEnabled() {
27
27
  // Source: checkpoints (from context_chunks table)
28
28
  // ---------------------------------------------------------------------------
29
29
  export function buildCheckpointNodes(db, sessionId, nodes, edges) {
30
- const rows = db
31
- .prepare(`SELECT id, session_id, summary, token_estimate, timestamp,
32
- dedup_status, topic_summary, key_decisions,
33
- normalized_text, embedding_blob
34
- FROM context_chunks
35
- WHERE session_id = ?
36
- ORDER BY timestamp ASC`)
37
- .all(sessionId);
30
+ const rows = sessionId
31
+ ? db
32
+ .prepare(`SELECT id, session_id, summary, token_estimate, timestamp,
33
+ dedup_status, topic_summary, key_decisions,
34
+ normalized_text, embedding_blob
35
+ FROM context_chunks
36
+ WHERE session_id = ?
37
+ ORDER BY timestamp ASC`)
38
+ .all(sessionId)
39
+ : db
40
+ .prepare(`SELECT id, session_id, summary, token_estimate, timestamp,
41
+ dedup_status, topic_summary, key_decisions,
42
+ normalized_text, embedding_blob
43
+ FROM context_chunks
44
+ ORDER BY timestamp ASC`)
45
+ .all();
38
46
  if (rows.length === 0)
39
47
  return;
40
48
  let prevId = null;
@@ -97,13 +105,20 @@ function addCheckpointSemanticEdges(rows, edges) {
97
105
  // ---------------------------------------------------------------------------
98
106
  export function buildTurnNodes(db, sessionId, nodes, edges) {
99
107
  const existingIds = new Set(nodes.map((n) => n.id));
100
- const rows = db
101
- .prepare(`SELECT turn_index, role, pressure_band, ctx_tokens, ctx_percent,
102
- epoch_id, ended_at
103
- FROM turns
104
- WHERE session_id = ?
105
- ORDER BY turn_index ASC`)
106
- .all(sessionId);
108
+ const rows = sessionId
109
+ ? db
110
+ .prepare(`SELECT turn_index, role, pressure_band, ctx_tokens, ctx_percent,
111
+ epoch_id, ended_at
112
+ FROM turns
113
+ WHERE session_id = ?
114
+ ORDER BY turn_index ASC`)
115
+ .all(sessionId)
116
+ : db
117
+ .prepare(`SELECT turn_index, role, pressure_band, ctx_tokens, ctx_percent,
118
+ epoch_id, ended_at
119
+ FROM turns
120
+ ORDER BY turn_index ASC`)
121
+ .all();
107
122
  if (rows.length === 0)
108
123
  return;
109
124
  let prevId = null;
@@ -146,13 +161,20 @@ export function buildTurnContentNodes(db, sessionId, nodes, edges) {
146
161
  // (identity_merge) merges them with the richest nodeType winning. Using the
147
162
  // global nodes array here would skip every node (Source A already added them).
148
163
  const seen = new Set();
149
- const rows = db
150
- .prepare(`SELECT t.turn_index, t.role, t.ended_at, r.content_bytes
151
- FROM turns t
152
- JOIN raw_transcript r ON r.session_id = t.session_id AND r.turn_index = t.turn_index
153
- WHERE t.session_id = ?
154
- ORDER BY t.turn_index ASC`)
155
- .all(sessionId);
164
+ const rows = sessionId
165
+ ? db
166
+ .prepare(`SELECT t.turn_index, t.role, t.ended_at, r.content_bytes
167
+ FROM turns t
168
+ JOIN raw_transcript r ON r.session_id = t.session_id AND r.turn_index = t.turn_index
169
+ WHERE t.session_id = ?
170
+ ORDER BY t.turn_index ASC`)
171
+ .all(sessionId)
172
+ : db
173
+ .prepare(`SELECT t.turn_index, t.role, t.ended_at, r.content_bytes
174
+ FROM turns t
175
+ JOIN raw_transcript r ON r.session_id = t.session_id AND r.turn_index = t.turn_index
176
+ ORDER BY t.turn_index ASC`)
177
+ .all();
156
178
  if (rows.length === 0)
157
179
  return;
158
180
  const turnContentNodes = [];