pi-mega-compact 0.11.13 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/extensions/dashboard-server/routes-health.js +81 -0
  2. package/dist/extensions/dashboard-server/routes.js +1 -0
  3. package/dist/extensions/dashboard-server/server.js +5 -1
  4. package/dist/extensions/mega-config.js +5 -0
  5. package/dist/extensions/mega-events/agent-handlers.js +5 -0
  6. package/dist/extensions/mega-events/health-handler.js +156 -0
  7. package/dist/extensions/mega-runtime/runtime.js +6 -0
  8. package/dist/src/contextHealth/cachePoison.js +183 -0
  9. package/dist/src/contextHealth/drift.js +89 -0
  10. package/dist/src/contextHealth/outputQuality.js +112 -0
  11. package/dist/src/contextHealth.js +110 -0
  12. package/dist/src/store/sqlite/context-health.js +193 -0
  13. package/dist/src/store/sqlite/schema.js +43 -1
  14. package/dist/src/store/sqlite.js +1 -0
  15. package/extensions/dashboard-client/dist/assets/{AchievementTiles-9KQgKBEh.js → AchievementTiles-DeQ0qnBX.js} +2 -2
  16. package/extensions/dashboard-client/dist/assets/{AchievementTiles-9KQgKBEh.js.map → AchievementTiles-DeQ0qnBX.js.map} +1 -1
  17. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B6xoQTEa.js → AchievementsTab-Dtk_RJfs.js} +2 -2
  18. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B6xoQTEa.js.map → AchievementsTab-Dtk_RJfs.js.map} +1 -1
  19. package/extensions/dashboard-client/dist/assets/{CacheTab-Dkm9rawo.js → CacheTab-kWcQt3PI.js} +2 -2
  20. package/extensions/dashboard-client/dist/assets/{CacheTab-Dkm9rawo.js.map → CacheTab-kWcQt3PI.js.map} +1 -1
  21. package/extensions/dashboard-client/dist/assets/{ConfigTab-Bd_v_OUF.js → ConfigTab-UFlo73fR.js} +2 -2
  22. package/extensions/dashboard-client/dist/assets/{ConfigTab-Bd_v_OUF.js.map → ConfigTab-UFlo73fR.js.map} +1 -1
  23. package/extensions/dashboard-client/dist/assets/{EventsTab-CYWoFOdI.js → EventsTab-BENO2N0y.js} +2 -2
  24. package/extensions/dashboard-client/dist/assets/{EventsTab-CYWoFOdI.js.map → EventsTab-BENO2N0y.js.map} +1 -1
  25. package/extensions/dashboard-client/dist/assets/{GameTab-CH7lecNL.js → GameTab-BCJavOML.js} +2 -2
  26. package/extensions/dashboard-client/dist/assets/{GameTab-CH7lecNL.js.map → GameTab-BCJavOML.js.map} +1 -1
  27. package/extensions/dashboard-client/dist/assets/HealthTab-C7iw7rhx.js +2 -0
  28. package/extensions/dashboard-client/dist/assets/HealthTab-C7iw7rhx.js.map +1 -0
  29. package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DNZ1IF4t.js → MaintenanceTab-C0rdAMWc.js} +2 -2
  30. package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DNZ1IF4t.js.map → MaintenanceTab-C0rdAMWc.js.map} +1 -1
  31. package/extensions/dashboard-client/dist/assets/{MemoryMapTab-tVRREYiG.js → MemoryMapTab-w5BZP3i-.js} +2 -2
  32. package/extensions/dashboard-client/dist/assets/{MemoryMapTab-tVRREYiG.js.map → MemoryMapTab-w5BZP3i-.js.map} +1 -1
  33. package/extensions/dashboard-client/dist/assets/{MetricsTab-CHblYuTY.js → MetricsTab-BBCXa3xS.js} +2 -2
  34. package/extensions/dashboard-client/dist/assets/{MetricsTab-CHblYuTY.js.map → MetricsTab-BBCXa3xS.js.map} +1 -1
  35. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dj0TgFvU.js → OverviewTab-CzABMSAQ.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dj0TgFvU.js.map → OverviewTab-CzABMSAQ.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/{ReposTab-BTJcfc1W.js → ReposTab-DFiM_Ubg.js} +2 -2
  38. package/extensions/dashboard-client/dist/assets/{ReposTab-BTJcfc1W.js.map → ReposTab-DFiM_Ubg.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/{SessionsTab-C8tU8K5u.js → SessionsTab-BtW4bees.js} +2 -2
  40. package/extensions/dashboard-client/dist/assets/{SessionsTab-C8tU8K5u.js.map → SessionsTab-BtW4bees.js.map} +1 -1
  41. package/extensions/dashboard-client/dist/assets/{SetupTab-BdxGMo6O.js → SetupTab-Duy1LJhg.js} +2 -2
  42. package/extensions/dashboard-client/dist/assets/{SetupTab-BdxGMo6O.js.map → SetupTab-Duy1LJhg.js.map} +1 -1
  43. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-BWZSARzT.js → TimeSavedCard-CC13EXij.js} +2 -2
  44. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-BWZSARzT.js.map → TimeSavedCard-CC13EXij.js.map} +1 -1
  45. package/extensions/dashboard-client/dist/assets/{TopicsTab-BCJwPYl8.js → TopicsTab-BrTB8uEK.js} +2 -2
  46. package/extensions/dashboard-client/dist/assets/{TopicsTab-BCJwPYl8.js.map → TopicsTab-BrTB8uEK.js.map} +1 -1
  47. package/extensions/dashboard-client/dist/assets/{TurnsTab-DQFTSDpz.js → TurnsTab-BJK7FIHu.js} +2 -2
  48. package/extensions/dashboard-client/dist/assets/{TurnsTab-DQFTSDpz.js.map → TurnsTab-BJK7FIHu.js.map} +1 -1
  49. package/extensions/dashboard-client/dist/assets/{index-DrrDswj-.js → index-B8k19B9g.js} +11 -11
  50. package/extensions/dashboard-client/dist/assets/{index-DrrDswj-.js.map → index-B8k19B9g.js.map} +1 -1
  51. package/extensions/dashboard-client/dist/assets/{useSSE-o4tteeTs.js → useSSE-CWBB4Me7.js} +2 -2
  52. package/extensions/dashboard-client/dist/assets/{useSSE-o4tteeTs.js.map → useSSE-CWBB4Me7.js.map} +1 -1
  53. package/extensions/dashboard-client/dist/index.html +1 -1
  54. package/extensions/dashboard-client/src/App.tsx +5 -1
  55. package/extensions/dashboard-client/src/api/health.ts +64 -0
  56. package/extensions/dashboard-client/src/tabs/HealthTab.tsx +165 -0
  57. package/extensions/dashboard-server/routes-health.ts +104 -0
  58. package/extensions/dashboard-server/routes.ts +1 -0
  59. package/extensions/dashboard-server/server.ts +4 -0
  60. package/extensions/mega-config.ts +15 -0
  61. package/extensions/mega-events/agent-handlers.ts +6 -0
  62. package/extensions/mega-events/health-handler.ts +190 -0
  63. package/extensions/mega-runtime/runtime.ts +7 -0
  64. package/package.json +1 -1
  65. package/src/contextHealth/cachePoison.ts +243 -0
  66. package/src/contextHealth/drift.ts +107 -0
  67. package/src/contextHealth/outputQuality.ts +139 -0
  68. package/src/contextHealth.ts +191 -0
  69. package/src/store/sqlite/context-health.ts +342 -0
  70. package/src/store/sqlite/schema.ts +43 -1
  71. package/src/store/sqlite.ts +1 -0
@@ -1,2 +1,2 @@
1
- import{r as e}from"./index-DrrDswj-.js";function R(i={}){const{maxEvents:c=500,maxReconnects:l=5}=i,[f,v]=e.useState([]),[E,n]=e.useState("connecting"),[S,p]=e.useState(0),[m,d]=e.useState(null),o=e.useRef(0),s=e.useRef(null),r=e.useCallback(()=>{s.current&&s.current.close(),n("connecting");const t=new EventSource("/api/events");s.current=t,t.onopen=()=>{n("connected"),o.current=0},t.onmessage=g=>{try{const x=JSON.parse(g.data);v(u=>{const a=[...u,x];return a.length>c?a.slice(-c):a}),p(u=>u+1),d(Date.now())}catch{}},t.onerror=()=>{t.close(),o.current<l?(o.current+=1,n("connecting"),setTimeout(r,1e3)):n("disconnected")}},[c,l]);return e.useEffect(()=>(r(),()=>{s.current?.close()}),[r]),{events:f,status:E,eventCount:S,lastEventAt:m}}export{R as u};
2
- //# sourceMappingURL=useSSE-o4tteeTs.js.map
1
+ import{r as e}from"./index-B8k19B9g.js";function R(i={}){const{maxEvents:c=500,maxReconnects:l=5}=i,[f,v]=e.useState([]),[E,n]=e.useState("connecting"),[S,p]=e.useState(0),[m,d]=e.useState(null),o=e.useRef(0),s=e.useRef(null),r=e.useCallback(()=>{s.current&&s.current.close(),n("connecting");const t=new EventSource("/api/events");s.current=t,t.onopen=()=>{n("connected"),o.current=0},t.onmessage=g=>{try{const x=JSON.parse(g.data);v(u=>{const a=[...u,x];return a.length>c?a.slice(-c):a}),p(u=>u+1),d(Date.now())}catch{}},t.onerror=()=>{t.close(),o.current<l?(o.current+=1,n("connecting"),setTimeout(r,1e3)):n("disconnected")}},[c,l]);return e.useEffect(()=>(r(),()=>{s.current?.close()}),[r]),{events:f,status:E,eventCount:S,lastEventAt:m}}export{R as u};
2
+ //# sourceMappingURL=useSSE-CWBB4Me7.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"useSSE-o4tteeTs.js","sources":["../../src/hooks/useSSE.ts"],"sourcesContent":["/**\n * dashboard-client/src/hooks/useSSE.ts — Server-Sent Events hook.\n *\n * Connects to /api/events, provides real-time event stream.\n * SPRINT-B1: basic connection. SPRINT-D1: reconnect with backoff.\n */\n\nimport { useState, useEffect, useRef, useCallback } from 'react';\nimport type { SseEvent } from '../../../dashboard-server/api-contracts';\n\nexport type SseStatus = 'connecting' | 'connected' | 'disconnected' | 'error';\n\nexport interface UseSSEResult {\n events: SseEvent[];\n status: SseStatus;\n eventCount: number;\n lastEventAt: number | null;\n}\n\nexport interface UseSSEOptions {\n /** Max events to keep in memory (ring buffer). */\n maxEvents?: number;\n /** Max reconnect attempts before giving up. */\n maxReconnects?: number;\n}\n\n// SPRINT-D1-REMAINING: exponential backoff (1s → 2s → 4s → max 30s).\n// SPRINT-D1-REMAINING: after 5 failures, set status='disconnected'.\n\nexport function useSSE(options: UseSSEOptions = {}): UseSSEResult {\n const { maxEvents = 500, maxReconnects = 5 } = options;\n const [events, setEvents] = useState<SseEvent[]>([]);\n const [status, setStatus] = useState<SseStatus>('connecting');\n const [eventCount, setEventCount] = useState(0);\n const [lastEventAt, setLastEventAt] = useState<number | null>(null);\n const reconnectCount = useRef(0);\n const sourceRef = useRef<EventSource | null>(null);\n\n const connect = useCallback(() => {\n if (sourceRef.current) {\n sourceRef.current.close();\n }\n setStatus('connecting');\n const source = new EventSource('/api/events');\n sourceRef.current = source;\n\n source.onopen = () => {\n setStatus('connected');\n reconnectCount.current = 0;\n };\n\n source.onmessage = (event) => {\n try {\n const parsed = JSON.parse(event.data) as SseEvent;\n setEvents(prev => {\n const next = [...prev, parsed];\n return next.length > maxEvents ? next.slice(-maxEvents) : next;\n });\n setEventCount(c => c + 1);\n setLastEventAt(Date.now());\n } catch {\n // non-fatal: skip malformed events\n }\n };\n\n source.onerror = () => {\n source.close();\n if (reconnectCount.current < maxReconnects) {\n reconnectCount.current += 1;\n setStatus('connecting');\n // SPRINT-D1: use exponential backoff instead of fixed delay\n setTimeout(connect, 1000);\n } else {\n setStatus('disconnected');\n }\n };\n }, [maxEvents, maxReconnects]);\n\n useEffect(() => {\n connect();\n return () => {\n sourceRef.current?.close();\n };\n }, [connect]);\n\n return { events, status, eventCount, lastEventAt };\n}\n"],"names":["useSSE","options","maxEvents","maxReconnects","events","setEvents","useState","status","setStatus","eventCount","setEventCount","lastEventAt","setLastEventAt","reconnectCount","useRef","sourceRef","connect","useCallback","source","event","parsed","prev","next","c","useEffect"],"mappings":"wCA6BO,SAASA,EAAOC,EAAyB,GAAkB,CAChE,KAAM,CAAE,UAAAC,EAAY,IAAK,cAAAC,EAAgB,GAAMF,EACzC,CAACG,EAAQC,CAAS,EAAIC,EAAAA,SAAqB,CAAA,CAAE,EAC7C,CAACC,EAAQC,CAAS,EAAIF,EAAAA,SAAoB,YAAY,EACtD,CAACG,EAAYC,CAAa,EAAIJ,EAAAA,SAAS,CAAC,EACxC,CAACK,EAAaC,CAAc,EAAIN,EAAAA,SAAwB,IAAI,EAC5DO,EAAiBC,EAAAA,OAAO,CAAC,EACzBC,EAAYD,EAAAA,OAA2B,IAAI,EAE3CE,EAAUC,EAAAA,YAAY,IAAM,CAC5BF,EAAU,SACZA,EAAU,QAAQ,MAAA,EAEpBP,EAAU,YAAY,EACtB,MAAMU,EAAS,IAAI,YAAY,aAAa,EAC5CH,EAAU,QAAUG,EAEpBA,EAAO,OAAS,IAAM,CACpBV,EAAU,WAAW,EACrBK,EAAe,QAAU,CAC3B,EAEAK,EAAO,UAAaC,GAAU,CAC5B,GAAI,CACF,MAAMC,EAAS,KAAK,MAAMD,EAAM,IAAI,EACpCd,EAAUgB,GAAQ,CAChB,MAAMC,EAAO,CAAC,GAAGD,EAAMD,CAAM,EAC7B,OAAOE,EAAK,OAASpB,EAAYoB,EAAK,MAAM,CAACpB,CAAS,EAAIoB,CAC5D,CAAC,EACDZ,EAAca,GAAKA,EAAI,CAAC,EACxBX,EAAe,KAAK,KAAK,CAC3B,MAAQ,CAER,CACF,EAEAM,EAAO,QAAU,IAAM,CACrBA,EAAO,MAAA,EACHL,EAAe,QAAUV,GAC3BU,EAAe,SAAW,EAC1BL,EAAU,YAAY,EAEtB,WAAWQ,EAAS,GAAI,GAExBR,EAAU,cAAc,CAE5B,CACF,EAAG,CAACN,EAAWC,CAAa,CAAC,EAE7BqB,OAAAA,EAAAA,UAAU,KACRR,EAAA,EACO,IAAM,CACXD,EAAU,SAAS,MAAA,CACrB,GACC,CAACC,CAAO,CAAC,EAEL,CAAE,OAAAZ,EAAQ,OAAAG,EAAQ,WAAAE,EAAY,YAAAE,CAAA,CACvC"}
1
+ {"version":3,"file":"useSSE-CWBB4Me7.js","sources":["../../src/hooks/useSSE.ts"],"sourcesContent":["/**\n * dashboard-client/src/hooks/useSSE.ts — Server-Sent Events hook.\n *\n * Connects to /api/events, provides real-time event stream.\n * SPRINT-B1: basic connection. SPRINT-D1: reconnect with backoff.\n */\n\nimport { useState, useEffect, useRef, useCallback } from 'react';\nimport type { SseEvent } from '../../../dashboard-server/api-contracts';\n\nexport type SseStatus = 'connecting' | 'connected' | 'disconnected' | 'error';\n\nexport interface UseSSEResult {\n events: SseEvent[];\n status: SseStatus;\n eventCount: number;\n lastEventAt: number | null;\n}\n\nexport interface UseSSEOptions {\n /** Max events to keep in memory (ring buffer). */\n maxEvents?: number;\n /** Max reconnect attempts before giving up. */\n maxReconnects?: number;\n}\n\n// SPRINT-D1-REMAINING: exponential backoff (1s → 2s → 4s → max 30s).\n// SPRINT-D1-REMAINING: after 5 failures, set status='disconnected'.\n\nexport function useSSE(options: UseSSEOptions = {}): UseSSEResult {\n const { maxEvents = 500, maxReconnects = 5 } = options;\n const [events, setEvents] = useState<SseEvent[]>([]);\n const [status, setStatus] = useState<SseStatus>('connecting');\n const [eventCount, setEventCount] = useState(0);\n const [lastEventAt, setLastEventAt] = useState<number | null>(null);\n const reconnectCount = useRef(0);\n const sourceRef = useRef<EventSource | null>(null);\n\n const connect = useCallback(() => {\n if (sourceRef.current) {\n sourceRef.current.close();\n }\n setStatus('connecting');\n const source = new EventSource('/api/events');\n sourceRef.current = source;\n\n source.onopen = () => {\n setStatus('connected');\n reconnectCount.current = 0;\n };\n\n source.onmessage = (event) => {\n try {\n const parsed = JSON.parse(event.data) as SseEvent;\n setEvents(prev => {\n const next = [...prev, parsed];\n return next.length > maxEvents ? next.slice(-maxEvents) : next;\n });\n setEventCount(c => c + 1);\n setLastEventAt(Date.now());\n } catch {\n // non-fatal: skip malformed events\n }\n };\n\n source.onerror = () => {\n source.close();\n if (reconnectCount.current < maxReconnects) {\n reconnectCount.current += 1;\n setStatus('connecting');\n // SPRINT-D1: use exponential backoff instead of fixed delay\n setTimeout(connect, 1000);\n } else {\n setStatus('disconnected');\n }\n };\n }, [maxEvents, maxReconnects]);\n\n useEffect(() => {\n connect();\n return () => {\n sourceRef.current?.close();\n };\n }, [connect]);\n\n return { events, status, eventCount, lastEventAt };\n}\n"],"names":["useSSE","options","maxEvents","maxReconnects","events","setEvents","useState","status","setStatus","eventCount","setEventCount","lastEventAt","setLastEventAt","reconnectCount","useRef","sourceRef","connect","useCallback","source","event","parsed","prev","next","c","useEffect"],"mappings":"wCA6BO,SAASA,EAAOC,EAAyB,GAAkB,CAChE,KAAM,CAAE,UAAAC,EAAY,IAAK,cAAAC,EAAgB,GAAMF,EACzC,CAACG,EAAQC,CAAS,EAAIC,EAAAA,SAAqB,CAAA,CAAE,EAC7C,CAACC,EAAQC,CAAS,EAAIF,EAAAA,SAAoB,YAAY,EACtD,CAACG,EAAYC,CAAa,EAAIJ,EAAAA,SAAS,CAAC,EACxC,CAACK,EAAaC,CAAc,EAAIN,EAAAA,SAAwB,IAAI,EAC5DO,EAAiBC,EAAAA,OAAO,CAAC,EACzBC,EAAYD,EAAAA,OAA2B,IAAI,EAE3CE,EAAUC,EAAAA,YAAY,IAAM,CAC5BF,EAAU,SACZA,EAAU,QAAQ,MAAA,EAEpBP,EAAU,YAAY,EACtB,MAAMU,EAAS,IAAI,YAAY,aAAa,EAC5CH,EAAU,QAAUG,EAEpBA,EAAO,OAAS,IAAM,CACpBV,EAAU,WAAW,EACrBK,EAAe,QAAU,CAC3B,EAEAK,EAAO,UAAaC,GAAU,CAC5B,GAAI,CACF,MAAMC,EAAS,KAAK,MAAMD,EAAM,IAAI,EACpCd,EAAUgB,GAAQ,CAChB,MAAMC,EAAO,CAAC,GAAGD,EAAMD,CAAM,EAC7B,OAAOE,EAAK,OAASpB,EAAYoB,EAAK,MAAM,CAACpB,CAAS,EAAIoB,CAC5D,CAAC,EACDZ,EAAca,GAAKA,EAAI,CAAC,EACxBX,EAAe,KAAK,KAAK,CAC3B,MAAQ,CAER,CACF,EAEAM,EAAO,QAAU,IAAM,CACrBA,EAAO,MAAA,EACHL,EAAe,QAAUV,GAC3BU,EAAe,SAAW,EAC1BL,EAAU,YAAY,EAEtB,WAAWQ,EAAS,GAAI,GAExBR,EAAU,cAAc,CAE5B,CACF,EAAG,CAACN,EAAWC,CAAa,CAAC,EAE7BqB,OAAAA,EAAAA,UAAU,KACRR,EAAA,EACO,IAAM,CACXD,EAAU,SAAS,MAAA,CACrB,GACC,CAACC,CAAO,CAAC,EAEL,CAAE,OAAAZ,EAAQ,OAAAG,EAAQ,WAAAE,EAAY,YAAAE,CAAA,CACvC"}
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>mega-compact dashboard</title>
7
- <script type="module" crossorigin src="./assets/index-DrrDswj-.js"></script>
7
+ <script type="module" crossorigin src="./assets/index-B8k19B9g.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="./assets/index-CUNn22iV.css">
9
9
  </head>
10
10
  <body>
@@ -28,6 +28,7 @@ const TopicsTab = React.lazy(() => import("./tabs/TopicsTab"));
28
28
  const TurnsTab = React.lazy(() => import("./tabs/TurnsTab"));
29
29
  const MaintenanceTab = React.lazy(() => import("./tabs/MaintenanceTab"));
30
30
  const MemoryMapTab = React.lazy(() => import("./tabs/MemoryMapTab"));
31
+ const HealthTab = React.lazy(() => import("./tabs/HealthTab"));
31
32
 
32
33
  export type TabId =
33
34
  | "overview"
@@ -43,7 +44,8 @@ export type TabId =
43
44
  | "topics"
44
45
  | "turns"
45
46
  | "maintenance"
46
- | "memory-map";
47
+ | "memory-map"
48
+ | "health";
47
49
 
48
50
  type TabDef = { id: TabId; label: string };
49
51
 
@@ -52,6 +54,7 @@ const PRIMARY_TABS: TabDef[] = [
52
54
  { id: "cache", label: "Cache" },
53
55
  { id: "sessions", label: "Sessions" },
54
56
  { id: "turns", label: "Turns" },
57
+ { id: "health", label: "Health" },
55
58
  ];
56
59
 
57
60
  const ADVANCED_TABS: TabDef[] = [
@@ -124,6 +127,7 @@ export default function App(): React.ReactElement {
124
127
  {activeTab === "topics" && <TopicsTab />}
125
128
  {activeTab === "maintenance" && <MaintenanceTab />}
126
129
  {activeTab === "memory-map" && <MemoryMapTab />}
130
+ {activeTab === "health" && <HealthTab />}
127
131
  </React.Suspense>
128
132
  </main>
129
133
  </div>
@@ -0,0 +1,64 @@
1
+ /**
2
+ * dashboard-client/src/api/health.ts — Context Health API client.
3
+ * PREVENT-PI-004: relative paths only (loopback dashboard).
4
+ */
5
+
6
+ export interface ContextHealthRow {
7
+ ts: number;
8
+ turnIndex: number;
9
+ sessionId: string;
10
+ driftScore: number;
11
+ outputQuality: number;
12
+ errorScore: number;
13
+ cacheHealth: number;
14
+ cachePoison: number;
15
+ composite: number;
16
+ modelId: string | null;
17
+ repetitionRatio: number | null;
18
+ coherenceScore: number | null;
19
+ prefixHash: string | null;
20
+ }
21
+
22
+ export interface ContextHealthResponse {
23
+ updatedAt: string;
24
+ latest: {
25
+ composite: number;
26
+ driftScore: number;
27
+ outputQuality: number;
28
+ errorScore: number;
29
+ cacheHealth: number;
30
+ cachePoison: number;
31
+ ts: number;
32
+ modelId: string | null;
33
+ } | null;
34
+ trend: number[];
35
+ rows: ContextHealthRow[];
36
+ perModel: Array<{ modelId: string; avgComposite: number; sampleCount: number }>;
37
+ alerts: ContextHealthRow[];
38
+ }
39
+
40
+ export interface CachePoisonEvent {
41
+ ts: number;
42
+ turnIndex: number;
43
+ sessionId: string;
44
+ layer: number;
45
+ detail: string;
46
+ severity: string;
47
+ }
48
+
49
+ export interface CachePoisonResponse {
50
+ updatedAt: string;
51
+ events: CachePoisonEvent[];
52
+ }
53
+
54
+ export async function fetchContextHealth(minutes?: number): Promise<ContextHealthResponse> {
55
+ const r = await fetch(`/api/context-health?minutes=${minutes ?? 30}`);
56
+ if (!r.ok) throw new Error(`context-health: ${r.status}`);
57
+ return r.json() as Promise<ContextHealthResponse>;
58
+ }
59
+
60
+ export async function fetchCachePoison(): Promise<CachePoisonResponse> {
61
+ const r = await fetch("/api/cache-poison");
62
+ if (!r.ok) throw new Error(`cache-poison: ${r.status}`);
63
+ return r.json() as Promise<CachePoisonResponse>;
64
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * HealthTab.tsx — Context Health dashboard tab.
3
+ * Shows composite health gauge, sparkline trend, sub-score bars,
4
+ * alerts, and per-model comparison. Polls /api/context-health every 5s.
5
+ */
6
+
7
+ import { useState, useEffect, useCallback } from "react";
8
+ import { fetchContextHealth, type ContextHealthResponse } from "../api/health";
9
+
10
+ function healthColor(score: number): string {
11
+ if (score > 0.8) return "#4ade80";
12
+ if (score >= 0.5) return "#facc15";
13
+ return "#f87171";
14
+ }
15
+
16
+ function HealthGauge({ score }: { score: number }): React.ReactElement {
17
+ const r = 70;
18
+ const circ = 2 * Math.PI * r;
19
+ const offset = circ * (1 - Math.max(0, Math.min(1, score)));
20
+ const color = healthColor(score);
21
+ return (
22
+ <div className="health-gauge-wrap">
23
+ <svg width="180" height="180" viewBox="0 0 180 180">
24
+ <circle cx="90" cy="90" r={r} fill="none" stroke="#1e293b" strokeWidth="14" />
25
+ <circle
26
+ cx="90"
27
+ cy="90"
28
+ r={r}
29
+ fill="none"
30
+ stroke={color}
31
+ strokeWidth="14"
32
+ strokeDasharray={circ}
33
+ strokeDashoffset={offset}
34
+ strokeLinecap="round"
35
+ transform="rotate(-90 90 90)"
36
+ style={{ transition: "stroke-dashoffset 0.5s ease" }}
37
+ />
38
+ <text x="90" y="85" textAnchor="middle" fontSize="32" fontWeight="bold" fill={color}>
39
+ {(score * 100).toFixed(0)}
40
+ </text>
41
+ <text x="90" y="108" textAnchor="middle" fontSize="14" fill="#94a3b8">/ 100</text>
42
+ </svg>
43
+ <div className="health-gauge-label">Context Health</div>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ function HealthSparkline({ trend }: { trend: number[] }): React.ReactElement {
49
+ if (trend.length < 2) return <div className="health-spark-empty">No trend data yet</div>;
50
+ const w = 400;
51
+ const h = 60;
52
+ const max = 1;
53
+ const min = 0;
54
+ const pts = trend.map((v, i) => {
55
+ const x = (i / (trend.length - 1)) * w;
56
+ const y = h - ((v - min) / (max - min)) * h;
57
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
58
+ });
59
+ const last = trend[trend.length - 1];
60
+ return (
61
+ <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} className="health-spark">
62
+ <polyline points={pts.join(" ")} fill="none" stroke={healthColor(last)} strokeWidth="2" />
63
+ </svg>
64
+ );
65
+ }
66
+
67
+ function SubScoreBar({ label, value }: { label: string; value: number }): React.ReactElement {
68
+ return (
69
+ <div className="health-subscore">
70
+ <span className="health-subscore-label">{label}</span>
71
+ <div className="health-subscore-bar">
72
+ <div
73
+ className="health-subscore-fill"
74
+ style={{ width: `${value * 100}%`, background: healthColor(value) }}
75
+ />
76
+ </div>
77
+ <span className="health-subscore-val">{(value * 100).toFixed(0)}%</span>
78
+ </div>
79
+ );
80
+ }
81
+
82
+ export default function HealthTab(): React.ReactElement {
83
+ const [data, setData] = useState<ContextHealthResponse | null>(null);
84
+ const [err, setError] = useState<string | null>(null);
85
+ const [loading, setLoading] = useState(true);
86
+
87
+ const poll = useCallback(() => {
88
+ fetchContextHealth()
89
+ .then(setData)
90
+ .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
91
+ .finally(() => setLoading(false));
92
+ }, []);
93
+
94
+ useEffect(() => {
95
+ poll();
96
+ const id = setInterval(poll, 5000);
97
+ return () => clearInterval(id);
98
+ }, [poll]);
99
+
100
+ if (loading && !data) return <div className="health-loading">Loading health data…</div>;
101
+ if (err && !data) return <div className="health-error">Error: {err}</div>;
102
+ if (!data) return <div className="health-empty">No health data available.</div>;
103
+
104
+ const latest = data.latest;
105
+ const composite = latest?.composite ?? 0;
106
+
107
+ return (
108
+ <div className="health-tab">
109
+ <div className="health-top-row">
110
+ <HealthGauge score={composite} />
111
+ <div className="health-subscores">
112
+ <h3>Sub-Scores</h3>
113
+ <SubScoreBar label="Drift" value={latest?.driftScore ?? 0} />
114
+ <SubScoreBar label="Output Quality" value={latest?.outputQuality ?? 0} />
115
+ <SubScoreBar label="Error Rate" value={latest?.errorScore ?? 0} />
116
+ <SubScoreBar label="Cache Health" value={latest?.cacheHealth ?? 0} />
117
+ <SubScoreBar label="Cache Poison" value={latest?.cachePoison ?? 0} />
118
+ </div>
119
+ </div>
120
+
121
+ <div className="health-trend-section">
122
+ <h3>Health Trend (last {data.trend.length} turns)</h3>
123
+ <HealthSparkline trend={data.trend} />
124
+ </div>
125
+
126
+ {data.alerts.length > 0 && (
127
+ <div className="health-alerts">
128
+ <h3>Recent Alerts ({data.alerts.length})</h3>
129
+ <div className="health-alert-list">
130
+ {data.alerts.slice(-10).map((a) => (
131
+ <div key={`${a.ts}-${a.turnIndex}`} className="health-alert-row">
132
+ <span className="health-alert-time">{new Date(a.ts).toLocaleTimeString()}</span>
133
+ <span className="health-alert-model">{a.modelId ?? "(unknown)"}</span>
134
+ <span className="health-alert-score" style={{ color: healthColor(a.composite) }}>
135
+ {(a.composite * 100).toFixed(0)}
136
+ </span>
137
+ {a.cachePoison < 0.3 && <span className="health-alert-tag">CACHE POISON</span>}
138
+ </div>
139
+ ))}
140
+ </div>
141
+ </div>
142
+ )}
143
+
144
+ {data.perModel.length > 0 && (
145
+ <div className="health-per-model">
146
+ <h3>Health by Model</h3>
147
+ <table className="health-model-table">
148
+ <thead><tr><th>Model</th><th>Avg Health</th><th>Samples</th></tr></thead>
149
+ <tbody>
150
+ {data.perModel.map((m) => (
151
+ <tr key={m.modelId}>
152
+ <td>{m.modelId}</td>
153
+ <td style={{ color: healthColor(m.avgComposite) }}>
154
+ {(m.avgComposite * 100).toFixed(0)}%
155
+ </td>
156
+ <td>{m.sampleCount}</td>
157
+ </tr>
158
+ ))}
159
+ </tbody>
160
+ </table>
161
+ </div>
162
+ )}
163
+ </div>
164
+ );
165
+ }
@@ -0,0 +1,104 @@
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
+
12
+ import { createRequire } from "node:module";
13
+ import type { IncomingMessage, ServerResponse } from "node:http";
14
+ import type { RouteContext } from "./routes-core.js";
15
+
16
+ export function handleContextHealth(
17
+ req: IncomingMessage,
18
+ res: ServerResponse,
19
+ ctx: RouteContext,
20
+ ): boolean {
21
+ if (!req.url?.startsWith("/api/context-health")) return false;
22
+ try {
23
+ if (req.method !== "GET") {
24
+ res.writeHead(405, { "Content-Type": "application/json" });
25
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
26
+ return true;
27
+ }
28
+ const url = new URL(req.url, "http://localhost");
29
+ const minutes = Math.max(1, Number(url.searchParams.get("minutes") ?? 30));
30
+ const sinceTs = Date.now() - minutes * 60_000;
31
+
32
+ const pfReq = createRequire(import.meta.url);
33
+ const {
34
+ readLatestContextHealth,
35
+ readContextHealthTrend,
36
+ readContextHealthRows,
37
+ } = pfReq("../../src/store/sqlite.js") as typeof import("../../src/store/sqlite.js");
38
+
39
+ const latest = readLatestContextHealth(ctx.stateDir);
40
+ const trend = readContextHealthTrend(ctx.stateDir, 50);
41
+ const rows = readContextHealthRows(ctx.stateDir, sinceTs);
42
+
43
+ const perModel = new Map<string, { sum: number; count: number }>();
44
+ for (const r of rows) {
45
+ const mid = r.modelId ?? "(unknown)";
46
+ const entry = perModel.get(mid) ?? { sum: 0, count: 0 };
47
+ entry.sum += r.composite;
48
+ entry.count += 1;
49
+ perModel.set(mid, entry);
50
+ }
51
+ const perModelAverages = [...perModel.entries()].map(([modelId, v]) => ({
52
+ modelId,
53
+ avgComposite: v.count > 0 ? v.sum / v.count : 0,
54
+ sampleCount: v.count,
55
+ }));
56
+
57
+ const alerts = rows.filter(
58
+ (r) => r.composite < 0.5 || r.cachePoison < 0.3,
59
+ );
60
+
61
+ const body = {
62
+ updatedAt: new Date().toISOString(),
63
+ latest,
64
+ trend,
65
+ rows: rows.slice(-200),
66
+ perModel: perModelAverages,
67
+ alerts,
68
+ };
69
+ res.writeHead(200, { "Content-Type": "application/json" });
70
+ res.end(JSON.stringify(body));
71
+ } catch (e) {
72
+ res.writeHead(500, { "Content-Type": "application/json" });
73
+ res.end(JSON.stringify({ error: String(e) }));
74
+ }
75
+ return true;
76
+ }
77
+
78
+ export function handleCachePoison(
79
+ req: IncomingMessage,
80
+ res: ServerResponse,
81
+ ctx: RouteContext,
82
+ ): boolean {
83
+ if (!req.url?.startsWith("/api/cache-poison")) return false;
84
+ try {
85
+ if (req.method !== "GET") {
86
+ res.writeHead(405, { "Content-Type": "application/json" });
87
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
88
+ return true;
89
+ }
90
+ const sinceTs = Date.now() - 30 * 60_000;
91
+ const pfReq = createRequire(import.meta.url);
92
+ const { readCachePoisonEvents } = pfReq(
93
+ "../../src/store/sqlite.js",
94
+ ) as typeof import("../../src/store/sqlite.js");
95
+
96
+ const events = readCachePoisonEvents(ctx.stateDir, sinceTs);
97
+ res.writeHead(200, { "Content-Type": "application/json" });
98
+ res.end(JSON.stringify({ updatedAt: new Date().toISOString(), events }));
99
+ } catch (e) {
100
+ res.writeHead(500, { "Content-Type": "application/json" });
101
+ res.end(JSON.stringify({ error: String(e) }));
102
+ }
103
+ return true;
104
+ }
@@ -24,3 +24,4 @@ export { handleMemoryStatus } from "./routes-memory.js";
24
24
  export { handleSetupStatus, handleSetupDetect, handleSetupConfigure } from "./routes-setup.js";
25
25
  export { handleMemoryMap } from "./routes-memory-map.js";
26
26
  export { handleCacheStripes } from "./routes-cache.js";
27
+ export { handleContextHealth, handleCachePoison } from "./routes-health.js";
@@ -44,6 +44,8 @@ import {
44
44
  handleSetupDetect,
45
45
  handleSetupConfigure,
46
46
  handleMemoryMap,
47
+ handleContextHealth,
48
+ handleCachePoison,
47
49
  handleStatic,
48
50
  } from "./routes.js";
49
51
 
@@ -239,6 +241,8 @@ export async function launchDashboardServer(
239
241
  if (handleSetupDetect(req, res, ctx)) return;
240
242
  if (handleSetupConfigure(req, res, ctx)) return;
241
243
  if (handleMemoryMap(req, res, ctx)) return;
244
+ if (handleContextHealth(req, res, ctx)) return;
245
+ if (handleCachePoison(req, res, ctx)) return;
242
246
  handleStatic(req, res, ctx);
243
247
  });
244
248
 
@@ -198,6 +198,16 @@ export interface MegaConfig {
198
198
  memoryGraphCrossTypeThreshold: number;
199
199
  /** D3 edges: Cosine floor for within-type semantic edges. */
200
200
  memoryGraphWithinTypeThreshold: number;
201
+ /** v0.12: Context health monitoring (drift + output quality + cache poison). Default ON. */
202
+ contextHealth: boolean;
203
+ /** Sub-flag: drift detection (topic drift + error escalation + prefix instability). */
204
+ contextHealthDrift: boolean;
205
+ /** Sub-flag: output quality analysis (repetition, coherence, token salad). */
206
+ contextHealthOutputQuality: boolean;
207
+ /** Sub-flag: tri-layer KV cache poison validation. */
208
+ contextHealthCachePoison: boolean;
209
+ /** v0.12: KV cache poison mitigation — inject prefix break on mismatch. Default OFF. */
210
+ contextHealthMitigate: boolean;
201
211
  }
202
212
 
203
213
  function envFlag(name: string, fallback: number): number {
@@ -372,6 +382,11 @@ export function loadConfig(): MegaConfig {
372
382
  memoryGraphSeedMemories: envBool("MEGACOMPACT_MEMORY_GRAPH_SEED_MEMORIES", true),
373
383
  memoryGraphCrossTypeThreshold: envFlag("MEGACOMPACT_MEMORY_GRAPH_CROSS_TYPE_THRESHOLD", 0.85),
374
384
  memoryGraphWithinTypeThreshold: envFlag("MEGACOMPACT_MEMORY_GRAPH_WITHIN_TYPE_THRESHOLD", 0.7),
385
+ contextHealth: envBool("MEGACOMPACT_CONTEXT_HEALTH", true),
386
+ contextHealthDrift: envBool("MEGACOMPACT_CONTEXT_HEALTH_DRIFT", true),
387
+ contextHealthOutputQuality: envBool("MEGACOMPACT_CONTEXT_HEALTH_OUTPUT_QUALITY", true),
388
+ contextHealthCachePoison: envBool("MEGACOMPACT_CONTEXT_HEALTH_CACHE_POISON", true),
389
+ contextHealthMitigate: envBool("MEGACOMPACT_CONTEXT_HEALTH_MITIGATE", false),
375
390
  // D.1: env-overridable recompact delta (minimum context growth % before
376
391
  // re-compacting instead of replaying the cached live trim). Default 50.
377
392
  recompactPctDelta: envFlag("MEGACOMPACT_RECOMPACT_PCT_DELTA", 50),
@@ -35,6 +35,7 @@ import {
35
35
  import {
36
36
  refreshStripeAssignments as writeStripeAssignments,
37
37
  } from "../../src/cache-stripe-impl.js";
38
+ import { handleTurnEndHealth } from "./health-handler.js";
38
39
 
39
40
  /** Register agent/turn tracking event handlers. */
40
41
  export function registerAgentHandlers(
@@ -296,6 +297,7 @@ export function registerAgentHandlers(
296
297
  pi.on("turn_end", async (event, ctx) => {
297
298
  runtime.dashboard.event("turn_end", { turnIndex: event.turnIndex });
298
299
  runtime.snapshot(ctx);
300
+ runtime.lastErrorCategory = null;
299
301
 
300
302
  // S53: consume staged recall blocks ONLY if they were actually injected
301
303
  // into a view this turn. If no context event fired (edge: turn ended
@@ -436,6 +438,7 @@ export function registerAgentHandlers(
436
438
  } else {
437
439
  const category = classifyError(event.message);
438
440
  const detail = classifyErrorDetailed(event.message);
441
+ runtime.lastErrorCategory = category;
439
442
  // R3: stateful poisoned-context signal — repeated identical error text
440
443
  // across consecutive turns. The classifier is stateless, so this
441
444
  // upgrade happens here. A 'transient' (or 'poisoned-context') turn
@@ -860,5 +863,8 @@ export function registerAgentHandlers(
860
863
  /* non-fatal: stripe refresh never breaks the agent loop */
861
864
  }
862
865
  }
866
+
867
+ // v0.12: Context Health — compute + persist health score. Non-fatal.
868
+ handleTurnEndHealth(event, runtime, config);
863
869
  });
864
870
  }