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
@@ -63,7 +63,7 @@ function renderByModel(repos) {
63
63
  var rows = document.getElementById('bm-rows');
64
64
  if (!rows) return;
65
65
  if (!repos || !repos.length) {
66
- rows.innerHTML = '<tr><td colspan="14" class="repo-none">No repositories registered yet.</td></tr>';
66
+ rows.innerHTML = '<tr><td colspan="16" class="repo-none">No repositories registered yet.</td></tr>';
67
67
  return;
68
68
  }
69
69
  var groups = {};
@@ -106,11 +106,35 @@ function renderByModel(repos) {
106
106
  var fmt = function(v) { return '$' + v.toFixed(6); };
107
107
  return lo === hi ? fmt(lo) : fmt(lo) + '–' + fmt(hi);
108
108
  }
109
+ // Helper: compute cache hit % from providerCacheRead/Write tokens
110
+ function cacheHitPct(cr, cw, inp) {
111
+ var denom = cr + cw + (inp || 0);
112
+ return denom > 0 ? ((cr / denom) * 100).toFixed(1) + '%' : '—';
113
+ }
114
+ // Helper: compute cache $ saved (read = 0.9 * inputRate, write = 0.25 * inputRate)
115
+ function cacheDollar(cr, cw, rate) {
116
+ if (!cr || !rate) return '—';
117
+ var saved = cr * rate * 0.9 - cw * rate * 0.25;
118
+ return '$' + saved.toFixed(4);
119
+ }
120
+ // Aggregate providerCacheRead/Write across repos in this group
121
+ var groupCacheRead = 0, groupCacheWrite = 0, groupTokensIn = 0;
122
+ for (var ii = 0; ii < repos.length; ii++) {
123
+ var rr = repos[ii];
124
+ var key2 = (rr.modelName && String(rr.modelName).trim()) || '(unknown)';
125
+ if (key2 === g.model) {
126
+ groupCacheRead += (rr.providerCacheRead || 0);
127
+ groupCacheWrite += (rr.providerCacheWrite || 0);
128
+ groupTokensIn += (rr.tokensDropped || 0);
129
+ }
130
+ }
109
131
  rows.innerHTML = arr.map(function(g) {
110
132
  var freed = (g.tokensIn || 0) - (g.tokensOut || 0);
111
- var usd = g.usd > 0 ? '$' + g.usd.toFixed(4) : '—';
133
+ // B: always show a dollar value (including $0.0000) do not use '' when usd is 0
134
+ var usd = '$' + (g.usd || 0).toFixed(4);
112
135
  var when = g.lastAt ? new Date(g.lastAt).toLocaleString() : '—';
113
136
  var reas = g.reasoning == null ? '—' : (g.reasoning ? 'yes' : 'no');
137
+ var rate0 = g.inRates && g.inRates.length ? g.inRates[0] : 0;
114
138
  return '<tr>' +
115
139
  '<td><span class="repo-model">' + sanitize(g.model) + '</span></td>' +
116
140
  '<td>' + sanitize(g.provider) + '</td>' +
@@ -125,6 +149,8 @@ function renderByModel(repos) {
125
149
  '<td class="num">' + collapseRate(g.inRates) + '</td>' +
126
150
  '<td class="num">' + collapseRate(g.outRates) + '</td>' +
127
151
  '<td class="num">' + sanitize(usd) + '</td>' +
152
+ '<td class="num">' + cacheHitPct(groupCacheRead, groupCacheWrite, groupTokensIn) + '</td>' +
153
+ '<td class="num">' + cacheDollar(groupCacheRead, groupCacheWrite, rate0) + '</td>' +
128
154
  '<td class="num">' + sanitize(when) + '</td>' +
129
155
  '</tr>';
130
156
  }).join('');
@@ -12,6 +12,7 @@ import { existsSync } from "node:fs";
12
12
  import { homedir } from "node:os";
13
13
  import { join } from "node:path";
14
14
  import { DatabaseSync } from "node:sqlite";
15
+ import { readProviderCacheForRepo, } from "../../src/store/sqlite/perf-samples.js";
15
16
  export function getIndexDir() {
16
17
  const override = process.env.MEGACOMPACT_INDEX_DIR;
17
18
  if (override && override.trim() !== "")
@@ -89,21 +90,21 @@ export function readIndex() {
89
90
  repo.maxTokens = Number(mrow.max_tokens ?? 0) || null;
90
91
  repo.reasoning = Number(mrow.reasoning ?? 0) === 1;
91
92
  }
92
- // Provider prompt-cache stats (E.3)
93
+ // Provider prompt-cache stats (E.3 / F4)
94
+ // Use readProviderCacheForRepo which correctly reads cache token
95
+ // counts from the meta JSON column (cacheRead/cacheWrite), not
96
+ // from non-existent table columns cache_read/cache_write.
93
97
  try {
94
- const pRow = sdb
95
- .prepare(`SELECT
96
- SUM(cache_read) AS totalRead,
97
- SUM(cache_write) AS totalWrite,
98
- SUM(cache_read + cache_write) * 1.0 / MAX(SUM(cache_read + cache_write + tokens_in), 1) AS hitPct
99
- FROM perf_samples
100
- WHERE cache_read > 0 OR cache_write > 0`)
101
- .get();
102
- if (pRow && pRow.totalRead > 0) {
103
- repo.providerCacheRead = Number(pRow.totalRead);
104
- repo.providerCacheWrite = Number(pRow.totalWrite);
105
- repo.providerCachePct = Number((pRow.hitPct * 100).toFixed(1));
98
+ const pc = readProviderCacheForRepo(repo.stateDir);
99
+ if (pc.totalCacheRead > 0) {
100
+ repo.providerCacheRead = pc.totalCacheRead;
101
+ repo.providerCacheWrite = pc.totalCacheWrite;
102
+ repo.providerCachePct = Number(pc.avgHitPct.toFixed(1));
106
103
  }
104
+ // Also enrich contextWindow/maxTokens/reasoning from the
105
+ // latest model_snapshots row (was already read above, but
106
+ // when latestModelSnapshot was used above we get the same
107
+ // data from the per-repo store — keep as-is).
107
108
  }
108
109
  catch {
109
110
  /* best-effort — perf_samples may not exist */
@@ -0,0 +1,81 @@
1
+ /**
2
+ * routes-health.ts — Context Health + KV Cache Poison route handlers.
3
+ *
4
+ * GET /api/context-health — recent health rows, latest composite, trend,
5
+ * per-model averages, alert flags. Accepts ?minutes=N (default 30).
6
+ * GET /api/cache-poison — recent cache poison events (last 30 min).
7
+ *
8
+ * Guardrails: PREVENT-PI-004 (loopback-only), PREVENT-001 (null-safe),
9
+ * PREVENT-002 (parameterized SQL via accessors). Localhost dashboard.
10
+ */
11
+ import { createRequire } from "node:module";
12
+ export function handleContextHealth(req, res, ctx) {
13
+ if (!req.url?.startsWith("/api/context-health"))
14
+ return false;
15
+ try {
16
+ if (req.method !== "GET") {
17
+ res.writeHead(405, { "Content-Type": "application/json" });
18
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
19
+ return true;
20
+ }
21
+ const url = new URL(req.url, "http://localhost");
22
+ const minutes = Math.max(1, Number(url.searchParams.get("minutes") ?? 30));
23
+ const sinceTs = Date.now() - minutes * 60_000;
24
+ const pfReq = createRequire(import.meta.url);
25
+ const { readLatestContextHealth, readContextHealthTrend, readContextHealthRows, } = pfReq("../../src/store/sqlite.js");
26
+ const latest = readLatestContextHealth(ctx.stateDir);
27
+ const trend = readContextHealthTrend(ctx.stateDir, 50);
28
+ const rows = readContextHealthRows(ctx.stateDir, sinceTs);
29
+ const perModel = new Map();
30
+ for (const r of rows) {
31
+ const mid = r.modelId ?? "(unknown)";
32
+ const entry = perModel.get(mid) ?? { sum: 0, count: 0 };
33
+ entry.sum += r.composite;
34
+ entry.count += 1;
35
+ perModel.set(mid, entry);
36
+ }
37
+ const perModelAverages = [...perModel.entries()].map(([modelId, v]) => ({
38
+ modelId,
39
+ avgComposite: v.count > 0 ? v.sum / v.count : 0,
40
+ sampleCount: v.count,
41
+ }));
42
+ const alerts = rows.filter((r) => r.composite < 0.5 || r.cachePoison < 0.3);
43
+ const body = {
44
+ updatedAt: new Date().toISOString(),
45
+ latest,
46
+ trend,
47
+ rows: rows.slice(-200),
48
+ perModel: perModelAverages,
49
+ alerts,
50
+ };
51
+ res.writeHead(200, { "Content-Type": "application/json" });
52
+ res.end(JSON.stringify(body));
53
+ }
54
+ catch (e) {
55
+ res.writeHead(500, { "Content-Type": "application/json" });
56
+ res.end(JSON.stringify({ error: String(e) }));
57
+ }
58
+ return true;
59
+ }
60
+ export function handleCachePoison(req, res, ctx) {
61
+ if (!req.url?.startsWith("/api/cache-poison"))
62
+ return false;
63
+ try {
64
+ if (req.method !== "GET") {
65
+ res.writeHead(405, { "Content-Type": "application/json" });
66
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
67
+ return true;
68
+ }
69
+ const sinceTs = Date.now() - 30 * 60_000;
70
+ const pfReq = createRequire(import.meta.url);
71
+ const { readCachePoisonEvents } = pfReq("../../src/store/sqlite.js");
72
+ const events = readCachePoisonEvents(ctx.stateDir, sinceTs);
73
+ res.writeHead(200, { "Content-Type": "application/json" });
74
+ res.end(JSON.stringify({ updatedAt: new Date().toISOString(), events }));
75
+ }
76
+ catch (e) {
77
+ res.writeHead(500, { "Content-Type": "application/json" });
78
+ res.end(JSON.stringify({ error: String(e) }));
79
+ }
80
+ return true;
81
+ }
@@ -7,6 +7,8 @@
7
7
  */
8
8
  import { openTurnStore } from "../../src/store/turns/connection.js";
9
9
  import { createTopicStore } from "../../src/topics/store.js";
10
+ import { openStore } from "../../src/store/sqlite.js";
11
+ import { buildTopicModel } from "../../src/topics/cluster.js";
10
12
  export function handleTopics(req, res, ctx) {
11
13
  const url = req.url ?? "";
12
14
  // ── GET /api/topics/:topicId/memories — wiki topic drill-down (S52) ──
@@ -42,6 +44,22 @@ export function handleTopics(req, res, ctx) {
42
44
  return false;
43
45
  try {
44
46
  const tdb = openTurnStore(ctx.stateDir);
47
+ // Lazy build: when the topics table is empty, attempt a one-shot build
48
+ // from available context_chunks embeddings so the wiki is useful even
49
+ // before the first compaction fires.
50
+ const topicCount = tdb.prepare("SELECT COUNT(*) AS c FROM topics").get().c;
51
+ if (topicCount === 0) {
52
+ try {
53
+ const mainDb = openStore(ctx.stateDir);
54
+ const model = buildTopicModel(mainDb);
55
+ if (model.k > 0 && model.totalChunks > 0) {
56
+ createTopicStore(ctx.stateDir).replaceTopicModel(model);
57
+ }
58
+ }
59
+ catch {
60
+ /* non-fatal: lazy wiki build is best-effort */
61
+ }
62
+ }
45
63
  const topics = tdb
46
64
  .prepare(`SELECT id, label, term_scores, memory_count, last_updated
47
65
  FROM topics ORDER BY memory_count DESC, id ASC`)
@@ -16,3 +16,4 @@ export { handleMemoryStatus } from "./routes-memory.js";
16
16
  export { handleSetupStatus, handleSetupDetect, handleSetupConfigure } from "./routes-setup.js";
17
17
  export { handleMemoryMap } from "./routes-memory-map.js";
18
18
  export { handleCacheStripes } from "./routes-cache.js";
19
+ export { handleContextHealth, handleCachePoison } from "./routes-health.js";
@@ -12,7 +12,7 @@ import { join, dirname } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { createRequire } from "node:module";
14
14
  import { log, setLogPath, setDashboardServerVersion } from "./state.js";
15
- import { buildRouteContext, handleIndex, handleRepoIndex, handleEvents, handleGameState, handleGameScores, handlePerf, handleAchievements, handleSessions, handleTopics, handleTurns, handleMaintenance, handleProviderCache, handleCacheStripes, handleMemoryStatus, handleSetupStatus, handleSetupDetect, handleSetupConfigure, handleMemoryMap, handleStatic, } from "./routes.js";
15
+ import { buildRouteContext, handleIndex, handleRepoIndex, handleEvents, handleGameState, handleGameScores, handlePerf, handleAchievements, handleSessions, handleTopics, handleTurns, handleMaintenance, handleProviderCache, handleCacheStripes, handleMemoryStatus, handleSetupStatus, handleSetupDetect, handleSetupConfigure, handleMemoryMap, handleContextHealth, handleCachePoison, handleStatic, } from "./routes.js";
16
16
  export async function launchDashboardServer(stateDir) {
17
17
  // Our own package version — exposed at /api/version so the launcher can
18
18
  // detect a stale server (started by an older build) and replace it on
@@ -207,6 +207,10 @@ export async function launchDashboardServer(stateDir) {
207
207
  return;
208
208
  if (handleMemoryMap(req, res, ctx))
209
209
  return;
210
+ if (handleContextHealth(req, res, ctx))
211
+ return;
212
+ if (handleCachePoison(req, res, ctx))
213
+ return;
210
214
  handleStatic(req, res, ctx);
211
215
  });
212
216
  // Bind base + range are env-configurable so tests can use a private,
@@ -180,6 +180,11 @@ export function loadConfig() {
180
180
  memoryGraphSeedMemories: envBool("MEGACOMPACT_MEMORY_GRAPH_SEED_MEMORIES", true),
181
181
  memoryGraphCrossTypeThreshold: envFlag("MEGACOMPACT_MEMORY_GRAPH_CROSS_TYPE_THRESHOLD", 0.85),
182
182
  memoryGraphWithinTypeThreshold: envFlag("MEGACOMPACT_MEMORY_GRAPH_WITHIN_TYPE_THRESHOLD", 0.7),
183
+ contextHealth: envBool("MEGACOMPACT_CONTEXT_HEALTH", true),
184
+ contextHealthDrift: envBool("MEGACOMPACT_CONTEXT_HEALTH_DRIFT", true),
185
+ contextHealthOutputQuality: envBool("MEGACOMPACT_CONTEXT_HEALTH_OUTPUT_QUALITY", true),
186
+ contextHealthCachePoison: envBool("MEGACOMPACT_CONTEXT_HEALTH_CACHE_POISON", true),
187
+ contextHealthMitigate: envBool("MEGACOMPACT_CONTEXT_HEALTH_MITIGATE", false),
183
188
  // D.1: env-overridable recompact delta (minimum context growth % before
184
189
  // re-compacting instead of replaying the cached live trim). Default 50.
185
190
  recompactPctDelta: envFlag("MEGACOMPACT_RECOMPACT_PCT_DELTA", 50),
@@ -12,6 +12,7 @@ import { vectorStats } from "../../src/vectorStore.js";
12
12
  import { defaultEmbedder } from "../../src/embedder.js";
13
13
  import { storeTopicEmbedding, loadTopicEmbedding, detectTopicShift, } from "./separated-prompt.js";
14
14
  import { refreshStripeAssignments as writeStripeAssignments, } from "../../src/cache-stripe-impl.js";
15
+ import { handleTurnEndHealth } from "./health-handler.js";
15
16
  /** Register agent/turn tracking event handlers. */
16
17
  export function registerAgentHandlers(pi, runtime, config) {
17
18
  // ---- Agent tracking for real-time widget + status-line updates ---------
@@ -252,6 +253,7 @@ export function registerAgentHandlers(pi, runtime, config) {
252
253
  pi.on("turn_end", async (event, ctx) => {
253
254
  runtime.dashboard.event("turn_end", { turnIndex: event.turnIndex });
254
255
  runtime.snapshot(ctx);
256
+ runtime.lastErrorCategory = null;
255
257
  // S53: consume staged recall blocks ONLY if they were actually injected
256
258
  // into a view this turn. If no context event fired (edge: turn ended
257
259
  // before any LLM call), recallInjectedThisTurn stays false and the blocks
@@ -372,6 +374,7 @@ export function registerAgentHandlers(pi, runtime, config) {
372
374
  else {
373
375
  const category = classifyError(event.message);
374
376
  const detail = classifyErrorDetailed(event.message);
377
+ runtime.lastErrorCategory = category;
375
378
  // R3: stateful poisoned-context signal — repeated identical error text
376
379
  // across consecutive turns. The classifier is stateless, so this
377
380
  // upgrade happens here. A 'transient' (or 'poisoned-context') turn
@@ -781,5 +784,7 @@ export function registerAgentHandlers(pi, runtime, config) {
781
784
  /* non-fatal: stripe refresh never breaks the agent loop */
782
785
  }
783
786
  }
787
+ // v0.12: Context Health — compute + persist health score. Non-fatal.
788
+ handleTurnEndHealth(event, runtime, config);
784
789
  });
785
790
  }
@@ -278,6 +278,11 @@ export function registerContextHandler(pi, runtime, config) {
278
278
  // S51B: auto-categorizing wiki rebuild — every Nth compaction, derived
279
279
  // from real context_chunks embeddings. Isolated-store only, gated on
280
280
  // AUTO_WIKI_ENABLED; best-effort + non-fatal (never breaks compaction).
281
+ // Fire regardless of dbMirror — context_chunks is the isolated store.
282
+ // Uses the already-open db when inside the dbMirror block; opens its own
283
+ // connection otherwise.
284
+ // from real context_chunks embeddings. Isolated-store only, gated on
285
+ // AUTO_WIKI_ENABLED; best-effort + non-fatal (never breaks compaction).
281
286
  try {
282
287
  if (config.autoWikiEnabled && config.turnsDbEnabled) {
283
288
  const every = Math.max(1, TurnsConfig.WIKI_REBUILD_EVERY_N_COMPACTS);
@@ -323,11 +328,11 @@ export function registerContextHandler(pi, runtime, config) {
323
328
  if (chunkCount < floor) {
324
329
  const transcriptRows = db
325
330
  .prepare(`SELECT DISTINCT content_bytes
326
- FROM raw_transcript
327
- WHERE session_id = ?
328
- AND length(content_bytes) > 10
329
- ORDER BY seq ASC
330
- LIMIT 200`)
331
+ FROM raw_transcript
332
+ WHERE session_id = ?
333
+ AND length(content_bytes) > 10
334
+ ORDER BY seq ASC
335
+ LIMIT 200`)
331
336
  .all(runtime.rt.sessionId);
332
337
  if (transcriptRows.length > 0) {
333
338
  const embedder = new TrigramEmbedder();
@@ -0,0 +1,156 @@
1
+ /**
2
+ * health-handler.ts — Context Health turn_end hook (v0.12).
3
+ *
4
+ * Computes a composite 0-1 health score per turn from five sub-scores
5
+ * (drift, output quality, error rate, cache health, cache poison),
6
+ * persists it to the context_health SQLite table, and emits a dashboard
7
+ * event. All paths non-fatal — health monitoring never breaks the agent loop.
8
+ *
9
+ * PREVENT-PI-004: zero network (local TrigramEmbedder + SQLite only).
10
+ */
11
+ import { defaultEmbedder } from "../../src/embedder.js";
12
+ import { computeOutputQuality } from "../../src/contextHealth/outputQuality.js";
13
+ import { computeTopicDrift, computeErrorEscalation, computePrefixInstability, computeDriftScore, } from "../../src/contextHealth/drift.js";
14
+ import { computePrefixHash, checkPrefixHash, evaluateCachePoison, } from "../../src/contextHealth/cachePoison.js";
15
+ import { computeHealthScore, recordContextHealth, } from "../../src/contextHealth.js";
16
+ import { recordCachePoisonEvent, } from "../../src/store/sqlite/context-health.js";
17
+ const RING_MAX = 5;
18
+ /** Extract assistant text from the turn_end event message. */
19
+ function extractAssistantText(event) {
20
+ const msg = event.message;
21
+ if (!msg || msg.role !== "assistant")
22
+ return "";
23
+ const c = msg.content;
24
+ if (typeof c === "string")
25
+ return c;
26
+ if (Array.isArray(c)) {
27
+ return c
28
+ .map((p) => (p && typeof p === "object" && "text" in p ? String(p.text ?? "") : ""))
29
+ .join(" ");
30
+ }
31
+ return "";
32
+ }
33
+ /** Extract messages as string array for prefix hash (cache poison Layer 1). */
34
+ function extractMessageTexts(event) {
35
+ const c = event?.message?.content;
36
+ if (typeof c === "string")
37
+ return [c];
38
+ if (Array.isArray(c)) {
39
+ return c.map((p) => (p && typeof p === "object" && "text" in p ? String(p.text ?? "") : ""));
40
+ }
41
+ return [];
42
+ }
43
+ /** Handle turn_end: compute + persist context health. Non-fatal. */
44
+ export function handleTurnEndHealth(event, runtime, config) {
45
+ if (!config.contextHealth)
46
+ return;
47
+ try {
48
+ const text = extractAssistantText(event);
49
+ const embedder = defaultEmbedder();
50
+ const emb = text.length > 0 ? embedder.embed(text) : [];
51
+ // Output quality sub-score
52
+ let outputQuality = 1.0;
53
+ let repetitionRatio = 0;
54
+ let coherenceScore = 1.0;
55
+ if (config.contextHealthOutputQuality && text.length > 0) {
56
+ const oq = computeOutputQuality(text, embedder);
57
+ outputQuality = oq.score;
58
+ repetitionRatio = oq.repetitionRatio;
59
+ coherenceScore = oq.coherenceScore;
60
+ }
61
+ // Drift sub-score
62
+ let drift = 1.0;
63
+ if (config.contextHealthDrift) {
64
+ const topic = computeTopicDrift(emb, runtime.recentTurnEmbeddings);
65
+ const error = computeErrorEscalation(runtime.recentErrorCategories);
66
+ const prefix = computePrefixInstability(0, 5);
67
+ drift = computeDriftScore(topic, error, prefix);
68
+ }
69
+ // Error rate sub-score (1 = no errors)
70
+ const errorScore = computeErrorEscalation(runtime.recentErrorCategories);
71
+ // Cache health (from perf-handler)
72
+ const cacheHealth = runtime.rt._lastCacheHealthScore ?? 1.0;
73
+ // Cache poison sub-score
74
+ let cachePoison = 1.0;
75
+ if (config.contextHealthCachePoison) {
76
+ const msgTexts = extractMessageTexts(event);
77
+ const currentHash = computePrefixHash(msgTexts);
78
+ const cacheRead = event.message?.usage?.cacheRead ?? 0;
79
+ const l1 = checkPrefixHash(currentHash, runtime.lastPrefixHash, cacheRead);
80
+ const result = evaluateCachePoison({
81
+ currentHash,
82
+ storedHash: runtime.lastPrefixHash,
83
+ cacheRead,
84
+ qualityByCacheHit: [],
85
+ qualityByCacheMiss: [],
86
+ errorRateCacheHit: 0,
87
+ errorRateCacheMiss: 0,
88
+ sampleCount: 0,
89
+ });
90
+ cachePoison = result.score;
91
+ if (l1.poisoned) {
92
+ try {
93
+ recordCachePoisonEvent(runtime.currentStateDir, {
94
+ ts: Date.now(),
95
+ turnIndex: event.turnIndex,
96
+ sessionId: runtime.rt.sessionId,
97
+ layer: 1,
98
+ detail: l1.detail,
99
+ severity: "warn",
100
+ });
101
+ }
102
+ catch { /* non-fatal */ }
103
+ }
104
+ runtime.lastPrefixHash = currentHash;
105
+ }
106
+ // Composite
107
+ const sub = {
108
+ drift,
109
+ outputQuality,
110
+ errorRate: errorScore,
111
+ cacheHealth,
112
+ cachePoison,
113
+ };
114
+ const composite = computeHealthScore(sub);
115
+ // Persist
116
+ recordContextHealth(runtime.currentStateDir, {
117
+ ts: Date.now(),
118
+ turnIndex: event.turnIndex,
119
+ sessionId: runtime.rt.sessionId,
120
+ driftScore: drift,
121
+ outputQuality,
122
+ errorScore,
123
+ cacheHealth,
124
+ cachePoison,
125
+ composite,
126
+ modelId: runtime.currentModel?.modelId,
127
+ repetitionRatio,
128
+ coherenceScore,
129
+ prefixHash: runtime.lastPrefixHash ?? undefined,
130
+ });
131
+ // Emit dashboard event
132
+ runtime.dashboard.event("context_health", {
133
+ composite,
134
+ drift,
135
+ outputQuality,
136
+ errorRate: errorScore,
137
+ cacheHealth,
138
+ cachePoison,
139
+ turnIndex: event.turnIndex,
140
+ });
141
+ // Update ring buffers
142
+ runtime.recentTurnEmbeddings.push(emb);
143
+ if (runtime.recentTurnEmbeddings.length > RING_MAX) {
144
+ runtime.recentTurnEmbeddings.shift();
145
+ }
146
+ runtime.recentErrorCategories.push(runtime.lastErrorCategory);
147
+ if (runtime.recentErrorCategories.length > RING_MAX) {
148
+ runtime.recentErrorCategories.shift();
149
+ }
150
+ }
151
+ catch (e) {
152
+ runtime.logger?.error("context_health_failed", {
153
+ error: String(e instanceof Error ? e.message : e),
154
+ });
155
+ }
156
+ }
@@ -11,7 +11,11 @@
11
11
  import { normalizeSessionId } from "../../src/store.js";
12
12
  // --------------------------------------------------------------- resetRuntime
13
13
  export function resetRuntimeImpl(self, sessionId) {
14
- const sid = normalizeSessionId(sessionId);
14
+ // Only call normalizeSessionId when a real sessionId string is passed.
15
+ // When undefined, keep the existing self.rt.sessionId (set by the real
16
+ // session-start / compact pipeline) so the early-return guard can fire and
17
+ // dashboard stats keyed by sessionId are not silently orphaned.
18
+ const sid = sessionId ? normalizeSessionId(sessionId) : self.rt.sessionId;
15
19
  if (self.rt.sessionId === sid && self.rt.persistedThisSession)
16
20
  return; // same session, keep checkpoint memory
17
21
  self.rt = {
@@ -215,6 +215,12 @@ export class MegaRuntime {
215
215
  diagCtxRunSkipped = 0; // runCompact() returned skipped
216
216
  diagCtxCutNull = 0; // computeLiveTrimCut returned null (anchor/boundary)
217
217
  diagCtxThrown = 0; // live-trim try threw (caught)
218
+ // Context health instrumentation (v0.12): rolling ring buffers for
219
+ // drift detection + cache poison Layer 1 hash baseline.
220
+ recentTurnEmbeddings = [];
221
+ recentErrorCategories = [];
222
+ lastPrefixHash = null;
223
+ lastErrorCategory = null;
218
224
  /**
219
225
  * S26 capture instrumentation: the "model_snapshots empty → $0.00 cost card"
220
226
  * bug was invisible because captureModel swallowed the DB write in a silent