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
@@ -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
+ }
@@ -64,7 +64,7 @@ function renderByModel(repos) {
64
64
  var rows = document.getElementById('bm-rows');
65
65
  if (!rows) return;
66
66
  if (!repos || !repos.length) {
67
- rows.innerHTML = '<tr><td colspan="14" class="repo-none">No repositories registered yet.</td></tr>';
67
+ rows.innerHTML = '<tr><td colspan="16" class="repo-none">No repositories registered yet.</td></tr>';
68
68
  return;
69
69
  }
70
70
  var groups = {};
@@ -107,11 +107,35 @@ function renderByModel(repos) {
107
107
  var fmt = function(v) { return '$' + v.toFixed(6); };
108
108
  return lo === hi ? fmt(lo) : fmt(lo) + '–' + fmt(hi);
109
109
  }
110
+ // Helper: compute cache hit % from providerCacheRead/Write tokens
111
+ function cacheHitPct(cr, cw, inp) {
112
+ var denom = cr + cw + (inp || 0);
113
+ return denom > 0 ? ((cr / denom) * 100).toFixed(1) + '%' : '—';
114
+ }
115
+ // Helper: compute cache $ saved (read = 0.9 * inputRate, write = 0.25 * inputRate)
116
+ function cacheDollar(cr, cw, rate) {
117
+ if (!cr || !rate) return '—';
118
+ var saved = cr * rate * 0.9 - cw * rate * 0.25;
119
+ return '$' + saved.toFixed(4);
120
+ }
121
+ // Aggregate providerCacheRead/Write across repos in this group
122
+ var groupCacheRead = 0, groupCacheWrite = 0, groupTokensIn = 0;
123
+ for (var ii = 0; ii < repos.length; ii++) {
124
+ var rr = repos[ii];
125
+ var key2 = (rr.modelName && String(rr.modelName).trim()) || '(unknown)';
126
+ if (key2 === g.model) {
127
+ groupCacheRead += (rr.providerCacheRead || 0);
128
+ groupCacheWrite += (rr.providerCacheWrite || 0);
129
+ groupTokensIn += (rr.tokensDropped || 0);
130
+ }
131
+ }
110
132
  rows.innerHTML = arr.map(function(g) {
111
133
  var freed = (g.tokensIn || 0) - (g.tokensOut || 0);
112
- var usd = g.usd > 0 ? '$' + g.usd.toFixed(4) : '—';
134
+ // B: always show a dollar value (including $0.0000) — do not use '—' when usd is 0
135
+ var usd = '$' + (g.usd || 0).toFixed(4);
113
136
  var when = g.lastAt ? new Date(g.lastAt).toLocaleString() : '—';
114
137
  var reas = g.reasoning == null ? '—' : (g.reasoning ? 'yes' : 'no');
138
+ var rate0 = g.inRates && g.inRates.length ? g.inRates[0] : 0;
115
139
  return '<tr>' +
116
140
  '<td><span class="repo-model">' + sanitize(g.model) + '</span></td>' +
117
141
  '<td>' + sanitize(g.provider) + '</td>' +
@@ -126,6 +150,8 @@ function renderByModel(repos) {
126
150
  '<td class="num">' + collapseRate(g.inRates) + '</td>' +
127
151
  '<td class="num">' + collapseRate(g.outRates) + '</td>' +
128
152
  '<td class="num">' + sanitize(usd) + '</td>' +
153
+ '<td class="num">' + cacheHitPct(groupCacheRead, groupCacheWrite, groupTokensIn) + '</td>' +
154
+ '<td class="num">' + cacheDollar(groupCacheRead, groupCacheWrite, rate0) + '</td>' +
129
155
  '<td class="num">' + sanitize(when) + '</td>' +
130
156
  '</tr>';
131
157
  }).join('');
@@ -14,6 +14,9 @@ import { homedir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { DatabaseSync } from "node:sqlite";
16
16
  import type { IndexRepo, IndexIndex } from "./types.js";
17
+ import {
18
+ readProviderCacheForRepo,
19
+ } from "../../src/store/sqlite/perf-samples.js";
17
20
 
18
21
  export function getIndexDir(): string {
19
22
  const override = process.env.MEGACOMPACT_INDEX_DIR;
@@ -100,25 +103,21 @@ export function readIndex(): IndexIndex | null {
100
103
  repo.maxTokens = Number(mrow.max_tokens ?? 0) || null;
101
104
  repo.reasoning = Number(mrow.reasoning ?? 0) === 1;
102
105
  }
103
- // Provider prompt-cache stats (E.3)
106
+ // Provider prompt-cache stats (E.3 / F4)
107
+ // Use readProviderCacheForRepo which correctly reads cache token
108
+ // counts from the meta JSON column (cacheRead/cacheWrite), not
109
+ // from non-existent table columns cache_read/cache_write.
104
110
  try {
105
- const pRow = sdb
106
- .prepare(
107
- `SELECT
108
- SUM(cache_read) AS totalRead,
109
- SUM(cache_write) AS totalWrite,
110
- SUM(cache_read + cache_write) * 1.0 / MAX(SUM(cache_read + cache_write + tokens_in), 1) AS hitPct
111
- FROM perf_samples
112
- WHERE cache_read > 0 OR cache_write > 0`,
113
- )
114
- .get() as
115
- | { totalRead: number; totalWrite: number; hitPct: number }
116
- | undefined;
117
- if (pRow && pRow.totalRead > 0) {
118
- repo.providerCacheRead = Number(pRow.totalRead);
119
- repo.providerCacheWrite = Number(pRow.totalWrite);
120
- repo.providerCachePct = Number((pRow.hitPct * 100).toFixed(1));
111
+ const pc = readProviderCacheForRepo(repo.stateDir);
112
+ if (pc.totalCacheRead > 0) {
113
+ repo.providerCacheRead = pc.totalCacheRead;
114
+ repo.providerCacheWrite = pc.totalCacheWrite;
115
+ repo.providerCachePct = Number(pc.avgHitPct.toFixed(1));
121
116
  }
117
+ // Also enrich contextWindow/maxTokens/reasoning from the
118
+ // latest model_snapshots row (was already read above, but
119
+ // when latestModelSnapshot was used above we get the same
120
+ // data from the per-repo store — keep as-is).
122
121
  } catch {
123
122
  /* best-effort — perf_samples may not exist */
124
123
  }
@@ -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
+ }
@@ -10,6 +10,8 @@ import type { IncomingMessage, ServerResponse } from "node:http";
10
10
  import type { RouteContext } from "./routes-core.js";
11
11
  import { openTurnStore } from "../../src/store/turns/connection.js";
12
12
  import { createTopicStore } from "../../src/topics/store.js";
13
+ import { openStore } from "../../src/store/sqlite.js";
14
+ import { buildTopicModel } from "../../src/topics/cluster.js";
13
15
  import type { TopicsResponse, TopicRow } from "./api-contracts/game-types.js";
14
16
  import type { TopicMemoriesResponse } from "./api-contracts/turns.js";
15
17
 
@@ -53,6 +55,25 @@ export function handleTopics(
53
55
 
54
56
  try {
55
57
  const tdb = openTurnStore(ctx.stateDir);
58
+
59
+ // Lazy build: when the topics table is empty, attempt a one-shot build
60
+ // from available context_chunks embeddings so the wiki is useful even
61
+ // before the first compaction fires.
62
+ const topicCount = (
63
+ tdb.prepare("SELECT COUNT(*) AS c FROM topics").get() as { c: number }
64
+ ).c;
65
+ if (topicCount === 0) {
66
+ try {
67
+ const mainDb = openStore(ctx.stateDir);
68
+ const model = buildTopicModel(mainDb);
69
+ if (model.k > 0 && model.totalChunks > 0) {
70
+ createTopicStore(ctx.stateDir).replaceTopicModel(model);
71
+ }
72
+ } catch {
73
+ /* non-fatal: lazy wiki build is best-effort */
74
+ }
75
+ }
76
+
56
77
  const topics: TopicRow[] = (
57
78
  tdb
58
79
  .prepare(
@@ -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
  }