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.
Files changed (78) hide show
  1. package/dist/extensions/dashboard-server/routes-health.js +125 -0
  2. package/dist/extensions/dashboard-server/routes.js +1 -0
  3. package/dist/extensions/dashboard-server/server.js +7 -1
  4. package/dist/extensions/mega-config.js +5 -0
  5. package/dist/extensions/mega-events/agent-handlers.js +31 -0
  6. package/dist/extensions/mega-events/health-handler.js +173 -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/meta.js +11 -0
  14. package/dist/src/store/sqlite/schema.js +43 -1
  15. package/dist/src/store/sqlite.js +1 -0
  16. package/extensions/dashboard-client/dist/assets/{AchievementTiles-9KQgKBEh.js → AchievementTiles-Cwg_Ab8R.js} +2 -2
  17. package/extensions/dashboard-client/dist/assets/{AchievementTiles-9KQgKBEh.js.map → AchievementTiles-Cwg_Ab8R.js.map} +1 -1
  18. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B6xoQTEa.js → AchievementsTab-Dk32-4Ha.js} +2 -2
  19. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B6xoQTEa.js.map → AchievementsTab-Dk32-4Ha.js.map} +1 -1
  20. package/extensions/dashboard-client/dist/assets/{CacheTab-Dkm9rawo.js → CacheTab-B9zR8UgD.js} +2 -2
  21. package/extensions/dashboard-client/dist/assets/{CacheTab-Dkm9rawo.js.map → CacheTab-B9zR8UgD.js.map} +1 -1
  22. package/extensions/dashboard-client/dist/assets/{ConfigTab-Bd_v_OUF.js → ConfigTab-DYRl3mJZ.js} +2 -2
  23. package/extensions/dashboard-client/dist/assets/{ConfigTab-Bd_v_OUF.js.map → ConfigTab-DYRl3mJZ.js.map} +1 -1
  24. package/extensions/dashboard-client/dist/assets/{EventsTab-CYWoFOdI.js → EventsTab-r_zXcrmZ.js} +2 -2
  25. package/extensions/dashboard-client/dist/assets/{EventsTab-CYWoFOdI.js.map → EventsTab-r_zXcrmZ.js.map} +1 -1
  26. package/extensions/dashboard-client/dist/assets/{GameTab-CH7lecNL.js → GameTab-DjwA_Q1v.js} +2 -2
  27. package/extensions/dashboard-client/dist/assets/{GameTab-CH7lecNL.js.map → GameTab-DjwA_Q1v.js.map} +1 -1
  28. package/extensions/dashboard-client/dist/assets/HealthTab-BjH73kQr.js +2 -0
  29. package/extensions/dashboard-client/dist/assets/HealthTab-BjH73kQr.js.map +1 -0
  30. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DzeSq9vK.js +2 -0
  31. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DzeSq9vK.js.map +1 -0
  32. package/extensions/dashboard-client/dist/assets/{MemoryMapTab-tVRREYiG.js → MemoryMapTab-BU9iM3ir.js} +2 -2
  33. package/extensions/dashboard-client/dist/assets/{MemoryMapTab-tVRREYiG.js.map → MemoryMapTab-BU9iM3ir.js.map} +1 -1
  34. package/extensions/dashboard-client/dist/assets/{MetricsTab-CHblYuTY.js → MetricsTab-lMVodOwY.js} +2 -2
  35. package/extensions/dashboard-client/dist/assets/{MetricsTab-CHblYuTY.js.map → MetricsTab-lMVodOwY.js.map} +1 -1
  36. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dj0TgFvU.js → OverviewTab-D-laa6CW.js} +2 -2
  37. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dj0TgFvU.js.map → OverviewTab-D-laa6CW.js.map} +1 -1
  38. package/extensions/dashboard-client/dist/assets/{ReposTab-BTJcfc1W.js → ReposTab-5Pn2cOOO.js} +2 -2
  39. package/extensions/dashboard-client/dist/assets/{ReposTab-BTJcfc1W.js.map → ReposTab-5Pn2cOOO.js.map} +1 -1
  40. package/extensions/dashboard-client/dist/assets/{SessionsTab-C8tU8K5u.js → SessionsTab-Dtdmvl2C.js} +2 -2
  41. package/extensions/dashboard-client/dist/assets/{SessionsTab-C8tU8K5u.js.map → SessionsTab-Dtdmvl2C.js.map} +1 -1
  42. package/extensions/dashboard-client/dist/assets/{SetupTab-BdxGMo6O.js → SetupTab-rKGgWpI0.js} +2 -2
  43. package/extensions/dashboard-client/dist/assets/{SetupTab-BdxGMo6O.js.map → SetupTab-rKGgWpI0.js.map} +1 -1
  44. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-BWZSARzT.js → TimeSavedCard-B5Lw6fTp.js} +2 -2
  45. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-BWZSARzT.js.map → TimeSavedCard-B5Lw6fTp.js.map} +1 -1
  46. package/extensions/dashboard-client/dist/assets/{TopicsTab-BCJwPYl8.js → TopicsTab-Ds1IHhPw.js} +2 -2
  47. package/extensions/dashboard-client/dist/assets/{TopicsTab-BCJwPYl8.js.map → TopicsTab-Ds1IHhPw.js.map} +1 -1
  48. package/extensions/dashboard-client/dist/assets/{TurnsTab-DQFTSDpz.js → TurnsTab-CG2RDRmh.js} +2 -2
  49. package/extensions/dashboard-client/dist/assets/{TurnsTab-DQFTSDpz.js.map → TurnsTab-CG2RDRmh.js.map} +1 -1
  50. package/extensions/dashboard-client/dist/assets/health-lSeDFTz7.js +2 -0
  51. package/extensions/dashboard-client/dist/assets/health-lSeDFTz7.js.map +1 -0
  52. package/extensions/dashboard-client/dist/assets/{index-DrrDswj-.js → index-C9dTXvDQ.js} +11 -11
  53. package/extensions/dashboard-client/dist/assets/{index-DrrDswj-.js.map → index-C9dTXvDQ.js.map} +1 -1
  54. package/extensions/dashboard-client/dist/assets/{useSSE-o4tteeTs.js → useSSE-B6l8O-yi.js} +2 -2
  55. package/extensions/dashboard-client/dist/assets/{useSSE-o4tteeTs.js.map → useSSE-B6l8O-yi.js.map} +1 -1
  56. package/extensions/dashboard-client/dist/index.html +1 -1
  57. package/extensions/dashboard-client/src/App.tsx +5 -1
  58. package/extensions/dashboard-client/src/api/health.ts +80 -0
  59. package/extensions/dashboard-client/src/tabs/HealthTab.tsx +165 -0
  60. package/extensions/dashboard-client/src/tabs/MaintenanceTab.tsx +91 -1
  61. package/extensions/dashboard-server/routes-health.ts +155 -0
  62. package/extensions/dashboard-server/routes.ts +1 -0
  63. package/extensions/dashboard-server/server.ts +6 -0
  64. package/extensions/mega-config.ts +15 -0
  65. package/extensions/mega-events/agent-handlers.ts +31 -0
  66. package/extensions/mega-events/health-handler.ts +218 -0
  67. package/extensions/mega-runtime/runtime.ts +7 -0
  68. package/package.json +1 -1
  69. package/src/contextHealth/cachePoison.ts +243 -0
  70. package/src/contextHealth/drift.ts +107 -0
  71. package/src/contextHealth/outputQuality.ts +139 -0
  72. package/src/contextHealth.ts +191 -0
  73. package/src/store/sqlite/context-health.ts +342 -0
  74. package/src/store/sqlite/meta.ts +16 -0
  75. package/src/store/sqlite/schema.ts +43 -1
  76. package/src/store/sqlite.ts +1 -0
  77. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DNZ1IF4t.js +0 -2
  78. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DNZ1IF4t.js.map +0 -1
@@ -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";
@@ -0,0 +1,193 @@
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
+ // ─── context_health accessors ────────────────────────────────────────────────
18
+ /**
19
+ * Record one context health sample. All values are fully parameterized
20
+ * (PREVENT-002). Optional columns (model_id, repetition_ratio, coherence_score,
21
+ * prefix_hash) are passed as-is; the column list enumerates every field so
22
+ * missing optional fields are NULL in the row.
23
+ *
24
+ * Non-fatal: any write error is logged to stderr and silently swallowed so
25
+ * instrumentation can never block the agent loop.
26
+ */
27
+ export function recordContextHealthRow(stateDir = getStateDir(), row) {
28
+ try {
29
+ const db = openStore(stateDir);
30
+ db.prepare(`INSERT INTO context_health
31
+ (ts, turn_index, session_id, drift_score, output_quality,
32
+ error_score, cache_health, cache_poison, composite,
33
+ model_id, repetition_ratio, coherence_score, prefix_hash)
34
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(row.ts, row.turnIndex, row.sessionId, row.driftScore, row.outputQuality, row.errorScore, row.cacheHealth, row.cachePoison, row.composite, row.modelId ?? null, row.repetitionRatio ?? null, row.coherenceScore ?? null, row.prefixHash ?? null);
35
+ }
36
+ catch (err) {
37
+ // Non-fatal: log and continue — health sampling must never block the agent.
38
+ const msg = err instanceof Error ? err.message : String(err);
39
+ process.stderr.write(`[context-health] recordContextHealthRow error: ${msg}\n`);
40
+ }
41
+ }
42
+ /**
43
+ * Read context health rows since `sinceTs`. When `modelId` is provided the
44
+ * query adds an AND filter; otherwise all models are returned. Results are
45
+ * ordered ascending by ts. Returns an empty array on any error (non-fatal).
46
+ *
47
+ * SQL is fully parameterized (PREVENT-002).
48
+ */
49
+ export function readContextHealthRows(stateDir = getStateDir(), sinceTs, modelId) {
50
+ try {
51
+ const db = openStore(stateDir);
52
+ let rows;
53
+ if (modelId != null) {
54
+ rows = db
55
+ .prepare(`SELECT id, ts, turn_index, session_id, drift_score, output_quality,
56
+ error_score, cache_health, cache_poison, composite,
57
+ model_id, repetition_ratio, coherence_score, prefix_hash
58
+ FROM context_health
59
+ WHERE ts >= ? AND model_id = ?
60
+ ORDER BY ts ASC`)
61
+ .all(sinceTs, modelId);
62
+ }
63
+ else {
64
+ rows = db
65
+ .prepare(`SELECT id, ts, turn_index, session_id, drift_score, output_quality,
66
+ error_score, cache_health, cache_poison, composite,
67
+ model_id, repetition_ratio, coherence_score, prefix_hash
68
+ FROM context_health
69
+ WHERE ts >= ?
70
+ ORDER BY ts ASC`)
71
+ .all(sinceTs);
72
+ }
73
+ return rows.map((r) => ({
74
+ id: r.id,
75
+ ts: r.ts,
76
+ turnIndex: r.turn_index,
77
+ sessionId: r.session_id,
78
+ driftScore: r.drift_score,
79
+ outputQuality: r.output_quality,
80
+ errorScore: r.error_score,
81
+ cacheHealth: r.cache_health,
82
+ cachePoison: r.cache_poison,
83
+ composite: r.composite,
84
+ modelId: r.model_id,
85
+ repetitionRatio: r.repetition_ratio,
86
+ coherenceScore: r.coherence_score,
87
+ prefixHash: r.prefix_hash,
88
+ }));
89
+ }
90
+ catch {
91
+ // Non-fatal: return empty array on DB error.
92
+ return [];
93
+ }
94
+ }
95
+ /**
96
+ * Read the most recent context_health row (ORDER BY ts DESC LIMIT 1).
97
+ * Returns null when no rows exist or on any error.
98
+ */
99
+ export function readLatestContextHealth(stateDir = getStateDir()) {
100
+ try {
101
+ const db = openStore(stateDir);
102
+ const row = db
103
+ .prepare(`SELECT composite, drift_score, output_quality, error_score,
104
+ cache_health, cache_poison, ts, model_id
105
+ FROM context_health
106
+ ORDER BY ts DESC
107
+ LIMIT 1`)
108
+ .get();
109
+ if (!row)
110
+ return null;
111
+ return {
112
+ composite: row.composite,
113
+ driftScore: row.drift_score,
114
+ outputQuality: row.output_quality,
115
+ errorScore: row.error_score,
116
+ cacheHealth: row.cache_health,
117
+ cachePoison: row.cache_poison,
118
+ ts: row.ts,
119
+ modelId: row.model_id,
120
+ };
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ /**
127
+ * Read the trailing `limit` composite scores (most recent first), then reverse
128
+ * to chronological order for sparkline rendering. Returns an empty array when
129
+ * no rows exist or on any error.
130
+ *
131
+ * SQL is fully parameterized (PREVENT-002).
132
+ */
133
+ export function readContextHealthTrend(stateDir = getStateDir(), limit) {
134
+ try {
135
+ const db = openStore(stateDir);
136
+ const rows = db
137
+ .prepare(`SELECT composite FROM context_health
138
+ ORDER BY ts DESC LIMIT ?`)
139
+ .all(limit);
140
+ // Reverse to chronological (oldest first) for sparkline display.
141
+ return rows.map((r) => r.composite).reverse();
142
+ }
143
+ catch {
144
+ return [];
145
+ }
146
+ }
147
+ // ─── cache_poison_events accessors ───────────────────────────────────────────
148
+ /**
149
+ * Record one cache poison advisory event. Fully parameterized (PREVENT-002).
150
+ * Non-fatal: write errors are logged to stderr and silently swallowed.
151
+ */
152
+ export function recordCachePoisonEvent(stateDir = getStateDir(), event) {
153
+ try {
154
+ const db = openStore(stateDir);
155
+ db.prepare(`INSERT INTO cache_poison_events
156
+ (ts, turn_index, session_id, layer, detail, severity)
157
+ VALUES (?, ?, ?, ?, ?, ?)`).run(event.ts, event.turnIndex, event.sessionId, event.layer, event.detail, event.severity);
158
+ }
159
+ catch (err) {
160
+ // Non-fatal: advisory logging must never block the agent loop.
161
+ const msg = err instanceof Error ? err.message : String(err);
162
+ process.stderr.write(`[context-health] recordCachePoisonEvent error: ${msg}\n`);
163
+ }
164
+ }
165
+ /**
166
+ * Read cache poison events since `sinceTs`, ordered descending by ts (most
167
+ * recent first), capped at 100 rows. Returns an empty array on any error
168
+ * (non-fatal). SQL is fully parameterized (PREVENT-002).
169
+ */
170
+ export function readCachePoisonEvents(stateDir = getStateDir(), sinceTs) {
171
+ try {
172
+ const db = openStore(stateDir);
173
+ const rows = db
174
+ .prepare(`SELECT id, ts, turn_index, session_id, layer, detail, severity
175
+ FROM cache_poison_events
176
+ WHERE ts >= ?
177
+ ORDER BY ts DESC
178
+ LIMIT 100`)
179
+ .all(sinceTs);
180
+ return rows.map((r) => ({
181
+ id: r.id,
182
+ ts: r.ts,
183
+ turnIndex: r.turn_index,
184
+ sessionId: r.session_id,
185
+ layer: r.layer,
186
+ detail: r.detail,
187
+ severity: r.severity,
188
+ }));
189
+ }
190
+ catch {
191
+ return [];
192
+ }
193
+ }
@@ -57,6 +57,17 @@ export function bumpDedupStats(deduped, stateDir = getStateDir()) {
57
57
  if (deduped)
58
58
  incMeta("deduped", 1, stateDir);
59
59
  }
60
+ // --- Context health mitigation (runtime-toggleable via dashboard) ----------
61
+ /** Read the context_health_mitigate flag from meta. Returns false if unset. */
62
+ export function getHealthMitigate(stateDir = getStateDir()) {
63
+ return getMeta("context_health_mitigate", stateDir) === "true";
64
+ }
65
+ /** Set the context_health_mitigate flag in meta (runtime-toggleable). */
66
+ export function setHealthMitigate(enabled, stateDir = getStateDir()) {
67
+ const db = openStore(stateDir);
68
+ db.prepare(`INSERT INTO meta(key, value) VALUES('context_health_mitigate', ?)
69
+ ON CONFLICT(key) DO UPDATE SET value = ?`).run(enabled ? "true" : "false", enabled ? "true" : "false");
70
+ }
60
71
  // --- Live dashboard counters (schemaless meta key/value — NO migration) -----
61
72
  // These reuse the private `incMeta` atomically-incrementing integer counter so
62
73
  // all cumulative tallies live in the same `meta` table as tokens_saved etc.
@@ -1,5 +1,5 @@
1
1
  import { ACHIEVEMENT_DEFS } from "../../game/scoring.js";
2
- const SCHEMA_VERSION = 4;
2
+ const SCHEMA_VERSION = 5;
3
3
  /**
4
4
  * Add `column` (with `decl`, e.g. "INTEGER") to `table` if it does not already
5
5
  * exist. Idempotent: checks PRAGMA table_info first, so it is safe to run on
@@ -412,6 +412,48 @@ export function initSchema(db) {
412
412
  );
413
413
  CREATE INDEX IF NOT EXISTS idx_emb_cache_computed
414
414
  ON embedding_cache(computed_at DESC);
415
+
416
+ -- A2 PLAN_V2 Phase 2 (SCHEMA_VERSION 5): Context Health metrics. One row
417
+ -- per turn recorded during compactSession. drift_score / output_quality /
418
+ -- error_score / cache_health / cache_poison / composite are the core signal
419
+ -- columns; repetition_ratio / coherence_score / prefix_hash are optional
420
+ -- enrichment. Parameterized accessors in context-health.ts (PREVENT-002).
421
+ CREATE TABLE IF NOT EXISTS context_health (
422
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
423
+ ts INTEGER NOT NULL,
424
+ turn_index INTEGER NOT NULL,
425
+ session_id TEXT NOT NULL,
426
+ drift_score REAL,
427
+ output_quality REAL,
428
+ error_score REAL,
429
+ cache_health REAL,
430
+ cache_poison REAL,
431
+ composite REAL,
432
+ model_id TEXT,
433
+ repetition_ratio REAL,
434
+ coherence_score REAL,
435
+ prefix_hash TEXT
436
+ );
437
+ CREATE INDEX IF NOT EXISTS idx_ctx_health_ts
438
+ ON context_health(ts);
439
+ CREATE INDEX IF NOT EXISTS idx_ctx_health_session
440
+ ON context_health(session_id, ts);
441
+
442
+ -- A2 PLAN_V2 Phase 2 (SCHEMA_VERSION 5): Cache poison advisory events.
443
+ -- Emitted when cache corruption / inconsistency is detected at a given
444
+ -- layer during compactSession. Drives the R13 advisory channel redesign.
445
+ -- Parameterized accessors in context-health.ts (PREVENT-002).
446
+ CREATE TABLE IF NOT EXISTS cache_poison_events (
447
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
448
+ ts INTEGER NOT NULL,
449
+ turn_index INTEGER NOT NULL,
450
+ session_id TEXT NOT NULL,
451
+ layer INTEGER,
452
+ detail TEXT,
453
+ severity TEXT
454
+ );
455
+ CREATE INDEX IF NOT EXISTS idx_cache_poison_ts
456
+ ON cache_poison_events(ts);
415
457
  `);
416
458
  // Idempotent column migrations. `CREATE TABLE IF NOT EXISTS` is a no-op on a
417
459
  // 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";
@@ -1,2 +1,2 @@
1
- import{r as o,j as n}from"./index-DrrDswj-.js";function x(s){try{return new Date(s*1e3).toLocaleString()}catch{return"—"}}function f({achievements:s}){const i=o.useRef(0),[r,a]=o.useState(null),l=s.reduce((e,t)=>Math.max(e,t.unlocked_at??0),0),c=i.current;o.useEffect(()=>{l>i.current&&(i.current=l)},[l]),o.useEffect(()=>{if(c>0&&l>c){const e=s.filter(t=>t.unlocked_at!=null&&(t.unlocked_at??0)>c);if(e.length>0){const t=e.map(d=>`${d.icon??""} ${d.title}`.trim()).join(", ")+" unlocked!";a(t);const h=setTimeout(()=>a(null),4e3);return()=>clearTimeout(h)}}},[s,l,c]);const u=s.filter(e=>!(e.hidden===1&&e.unlocked_at==null));return n.jsxs("div",{className:"ach-section",children:[r!=null&&n.jsx("div",{className:"ach-toast show",children:r}),n.jsxs("div",{className:"ach-tiles",children:[u.length===0&&n.jsx("span",{className:"repo-none",children:"no achievements yet"}),u.map(e=>{if(e.unlocked_at!=null){const t=c>0&&(e.unlocked_at??0)>c;return n.jsxs("div",{className:`ach-tile unlocked${t?" just-unlocked":""}`,children:[e.icon??""," ",e.title,n.jsxs("span",{className:"ach-detail",children:["unlocked ",x(e.unlocked_at)]})]},e.id)}return n.jsxs("div",{className:"ach-tile locked",children:["??? ",e.title]},e.id)})]})]})}export{f as A};
2
- //# sourceMappingURL=AchievementTiles-9KQgKBEh.js.map
1
+ import{r as o,j as n}from"./index-C9dTXvDQ.js";function x(s){try{return new Date(s*1e3).toLocaleString()}catch{return"—"}}function f({achievements:s}){const i=o.useRef(0),[r,a]=o.useState(null),l=s.reduce((e,t)=>Math.max(e,t.unlocked_at??0),0),c=i.current;o.useEffect(()=>{l>i.current&&(i.current=l)},[l]),o.useEffect(()=>{if(c>0&&l>c){const e=s.filter(t=>t.unlocked_at!=null&&(t.unlocked_at??0)>c);if(e.length>0){const t=e.map(d=>`${d.icon??""} ${d.title}`.trim()).join(", ")+" unlocked!";a(t);const h=setTimeout(()=>a(null),4e3);return()=>clearTimeout(h)}}},[s,l,c]);const u=s.filter(e=>!(e.hidden===1&&e.unlocked_at==null));return n.jsxs("div",{className:"ach-section",children:[r!=null&&n.jsx("div",{className:"ach-toast show",children:r}),n.jsxs("div",{className:"ach-tiles",children:[u.length===0&&n.jsx("span",{className:"repo-none",children:"no achievements yet"}),u.map(e=>{if(e.unlocked_at!=null){const t=c>0&&(e.unlocked_at??0)>c;return n.jsxs("div",{className:`ach-tile unlocked${t?" just-unlocked":""}`,children:[e.icon??""," ",e.title,n.jsxs("span",{className:"ach-detail",children:["unlocked ",x(e.unlocked_at)]})]},e.id)}return n.jsxs("div",{className:"ach-tile locked",children:["??? ",e.title]},e.id)})]})]})}export{f as A};
2
+ //# sourceMappingURL=AchievementTiles-Cwg_Ab8R.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"AchievementTiles-9KQgKBEh.js","sources":["../../src/components/AchievementTiles.tsx"],"sourcesContent":["/**\n * dashboard-client/src/components/AchievementTiles.tsx — shared achievement grid.\n *\n * Renders a grid of achievement tiles from fetchAchievements() data.\n * Tile states: unlocked (icon+title+date), visible-locked (??? teaser),\n * hidden+locked (not rendered). Just-unlocked tiles get a pulse animation.\n * Toast notification fires for newly-unlocked achievements.\n *\n * Used by both GameTab (sub-section) and AchievementsTab (standalone).\n */\n\nimport type React from \"react\";\nimport { useRef, useState, useEffect } from \"react\";\nimport type { AchievementRow } from \"@contracts\";\n\nexport interface AchievementTilesProps {\n\t/** Achievement rows from /api/achievements. */\n\tachievements: AchievementRow[];\n}\n\n/** Format unlock timestamp (seconds → locale string). */\nfunction fmtUnlockDate(ts: number): string {\n\ttry {\n\t\treturn new Date(ts * 1000).toLocaleString();\n\t} catch {\n\t\treturn \"\\u2014\";\n\t}\n}\n\nexport function AchievementTiles({\n\tachievements,\n}: AchievementTilesProps): React.ReactElement {\n\tconst lastMaxTsRef = useRef(0);\n\tconst [toastText, setToastText] = useState<string | null>(null);\n\n\tconst maxTs = achievements.reduce(\n\t\t(mx, a) => Math.max(mx, a.unlocked_at ?? 0),\n\t\t0,\n\t);\n\tconst prevMaxTs = lastMaxTsRef.current;\n\n\t/* Update ref after render so next poll sees previous baseline. */\n\tuseEffect(() => {\n\t\tif (maxTs > lastMaxTsRef.current) {\n\t\t\tlastMaxTsRef.current = maxTs;\n\t\t}\n\t}, [maxTs]);\n\n\t/* Toast for newly-unlocked achievements. */\n\tuseEffect(() => {\n\t\tif (prevMaxTs > 0 && maxTs > prevMaxTs) {\n\t\t\tconst newly = achievements.filter(\n\t\t\t\t(a) => a.unlocked_at != null && (a.unlocked_at ?? 0) > prevMaxTs,\n\t\t\t);\n\t\t\tif (newly.length > 0) {\n\t\t\t\tconst text =\n\t\t\t\t\tnewly\n\t\t\t\t\t\t.map((a) => `${a.icon ?? \"\"} ${a.title}`.trim())\n\t\t\t\t\t\t.join(\", \") + \" unlocked!\";\n\t\t\t\tsetToastText(text);\n\t\t\t\tconst t = setTimeout(() => setToastText(null), 4000);\n\t\t\t\treturn (): void => clearTimeout(t);\n\t\t\t}\n\t\t}\n\t}, [achievements, maxTs, prevMaxTs]);\n\n\t/* Visible tiles: skip hidden+locked (hidden === 1 && unlocked_at == null). */\n\tconst tiles = achievements.filter(\n\t\t(a) => !(a.hidden === 1 && a.unlocked_at == null),\n\t);\n\n\treturn (\n\t\t<div className=\"ach-section\">\n\t\t\t{toastText != null && (\n\t\t\t\t<div className=\"ach-toast show\">{toastText}</div>\n\t\t\t)}\n\t\t\t<div className=\"ach-tiles\">\n\t\t\t\t{tiles.length === 0 && (\n\t\t\t\t\t<span className=\"repo-none\">no achievements yet</span>\n\t\t\t\t)}\n\t\t\t\t{tiles.map((a) => {\n\t\t\t\t\tif (a.unlocked_at != null) {\n\t\t\t\t\t\tconst isNew = prevMaxTs > 0 && (a.unlocked_at ?? 0) > prevMaxTs;\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tkey={a.id}\n\t\t\t\t\t\t\t\tclassName={`ach-tile unlocked${isNew ? \" just-unlocked\" : \"\"}`}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{a.icon ?? \"\"} {a.title}\n\t\t\t\t\t\t\t\t<span className=\"ach-detail\">\n\t\t\t\t\t\t\t\t\tunlocked {fmtUnlockDate(a.unlocked_at)}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<div key={a.id} className=\"ach-tile locked\">\n\t\t\t\t\t\t\t??? {a.title}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t);\n\t\t\t\t})}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"],"names":["fmtUnlockDate","ts","AchievementTiles","achievements","lastMaxTsRef","useRef","toastText","setToastText","useState","maxTs","mx","a","prevMaxTs","useEffect","newly","text","t","tiles","jsxs","jsx","isNew"],"mappings":"+CAqBA,SAASA,EAAcC,EAAoB,CAC1C,GAAI,CACH,OAAO,IAAI,KAAKA,EAAK,GAAI,EAAE,eAAA,CAC5B,MAAQ,CACP,MAAO,GACR,CACD,CAEO,SAASC,EAAiB,CAChC,aAAAC,CACD,EAA8C,CAC7C,MAAMC,EAAeC,EAAAA,OAAO,CAAC,EACvB,CAACC,EAAWC,CAAY,EAAIC,EAAAA,SAAwB,IAAI,EAExDC,EAAQN,EAAa,OAC1B,CAACO,EAAIC,IAAM,KAAK,IAAID,EAAIC,EAAE,aAAe,CAAC,EAC1C,CAAA,EAEKC,EAAYR,EAAa,QAG/BS,EAAAA,UAAU,IAAM,CACXJ,EAAQL,EAAa,UACxBA,EAAa,QAAUK,EAEzB,EAAG,CAACA,CAAK,CAAC,EAGVI,EAAAA,UAAU,IAAM,CACf,GAAID,EAAY,GAAKH,EAAQG,EAAW,CACvC,MAAME,EAAQX,EAAa,OACzBQ,GAAMA,EAAE,aAAe,OAASA,EAAE,aAAe,GAAKC,CAAA,EAExD,GAAIE,EAAM,OAAS,EAAG,CACrB,MAAMC,EACLD,EACE,IAAKH,GAAM,GAAGA,EAAE,MAAQ,EAAE,IAAIA,EAAE,KAAK,GAAG,KAAA,CAAM,EAC9C,KAAK,IAAI,EAAI,aAChBJ,EAAaQ,CAAI,EACjB,MAAMC,EAAI,WAAW,IAAMT,EAAa,IAAI,EAAG,GAAI,EACnD,MAAO,IAAY,aAAaS,CAAC,CAClC,CACD,CACD,EAAG,CAACb,EAAcM,EAAOG,CAAS,CAAC,EAGnC,MAAMK,EAAQd,EAAa,OACzBQ,GAAM,EAAEA,EAAE,SAAW,GAAKA,EAAE,aAAe,KAAA,EAG7C,OACCO,EAAAA,KAAC,MAAA,CAAI,UAAU,cACb,SAAA,CAAAZ,GAAa,MACba,EAAAA,IAAC,MAAA,CAAI,UAAU,iBAAkB,SAAAb,EAAU,EAE5CY,EAAAA,KAAC,MAAA,CAAI,UAAU,YACb,SAAA,CAAAD,EAAM,SAAW,GACjBE,MAAC,OAAA,CAAK,UAAU,YAAY,SAAA,sBAAmB,EAE/CF,EAAM,IAAKN,GAAM,CACjB,GAAIA,EAAE,aAAe,KAAM,CAC1B,MAAMS,EAAQR,EAAY,IAAMD,EAAE,aAAe,GAAKC,EACtD,OACCM,EAAAA,KAAC,MAAA,CAEA,UAAW,oBAAoBE,EAAQ,iBAAmB,EAAE,GAE3D,SAAA,CAAAT,EAAE,MAAQ,GAAG,IAAEA,EAAE,MAClBO,EAAAA,KAAC,OAAA,CAAK,UAAU,aAAa,SAAA,CAAA,YAClBlB,EAAcW,EAAE,WAAW,CAAA,CAAA,CACtC,CAAA,CAAA,EANKA,EAAE,EAAA,CASV,CACA,OACCO,EAAAA,KAAC,MAAA,CAAe,UAAU,kBAAkB,SAAA,CAAA,OACtCP,EAAE,KAAA,CAAA,EADEA,EAAE,EAEZ,CAEF,CAAC,CAAA,CAAA,CACF,CAAA,EACD,CAEF"}
1
+ {"version":3,"file":"AchievementTiles-Cwg_Ab8R.js","sources":["../../src/components/AchievementTiles.tsx"],"sourcesContent":["/**\n * dashboard-client/src/components/AchievementTiles.tsx — shared achievement grid.\n *\n * Renders a grid of achievement tiles from fetchAchievements() data.\n * Tile states: unlocked (icon+title+date), visible-locked (??? teaser),\n * hidden+locked (not rendered). Just-unlocked tiles get a pulse animation.\n * Toast notification fires for newly-unlocked achievements.\n *\n * Used by both GameTab (sub-section) and AchievementsTab (standalone).\n */\n\nimport type React from \"react\";\nimport { useRef, useState, useEffect } from \"react\";\nimport type { AchievementRow } from \"@contracts\";\n\nexport interface AchievementTilesProps {\n\t/** Achievement rows from /api/achievements. */\n\tachievements: AchievementRow[];\n}\n\n/** Format unlock timestamp (seconds → locale string). */\nfunction fmtUnlockDate(ts: number): string {\n\ttry {\n\t\treturn new Date(ts * 1000).toLocaleString();\n\t} catch {\n\t\treturn \"\\u2014\";\n\t}\n}\n\nexport function AchievementTiles({\n\tachievements,\n}: AchievementTilesProps): React.ReactElement {\n\tconst lastMaxTsRef = useRef(0);\n\tconst [toastText, setToastText] = useState<string | null>(null);\n\n\tconst maxTs = achievements.reduce(\n\t\t(mx, a) => Math.max(mx, a.unlocked_at ?? 0),\n\t\t0,\n\t);\n\tconst prevMaxTs = lastMaxTsRef.current;\n\n\t/* Update ref after render so next poll sees previous baseline. */\n\tuseEffect(() => {\n\t\tif (maxTs > lastMaxTsRef.current) {\n\t\t\tlastMaxTsRef.current = maxTs;\n\t\t}\n\t}, [maxTs]);\n\n\t/* Toast for newly-unlocked achievements. */\n\tuseEffect(() => {\n\t\tif (prevMaxTs > 0 && maxTs > prevMaxTs) {\n\t\t\tconst newly = achievements.filter(\n\t\t\t\t(a) => a.unlocked_at != null && (a.unlocked_at ?? 0) > prevMaxTs,\n\t\t\t);\n\t\t\tif (newly.length > 0) {\n\t\t\t\tconst text =\n\t\t\t\t\tnewly\n\t\t\t\t\t\t.map((a) => `${a.icon ?? \"\"} ${a.title}`.trim())\n\t\t\t\t\t\t.join(\", \") + \" unlocked!\";\n\t\t\t\tsetToastText(text);\n\t\t\t\tconst t = setTimeout(() => setToastText(null), 4000);\n\t\t\t\treturn (): void => clearTimeout(t);\n\t\t\t}\n\t\t}\n\t}, [achievements, maxTs, prevMaxTs]);\n\n\t/* Visible tiles: skip hidden+locked (hidden === 1 && unlocked_at == null). */\n\tconst tiles = achievements.filter(\n\t\t(a) => !(a.hidden === 1 && a.unlocked_at == null),\n\t);\n\n\treturn (\n\t\t<div className=\"ach-section\">\n\t\t\t{toastText != null && (\n\t\t\t\t<div className=\"ach-toast show\">{toastText}</div>\n\t\t\t)}\n\t\t\t<div className=\"ach-tiles\">\n\t\t\t\t{tiles.length === 0 && (\n\t\t\t\t\t<span className=\"repo-none\">no achievements yet</span>\n\t\t\t\t)}\n\t\t\t\t{tiles.map((a) => {\n\t\t\t\t\tif (a.unlocked_at != null) {\n\t\t\t\t\t\tconst isNew = prevMaxTs > 0 && (a.unlocked_at ?? 0) > prevMaxTs;\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tkey={a.id}\n\t\t\t\t\t\t\t\tclassName={`ach-tile unlocked${isNew ? \" just-unlocked\" : \"\"}`}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{a.icon ?? \"\"} {a.title}\n\t\t\t\t\t\t\t\t<span className=\"ach-detail\">\n\t\t\t\t\t\t\t\t\tunlocked {fmtUnlockDate(a.unlocked_at)}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<div key={a.id} className=\"ach-tile locked\">\n\t\t\t\t\t\t\t??? {a.title}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t);\n\t\t\t\t})}\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n"],"names":["fmtUnlockDate","ts","AchievementTiles","achievements","lastMaxTsRef","useRef","toastText","setToastText","useState","maxTs","mx","a","prevMaxTs","useEffect","newly","text","t","tiles","jsxs","jsx","isNew"],"mappings":"+CAqBA,SAASA,EAAcC,EAAoB,CAC1C,GAAI,CACH,OAAO,IAAI,KAAKA,EAAK,GAAI,EAAE,eAAA,CAC5B,MAAQ,CACP,MAAO,GACR,CACD,CAEO,SAASC,EAAiB,CAChC,aAAAC,CACD,EAA8C,CAC7C,MAAMC,EAAeC,EAAAA,OAAO,CAAC,EACvB,CAACC,EAAWC,CAAY,EAAIC,EAAAA,SAAwB,IAAI,EAExDC,EAAQN,EAAa,OAC1B,CAACO,EAAIC,IAAM,KAAK,IAAID,EAAIC,EAAE,aAAe,CAAC,EAC1C,CAAA,EAEKC,EAAYR,EAAa,QAG/BS,EAAAA,UAAU,IAAM,CACXJ,EAAQL,EAAa,UACxBA,EAAa,QAAUK,EAEzB,EAAG,CAACA,CAAK,CAAC,EAGVI,EAAAA,UAAU,IAAM,CACf,GAAID,EAAY,GAAKH,EAAQG,EAAW,CACvC,MAAME,EAAQX,EAAa,OACzBQ,GAAMA,EAAE,aAAe,OAASA,EAAE,aAAe,GAAKC,CAAA,EAExD,GAAIE,EAAM,OAAS,EAAG,CACrB,MAAMC,EACLD,EACE,IAAKH,GAAM,GAAGA,EAAE,MAAQ,EAAE,IAAIA,EAAE,KAAK,GAAG,KAAA,CAAM,EAC9C,KAAK,IAAI,EAAI,aAChBJ,EAAaQ,CAAI,EACjB,MAAMC,EAAI,WAAW,IAAMT,EAAa,IAAI,EAAG,GAAI,EACnD,MAAO,IAAY,aAAaS,CAAC,CAClC,CACD,CACD,EAAG,CAACb,EAAcM,EAAOG,CAAS,CAAC,EAGnC,MAAMK,EAAQd,EAAa,OACzBQ,GAAM,EAAEA,EAAE,SAAW,GAAKA,EAAE,aAAe,KAAA,EAG7C,OACCO,EAAAA,KAAC,MAAA,CAAI,UAAU,cACb,SAAA,CAAAZ,GAAa,MACba,EAAAA,IAAC,MAAA,CAAI,UAAU,iBAAkB,SAAAb,EAAU,EAE5CY,EAAAA,KAAC,MAAA,CAAI,UAAU,YACb,SAAA,CAAAD,EAAM,SAAW,GACjBE,MAAC,OAAA,CAAK,UAAU,YAAY,SAAA,sBAAmB,EAE/CF,EAAM,IAAKN,GAAM,CACjB,GAAIA,EAAE,aAAe,KAAM,CAC1B,MAAMS,EAAQR,EAAY,IAAMD,EAAE,aAAe,GAAKC,EACtD,OACCM,EAAAA,KAAC,MAAA,CAEA,UAAW,oBAAoBE,EAAQ,iBAAmB,EAAE,GAE3D,SAAA,CAAAT,EAAE,MAAQ,GAAG,IAAEA,EAAE,MAClBO,EAAAA,KAAC,OAAA,CAAK,UAAU,aAAa,SAAA,CAAA,YAClBlB,EAAcW,EAAE,WAAW,CAAA,CAAA,CACtC,CAAA,CAAA,EANKA,EAAE,EAAA,CASV,CACA,OACCO,EAAAA,KAAC,MAAA,CAAe,UAAU,kBAAkB,SAAA,CAAA,OACtCP,EAAE,KAAA,CAAA,EADEA,EAAE,EAEZ,CAEF,CAAC,CAAA,CAAA,CACF,CAAA,EACD,CAEF"}
@@ -1,2 +1,2 @@
1
- import{r as t,n as m,j as s}from"./index-DrrDswj-.js";import{A as v}from"./AchievementTiles-9KQgKBEh.js";function x(){const[r,o]=t.useState([]),[l,u]=t.useState(!0),[a,n]=t.useState(null),c=t.useCallback(async()=>{try{const e=await m();o(e),n(null)}catch(e){n(e instanceof Error?e:new Error(String(e)))}finally{u(!1)}},[]);return t.useEffect(()=>{let e=!0;const i=async()=>{e&&await c()};i();const h=setInterval(()=>void i(),15e3);return()=>{e=!1,clearInterval(h)}},[c]),l&&r.length===0?s.jsx("div",{className:"tab-stub",children:"Loading achievements…"}):a&&r.length===0?s.jsxs("div",{className:"tab-stub",children:["Error: ",a.message]}):s.jsxs("div",{className:"achievements-tab",children:[s.jsx("h2",{children:"Achievements"}),s.jsx(v,{achievements:r})]})}export{x as default};
2
- //# sourceMappingURL=AchievementsTab-B6xoQTEa.js.map
1
+ import{r as t,n as m,j as s}from"./index-C9dTXvDQ.js";import{A as v}from"./AchievementTiles-Cwg_Ab8R.js";function x(){const[r,o]=t.useState([]),[l,u]=t.useState(!0),[a,n]=t.useState(null),c=t.useCallback(async()=>{try{const e=await m();o(e),n(null)}catch(e){n(e instanceof Error?e:new Error(String(e)))}finally{u(!1)}},[]);return t.useEffect(()=>{let e=!0;const i=async()=>{e&&await c()};i();const h=setInterval(()=>void i(),15e3);return()=>{e=!1,clearInterval(h)}},[c]),l&&r.length===0?s.jsx("div",{className:"tab-stub",children:"Loading achievements…"}):a&&r.length===0?s.jsxs("div",{className:"tab-stub",children:["Error: ",a.message]}):s.jsxs("div",{className:"achievements-tab",children:[s.jsx("h2",{children:"Achievements"}),s.jsx(v,{achievements:r})]})}export{x as default};
2
+ //# sourceMappingURL=AchievementsTab-Dk32-4Ha.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"AchievementsTab-B6xoQTEa.js","sources":["../../src/tabs/AchievementsTab.tsx"],"sourcesContent":["/**\n * dashboard-client/src/tabs/AchievementsTab.tsx — Achievements tab (NEW).\n *\n * Renders the shared AchievementTiles component standalone, plus a toast area.\n * Polls /api/achievements every 15s.\n */\n\nimport type React from \"react\";\nimport { useState, useEffect, useCallback } from \"react\";\nimport type { AchievementRow } from \"@contracts\";\nimport { fetchAchievements } from \"../api/client\";\nimport { AchievementTiles } from \"../components/AchievementTiles\";\n\nexport default function AchievementsTab(): React.ReactElement {\n\tconst [achievements, setAchievements] = useState<AchievementRow[]>([]);\n\tconst [loading, setLoading] = useState(true);\n\tconst [error, setError] = useState<Error | null>(null);\n\n\tconst doFetch = useCallback(async (): Promise<void> => {\n\t\ttry {\n\t\t\tconst rows = await fetchAchievements();\n\t\t\tsetAchievements(rows);\n\t\t\tsetError(null);\n\t\t} catch (e) {\n\t\t\tsetError(e instanceof Error ? e : new Error(String(e)));\n\t\t} finally {\n\t\t\tsetLoading(false);\n\t\t}\n\t}, []);\n\n\tuseEffect(() => {\n\t\tlet active = true;\n\t\tconst run = async (): Promise<void> => {\n\t\t\tif (active) await doFetch();\n\t\t};\n\t\tvoid run();\n\t\tconst timer = setInterval(() => void run(), 15000);\n\t\treturn () => {\n\t\t\tactive = false;\n\t\t\tclearInterval(timer);\n\t\t};\n\t}, [doFetch]);\n\n\tif (loading && achievements.length === 0)\n\t\treturn <div className=\"tab-stub\">Loading achievements…</div>;\n\tif (error && achievements.length === 0)\n\t\treturn <div className=\"tab-stub\">Error: {error.message}</div>;\n\n\treturn (\n\t\t<div className=\"achievements-tab\">\n\t\t\t<h2>Achievements</h2>\n\t\t\t<AchievementTiles achievements={achievements} />\n\t\t</div>\n\t);\n}\n"],"names":["AchievementsTab","achievements","setAchievements","useState","loading","setLoading","error","setError","doFetch","useCallback","rows","fetchAchievements","useEffect","active","run","timer","jsx","jsxs","AchievementTiles"],"mappings":"yGAaA,SAAwBA,GAAsC,CAC7D,KAAM,CAACC,EAAcC,CAAe,EAAIC,EAAAA,SAA2B,CAAA,CAAE,EAC/D,CAACC,EAASC,CAAU,EAAIF,EAAAA,SAAS,EAAI,EACrC,CAACG,EAAOC,CAAQ,EAAIJ,EAAAA,SAAuB,IAAI,EAE/CK,EAAUC,EAAAA,YAAY,SAA2B,CACtD,GAAI,CACH,MAAMC,EAAO,MAAMC,EAAA,EACnBT,EAAgBQ,CAAI,EACpBH,EAAS,IAAI,CACd,OAAS,EAAG,CACXA,EAAS,aAAa,MAAQ,EAAI,IAAI,MAAM,OAAO,CAAC,CAAC,CAAC,CACvD,QAAA,CACCF,EAAW,EAAK,CACjB,CACD,EAAG,CAAA,CAAE,EAeL,OAbAO,EAAAA,UAAU,IAAM,CACf,IAAIC,EAAS,GACb,MAAMC,EAAM,SAA2B,CAClCD,SAAcL,EAAA,CACnB,EACKM,EAAA,EACL,MAAMC,EAAQ,YAAY,IAAM,KAAKD,EAAA,EAAO,IAAK,EACjD,MAAO,IAAM,CACZD,EAAS,GACT,cAAcE,CAAK,CACpB,CACD,EAAG,CAACP,CAAO,CAAC,EAERJ,GAAWH,EAAa,SAAW,EAC/Be,EAAAA,IAAC,MAAA,CAAI,UAAU,WAAW,SAAA,wBAAqB,EACnDV,GAASL,EAAa,SAAW,EAC7BgB,EAAAA,KAAC,MAAA,CAAI,UAAU,WAAW,SAAA,CAAA,UAAQX,EAAM,OAAA,EAAQ,EAGvDW,EAAAA,KAAC,MAAA,CAAI,UAAU,mBACd,SAAA,CAAAD,EAAAA,IAAC,MAAG,SAAA,cAAA,CAAY,EAChBA,MAACE,GAAiB,aAAAjB,CAAA,CAA4B,CAAA,EAC/C,CAEF"}
1
+ {"version":3,"file":"AchievementsTab-Dk32-4Ha.js","sources":["../../src/tabs/AchievementsTab.tsx"],"sourcesContent":["/**\n * dashboard-client/src/tabs/AchievementsTab.tsx — Achievements tab (NEW).\n *\n * Renders the shared AchievementTiles component standalone, plus a toast area.\n * Polls /api/achievements every 15s.\n */\n\nimport type React from \"react\";\nimport { useState, useEffect, useCallback } from \"react\";\nimport type { AchievementRow } from \"@contracts\";\nimport { fetchAchievements } from \"../api/client\";\nimport { AchievementTiles } from \"../components/AchievementTiles\";\n\nexport default function AchievementsTab(): React.ReactElement {\n\tconst [achievements, setAchievements] = useState<AchievementRow[]>([]);\n\tconst [loading, setLoading] = useState(true);\n\tconst [error, setError] = useState<Error | null>(null);\n\n\tconst doFetch = useCallback(async (): Promise<void> => {\n\t\ttry {\n\t\t\tconst rows = await fetchAchievements();\n\t\t\tsetAchievements(rows);\n\t\t\tsetError(null);\n\t\t} catch (e) {\n\t\t\tsetError(e instanceof Error ? e : new Error(String(e)));\n\t\t} finally {\n\t\t\tsetLoading(false);\n\t\t}\n\t}, []);\n\n\tuseEffect(() => {\n\t\tlet active = true;\n\t\tconst run = async (): Promise<void> => {\n\t\t\tif (active) await doFetch();\n\t\t};\n\t\tvoid run();\n\t\tconst timer = setInterval(() => void run(), 15000);\n\t\treturn () => {\n\t\t\tactive = false;\n\t\t\tclearInterval(timer);\n\t\t};\n\t}, [doFetch]);\n\n\tif (loading && achievements.length === 0)\n\t\treturn <div className=\"tab-stub\">Loading achievements…</div>;\n\tif (error && achievements.length === 0)\n\t\treturn <div className=\"tab-stub\">Error: {error.message}</div>;\n\n\treturn (\n\t\t<div className=\"achievements-tab\">\n\t\t\t<h2>Achievements</h2>\n\t\t\t<AchievementTiles achievements={achievements} />\n\t\t</div>\n\t);\n}\n"],"names":["AchievementsTab","achievements","setAchievements","useState","loading","setLoading","error","setError","doFetch","useCallback","rows","fetchAchievements","useEffect","active","run","timer","jsx","jsxs","AchievementTiles"],"mappings":"yGAaA,SAAwBA,GAAsC,CAC7D,KAAM,CAACC,EAAcC,CAAe,EAAIC,EAAAA,SAA2B,CAAA,CAAE,EAC/D,CAACC,EAASC,CAAU,EAAIF,EAAAA,SAAS,EAAI,EACrC,CAACG,EAAOC,CAAQ,EAAIJ,EAAAA,SAAuB,IAAI,EAE/CK,EAAUC,EAAAA,YAAY,SAA2B,CACtD,GAAI,CACH,MAAMC,EAAO,MAAMC,EAAA,EACnBT,EAAgBQ,CAAI,EACpBH,EAAS,IAAI,CACd,OAAS,EAAG,CACXA,EAAS,aAAa,MAAQ,EAAI,IAAI,MAAM,OAAO,CAAC,CAAC,CAAC,CACvD,QAAA,CACCF,EAAW,EAAK,CACjB,CACD,EAAG,CAAA,CAAE,EAeL,OAbAO,EAAAA,UAAU,IAAM,CACf,IAAIC,EAAS,GACb,MAAMC,EAAM,SAA2B,CAClCD,SAAcL,EAAA,CACnB,EACKM,EAAA,EACL,MAAMC,EAAQ,YAAY,IAAM,KAAKD,EAAA,EAAO,IAAK,EACjD,MAAO,IAAM,CACZD,EAAS,GACT,cAAcE,CAAK,CACpB,CACD,EAAG,CAACP,CAAO,CAAC,EAERJ,GAAWH,EAAa,SAAW,EAC/Be,EAAAA,IAAC,MAAA,CAAI,UAAU,WAAW,SAAA,wBAAqB,EACnDV,GAASL,EAAa,SAAW,EAC7BgB,EAAAA,KAAC,MAAA,CAAI,UAAU,WAAW,SAAA,CAAA,UAAQX,EAAM,OAAA,EAAQ,EAGvDW,EAAAA,KAAC,MAAA,CAAI,UAAU,mBACd,SAAA,CAAAD,EAAAA,IAAC,MAAG,SAAA,cAAA,CAAY,EAChBA,MAACE,GAAiB,aAAAjB,CAAA,CAA4B,CAAA,EAC/C,CAEF"}