pi-mega-compact 0.8.17 → 0.8.18

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 (85) hide show
  1. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +12 -0
  2. package/dist/extensions/dashboard-server/api-contracts.test.js +19 -6
  3. package/dist/extensions/dashboard-server/helpers.js +37 -0
  4. package/dist/extensions/dashboard-server/html/all-repos-tab.js +26 -0
  5. package/dist/extensions/dashboard-server/html/body-open.js +23 -0
  6. package/dist/extensions/dashboard-server/html/current-repo-tab.js +130 -0
  7. package/dist/extensions/dashboard-server/html/head-open.js +16 -0
  8. package/dist/extensions/dashboard-server/html/high-score-tab.js +25 -0
  9. package/dist/extensions/dashboard-server/html/repo-detail-modal.js +26 -0
  10. package/dist/extensions/dashboard-server/html/script.js +259 -0
  11. package/dist/extensions/dashboard-server/html/styles.js +103 -0
  12. package/dist/extensions/dashboard-server/html/summary-tab.js +19 -0
  13. package/dist/extensions/dashboard-server/html-template.js +41 -0
  14. package/dist/extensions/dashboard-server/server.js +83 -0
  15. package/dist/extensions/mega-runtime/state.js +17 -1
  16. package/dist/src/store/sqlite/connection.js +35 -0
  17. package/dist/src/store/sqlite/global-index.js +199 -1
  18. package/dist/src/store/sqlite/global-index.test.js +136 -0
  19. package/dist/src/store/sqlite/index-store.js +167 -0
  20. package/dist/src/store/sqlite/memory.js +54 -0
  21. package/dist/src/store/sqlite/minhash-lsh.js +47 -0
  22. package/dist/src/store/sqlite/sessions.js +39 -0
  23. package/dist/src/store/sqlite/transaction.js +19 -0
  24. package/dist/src/vectorStore/add.js +260 -0
  25. package/dist/src/vectorStore/dedup.js +52 -0
  26. package/dist/src/vectorStore/index.js +10 -0
  27. package/dist/src/vectorStore/queries.js +83 -0
  28. package/dist/src/vectorStore/search.js +95 -0
  29. package/dist/src/vectorStore/session.js +19 -0
  30. package/dist/src/vectorStore/store.js +105 -0
  31. package/dist/src/vectorStore/types.js +6 -0
  32. package/dist/src/vectorStore/utils.js +23 -0
  33. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js → AchievementTiles-CNWZ5NjP.js} +2 -2
  34. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js.map → AchievementTiles-CNWZ5NjP.js.map} +1 -1
  35. package/extensions/dashboard-client/dist/assets/{AchievementsTab-DKgqx1RQ.js → AchievementsTab-BKPifNKj.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{AchievementsTab-DKgqx1RQ.js.map → AchievementsTab-BKPifNKj.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js → CacheTab--epzrQHD.js} +2 -2
  38. package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js.map → CacheTab--epzrQHD.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/{ConfigTab-DXD7zbqy.js → ConfigTab-D3ralwSj.js} +2 -2
  40. package/extensions/dashboard-client/dist/assets/ConfigTab-D3ralwSj.js.map +1 -0
  41. package/extensions/dashboard-client/dist/assets/EventsTab-BKkuVQsM.js +2 -0
  42. package/extensions/dashboard-client/dist/assets/EventsTab-BKkuVQsM.js.map +1 -0
  43. package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js → GameTab-CgUvITYy.js} +2 -2
  44. package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js.map → GameTab-CgUvITYy.js.map} +1 -1
  45. package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js → MetricsTab-DrlJqXGL.js} +2 -2
  46. package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js.map → MetricsTab-DrlJqXGL.js.map} +1 -1
  47. package/extensions/dashboard-client/dist/assets/{OverviewTab-lVGR1Jl_.js → OverviewTab-_gD756wJ.js} +2 -2
  48. package/extensions/dashboard-client/dist/assets/{OverviewTab-lVGR1Jl_.js.map → OverviewTab-_gD756wJ.js.map} +1 -1
  49. package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js → ReposTab-DjEZmqUb.js} +2 -2
  50. package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js.map → ReposTab-DjEZmqUb.js.map} +1 -1
  51. package/extensions/dashboard-client/dist/assets/SessionsTab-DsYMUzoh.js +70 -0
  52. package/extensions/dashboard-client/dist/assets/SessionsTab-DsYMUzoh.js.map +1 -0
  53. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js → TimeSavedCard-BbAXT1wO.js} +2 -2
  54. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js.map → TimeSavedCard-BbAXT1wO.js.map} +1 -1
  55. package/extensions/dashboard-client/dist/assets/{index-Cfxniu2R.css → index-BTft6DM_.css} +1 -1
  56. package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js → index-CnRzusfi.js} +10 -10
  57. package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js.map → index-CnRzusfi.js.map} +1 -1
  58. package/extensions/dashboard-client/dist/assets/useSSE-Bf-1rZ2N.js +2 -0
  59. package/extensions/dashboard-client/dist/assets/useSSE-Bf-1rZ2N.js.map +1 -0
  60. package/extensions/dashboard-client/dist/index.html +2 -2
  61. package/extensions/dashboard-client/package-lock.json +374 -2
  62. package/extensions/dashboard-client/package.json +2 -1
  63. package/extensions/dashboard-client/src/App.tsx +5 -1
  64. package/extensions/dashboard-client/src/api/client.ts +12 -0
  65. package/extensions/dashboard-client/src/components/ActiveSessionsTable.tsx +185 -0
  66. package/extensions/dashboard-client/src/components/EventStream.tsx +3 -0
  67. package/extensions/dashboard-client/src/components/SessionsMemoryChart.tsx +199 -0
  68. package/extensions/dashboard-client/src/main.tsx +1 -0
  69. package/extensions/dashboard-client/src/styles/sessions.css +193 -0
  70. package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +6 -3
  71. package/extensions/dashboard-client/src/tabs/SessionsTab.tsx +193 -0
  72. package/extensions/dashboard-server/api-contracts/core.ts +25 -0
  73. package/extensions/dashboard-server/api-contracts/endpoints.ts +102 -0
  74. package/extensions/dashboard-server/api-contracts/index.ts +10 -1
  75. package/extensions/dashboard-server/api-contracts.test.ts +20 -7
  76. package/extensions/dashboard-server/server.ts +100 -0
  77. package/extensions/mega-runtime/state.ts +31 -0
  78. package/package.json +1 -1
  79. package/src/store/sqlite/global-index.test.ts +222 -0
  80. package/src/store/sqlite/global-index.ts +316 -1
  81. package/dist/extensions/dashboard-client/src/hooks/useApi.js +0 -51
  82. package/dist/extensions/dashboard-client/src/hooks/useSSE.js +0 -63
  83. package/extensions/dashboard-client/dist/assets/ConfigTab-DXD7zbqy.js.map +0 -1
  84. package/extensions/dashboard-client/dist/assets/EventsTab-BGeuYvM3.js +0 -2
  85. package/extensions/dashboard-client/dist/assets/EventsTab-BGeuYvM3.js.map +0 -1
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Inline CSS for the dashboard.
3
+ *
4
+ * Extracted verbatim from the original monolithic `html-template.ts`.
5
+ * Kept as a single cohesive stylesheet — splitting CSS into per-tab files
6
+ * would fragment the design system (shared tokens, e.g. `.card`, `.stat-grid`,
7
+ * `.meter-*` are reused across all tabs).
8
+ */
9
+ export function styles() {
10
+ return `<style>
11
+ * { margin: 0; padding: 0; box-sizing: border-box; }
12
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #0d1117; color: #c9d1d9; padding: 24px; line-height: 1.5; }
13
+ h1 { font-size: 20px; font-weight: 600; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; color: #f0f6fc; }
14
+ h1 .tier { background: #1f6feb; color: #fff; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: .5px; }
15
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
16
+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
17
+ .card.safe { border-color: #238636; }
18
+ .card.safe h2 { color: #3fb950; }
19
+ .safe-note { font-size: 12px; color: #8b949e; margin: 12px 0 0; line-height: 1.5; }
20
+ .value.ok { color: #3fb950; }
21
+ .label {
22
+ cursor: help;
23
+ border-bottom: 1px dotted #484f58;
24
+ }
25
+ .card.legend { grid-column: 1 / -1; }
26
+ .legend-list { margin: 0; padding-left: 18px; color: #c9d1d9; }
27
+ .legend-list li { margin-bottom: 8px; line-height: 1.5; }
28
+ .legend-list b { color: #f0f6fc; }
29
+ .legend-note { font-size: 12px; color: #8b949e; margin: 12px 0 0; font-style: italic; }
30
+ .card h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: #8b949e; margin-bottom: 12px; font-weight: 600; }
31
+ .meter-track { background: #21262d; border-radius: 4px; height: 20px; overflow: hidden; margin: 8px 0; }
32
+ .meter-fill { height: 100%; border-radius: 4px; transition: width .6s ease; min-width: 2px; }
33
+ .meter-green { background: #238636; }
34
+ .meter-yellow { background: #d29922; }
35
+ .meter-red { background: #f85149; }
36
+ .meter-label { font-size: 24px; font-weight: 700; color: #f0f6fc; }
37
+ .meter-sub { font-size: 12px; color: #8b949e; }
38
+ .status-row { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 14px; }
39
+ .status-row .bullet { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
40
+ .bullet-on { background: #3fb950; box-shadow: 0 0 6px #3fb95088; }
41
+ .bullet-off { background: #484f58; }
42
+ .bullet-na { background: #d29922; }
43
+ .state-text { font-size: 13px; color: #8b949e; margin-top: 8px; font-family: monospace; }
44
+ .stat-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 14px; }
45
+ .stat-grid .label { color: #8b949e; }
46
+ .stat-grid .value { color: #f0f6fc; font-weight: 600; font-family: monospace; }
47
+ .conf-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 14px; }
48
+ .conf-grid .label { color: #8b949e; }
49
+ .conf-grid .value { color: #f0f6fc; font-family: monospace; }
50
+ .events { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
51
+ .events h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: #8b949e; margin-bottom: 12px; font-weight: 600; }
52
+ .events-wrap { max-height: 240px; overflow-y: auto; font-family: monospace; font-size: 12px; }
53
+ .ev { padding: 3px 0; border-bottom: 1px solid #21262d; display: flex; gap: 8px; align-items: baseline; }
54
+ .ev:last-child { border-bottom: none; }
55
+ .ev-type { font-weight: 700; min-width: 70px; text-align: right; }
56
+ .ev-type-compact { color: #3fb950; }
57
+ .ev-type-recall { color: #a371f7; }
58
+ .ev-time { color: #484f58; font-size: 10px; min-width: 80px; }
59
+ .ev-detail { color: #8b949e; flex: 1; }
60
+ .updated { font-size: 11px; color: #484f58; margin-top: 16px; text-align: right; }
61
+ .empty { color: #484f58; font-style: italic; font-size: 13px; padding: 8px 0; }
62
+ .offline-banner { background: #f8514922; border: 1px solid #f85149; border-radius: 6px; padding: 10px 16px; margin-bottom: 16px; font-size: 13px; color: #f85149; display: none; }
63
+ .tabs { display: flex; gap: 8px; margin-bottom: 20px; }
64
+ .tab { background: #161b22; color: #8b949e; border: 1px solid #30363d; border-radius: 6px; padding: 8px 16px; font-size: 13px; font-weight: 600; cursor: pointer; transition: all .15s ease; }
65
+ .tab:hover { color: #c9d1d9; border-color: #484f58; }
66
+ .tab.active { background: #1f6feb; color: #fff; border-color: #1f6feb; }
67
+ .tab.future { opacity: .7; }
68
+ .tab .soon { font-size: 9px; font-weight: 700; background: #6e40c9; color: #fff; padding: 1px 6px; border-radius: 8px; margin-left: 6px; text-transform: uppercase; letter-spacing: .5px; }
69
+ .tab-panel { display: none; }
70
+ .tab-panel.active { display: block; }
71
+ .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 20px; }
72
+ .summary-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
73
+ .summary-card .num { font-size: 24px; font-weight: 700; color: #f0f6fc; }
74
+ .summary-card .lbl { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
75
+ table.repos { width: 100%; border-collapse: collapse; background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
76
+ table.repos th, table.repos td { text-align: left; padding: 10px 14px; font-size: 13px; border-bottom: 1px solid #21262d; }
77
+ table.repos th { color: #8b949e; text-transform: uppercase; letter-spacing: .5px; font-size: 11px; background: #0d1117; }
78
+ table.repos td.num { font-family: monospace; color: #f0f6fc; text-align: right; }
79
+ table.repos tr:last-child td { border-bottom: none; }
80
+ table.repos tr:hover td { background: #1c2128; }
81
+ .repo-model { color: #a371f7; }
82
+ .repo-none { color: #484f58; font-style: italic; }
83
+ .updated { font-size: 11px; color: #484f58; margin-top: 16px; text-align: right; }
84
+ .model-pill { background: #6e40c9; color: #fff; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: .5px; }
85
+ .card.cost h2 { color: #a371f7; }
86
+ .cost-usd { font-size: 22px; font-weight: 700; color: #3fb950; }
87
+ .cost-sub { font-size: 12px; color: #8b949e; margin-top: 4px; }
88
+ .repo-link { cursor: pointer; }
89
+ .repo-link:hover td { color: #58a6ff; }
90
+ .repo-detail { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: none; align-items: center; justify-content: center; z-index: 50; }
91
+ .repo-detail.open { display: flex; }
92
+ .repo-detail-box { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 24px; width: 560px; max-width: 92vw; max-height: 86vh; overflow-y: auto; }
93
+ .repo-detail-box h2 { font-size: 14px; color: #f0f6fc; margin-bottom: 14px; display: flex; justify-content: space-between; align-items: center; }
94
+ .repo-close { cursor: pointer; color: #8b949e; font-size: 20px; line-height: 1; border: none; background: none; padding: 0 4px; }
95
+ .repo-close:hover { color: #f0f6fc; }
96
+ .repo-path { font-size: 11px; color: #484f58; word-break: break-all; margin: -8px 0 12px; }
97
+ .placeholder { text-align: center; padding: 48px 16px; color: #8b949e; }
98
+ .placeholder .em { font-size: 40px; margin-bottom: 12px; }
99
+ .placeholder h2 { font-size: 16px; color: #f0f6fc; margin-bottom: 8px; }
100
+ .placeholder p { font-size: 13px; line-height: 1.6; max-width: 420px; margin: 0 auto; }
101
+ </style>
102
+ `;
103
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * "Summary" tab panel — aggregate counts across all registered repos.
3
+ *
4
+ * Populated by the index poller in `script.ts`.
5
+ */
6
+ export function summaryTab() {
7
+ return `<!-- Summary (aggregate across all repos) -->
8
+ <div class="tab-panel" id="panel-summary">
9
+ <div class="summary-grid">
10
+ <div class="summary-card"><div class="num" id="sm-repos">0</div><div class="lbl">Repositories</div></div>
11
+ <div class="summary-card"><div class="num" id="sm-checkpoints">0</div><div class="lbl">Total Checkpoints</div></div>
12
+ <div class="summary-card"><div class="num" id="sm-saved">0</div><div class="lbl">Total Tokens Saved</div></div>
13
+ <div class="summary-card"><div class="num" id="sm-bytes">0 B</div><div class="lbl">Compressed-Original</div></div>
14
+ </div>
15
+ <div class="updated" id="sm-updated"></div>
16
+ </div>
17
+
18
+ `;
19
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * HTML template for the dashboard server — composer.
3
+ *
4
+ * Originally a single 518-line monolithic file returning one large HTML page
5
+ * template. Split into per-concern fragments under `./html/` (each tab is its
6
+ * own file; plus shared styles, header chrome, the repo-detail modal, and the
7
+ * client script). This file composes them back into the full document.
8
+ *
9
+ * `tierName` is threaded into the heading badge and the Configuration card.
10
+ *
11
+ * Fragment layout:
12
+ * headOpen() — <!DOCTYPE html>…<title>
13
+ * styles() — <style>…</style> (shared CSS, design-system level)
14
+ * bodyOpen(tier) — </head><body>…<h1>…</nav> (incl. High Score future tab)
15
+ * currentRepoTab(tier) — panel-current (cards + event stream + in-panel table)
16
+ * allReposTab() — panel-all (machine-wide repo table)
17
+ * summaryTab() — panel-summary (aggregate cards)
18
+ * highScoreTab() — panel-highscore (FUTURE placeholder, next milestone)
19
+ * repoDetailModal() — per-repo overlay (position-independent)
20
+ * script() — <script>…</script></body></html>
21
+ */
22
+ import { headOpen } from "./html/head-open.js";
23
+ import { styles } from "./html/styles.js";
24
+ import { bodyOpen } from "./html/body-open.js";
25
+ import { currentRepoTab } from "./html/current-repo-tab.js";
26
+ import { allReposTab } from "./html/all-repos-tab.js";
27
+ import { summaryTab } from "./html/summary-tab.js";
28
+ import { highScoreTab } from "./html/high-score-tab.js";
29
+ import { repoDetailModal } from "./html/repo-detail-modal.js";
30
+ import { script } from "./html/script.js";
31
+ export function dashboardHtml(tierName) {
32
+ return (headOpen() +
33
+ styles() +
34
+ bodyOpen(tierName) +
35
+ currentRepoTab(tierName) +
36
+ repoDetailModal() +
37
+ allReposTab() +
38
+ summaryTab() +
39
+ highScoreTab() +
40
+ script());
41
+ }
@@ -691,6 +691,89 @@ export async function launchDashboardServer(stateDir) {
691
691
  }
692
692
  return;
693
693
  }
694
+ // /api/sessions — S39: active pi sessions with latest token usage + heartbeat.
695
+ // GET returns {updatedAt, pruned, sessions[]} after pruning stale heartbeats.
696
+ // The dashboard server is a detached child with no MegaRuntime ref, so it
697
+ // reads session_heartbeats via a require()'d sqlite helper (same pattern as
698
+ // /api/achievements, /api/perf). Non-GET -> 405. PREVENT-PI-004: loopback.
699
+ if (req.url?.startsWith("/api/sessions")) {
700
+ // Guard: /api/sessions/timeseries handled separately below.
701
+ if (req.url.startsWith("/api/sessions/timeseries")) {
702
+ // Fall through to the timeseries handler below.
703
+ }
704
+ else {
705
+ const sReq = createRequire(import.meta.url);
706
+ const { readActiveSessions, pruneStaleSessions, listRepoRegistry, } = sReq("../../src/store/sqlite.js");
707
+ if (req.method !== "GET") {
708
+ res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
709
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
710
+ return;
711
+ }
712
+ try {
713
+ const pruned = pruneStaleSessions(); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
714
+ const active = readActiveSessions();
715
+ const repos = listRepoRegistry();
716
+ const repoMap = new Map(repos.map((r) => [r.repoRoot, r]));
717
+ const sessions = active.map((s) => ({
718
+ pid: s.pid,
719
+ sessionId: s.sessionId,
720
+ repoRoot: s.repoRoot,
721
+ displayName: s.repoRoot
722
+ ? (s.repoRoot.split(/[\\/]/).filter(Boolean).pop() ?? s.repoRoot)
723
+ : (s.stateDir?.split(/[\\/]/).filter(Boolean).pop() ?? "unknown"),
724
+ model: s.repoRoot ? (repoMap.get(s.repoRoot)?.modelName ?? null) : null,
725
+ tokens: s.tokens,
726
+ percent: s.percent,
727
+ ctxWindow: s.ctxWindow,
728
+ lastSeen: s.lastSeen,
729
+ stateDir: s.stateDir,
730
+ }));
731
+ res.writeHead(200, { "Content-Type": "application/json" });
732
+ res.end(JSON.stringify({ updatedAt: new Date().toISOString(), pruned, sessions }));
733
+ }
734
+ catch (e) {
735
+ res.writeHead(500, { "Content-Type": "application/json" });
736
+ res.end(JSON.stringify({ error: "sessions_unavailable", detail: String(e) }));
737
+ }
738
+ return;
739
+ }
740
+ }
741
+ // /api/sessions/timeseries — S39: stacked per-session token timeseries for
742
+ // the recharts memory graph. GET ?minutes=N (clamped [1,1440]) returns
743
+ // {updatedAt, windowMinutes, series[], totals[]} in recharts-ready shape.
744
+ // Non-GET -> 405. PREVENT-PI-004: loopback.
745
+ if (req.url?.startsWith("/api/sessions/timeseries")) {
746
+ const tsReq = createRequire(import.meta.url);
747
+ const { readSessionTimeseries, pruneTokenSamples, } = tsReq("../../src/store/sqlite.js");
748
+ if (req.method !== "GET") {
749
+ res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
750
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
751
+ return;
752
+ }
753
+ try {
754
+ const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
755
+ let minutes = Number(url.searchParams.get("minutes") ?? "30");
756
+ if (!Number.isFinite(minutes) || minutes <= 0)
757
+ minutes = 30;
758
+ minutes = Math.min(Math.max(minutes, 1), 1440);
759
+ const sinceTs = Date.now() - minutes * 60_000;
760
+ const pruneMs = Math.max(minutes * 60_000, 1_800_000);
761
+ pruneTokenSamples(pruneMs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
762
+ const result = readSessionTimeseries(sinceTs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
763
+ res.writeHead(200, { "Content-Type": "application/json" });
764
+ res.end(JSON.stringify({
765
+ updatedAt: new Date().toISOString(),
766
+ windowMinutes: minutes,
767
+ series: result.series,
768
+ totals: result.totals,
769
+ }));
770
+ }
771
+ catch (e) {
772
+ res.writeHead(500, { "Content-Type": "application/json" });
773
+ res.end(JSON.stringify({ error: "timeseries_unavailable", detail: String(e) }));
774
+ }
775
+ return;
776
+ }
694
777
  // Fallback — serve the React client build (SPA route) or legacy dashboard.
695
778
  // Non-/api/* GETs hit here: serve client assets if built, else inline HTML.
696
779
  if (req.method === "GET" &&
@@ -15,7 +15,7 @@ import { VectorStore } from "../../src/vectorStore.js";
15
15
  import { toEngineMessages } from "../../src/adapt.js";
16
16
  import { normalizeSessionId } from "../../src/store.js";
17
17
  import { Logger } from "../../src/log.js";
18
- import { recordModelSnapshot, latestModelSnapshot, upsertRepoRegistry, recordRepoModel, getDedupStats, getCompactCount, getRecallInjected, getCacheHitTokensSaved, getGameState, recordPerfSample, } from "../../src/store/sqlite.js";
18
+ import { recordModelSnapshot, latestModelSnapshot, upsertRepoRegistry, recordRepoModel, getDedupStats, getCompactCount, getRecallInjected, getCacheHitTokensSaved, getGameState, recordPerfSample, recordSessionHeartbeat, appendTokenSample, } from "../../src/store/sqlite.js";
19
19
  import { detectCrossRepoDrift } from "../../src/driftDetection.js";
20
20
  import { repoStateDir, resolveRepoRoot, pressureRatio, pressureFromPct, pressureBand, effectiveThresholdTokens, } from "../mega-config.js";
21
21
  import { Dashboard } from "../mega-dashboard.js";
@@ -513,6 +513,22 @@ export class MegaRuntime {
513
513
  },
514
514
  });
515
515
  const perfDiskMs = this.dashboard.lastWriteMs;
516
+ // S39: record a session heartbeat + token sample into the shared
517
+ // machine-wide index.sqlite so the dashboard can show a real-time
518
+ // stacked-memory graph across all active pi processes. Behind the
519
+ // material-change gate (this code only runs when sig changed). Non-fatal
520
+ // try/catch mirrors the recordPerfSample pattern below. Skip the token
521
+ // sample when lastCtxTokens is null (no context data yet).
522
+ try {
523
+ const repo = resolveRepoRoot(ctx?.cwd ?? this.currentStateDir) ?? this.currentStateDir;
524
+ recordSessionHeartbeat(process.pid, this.rt.sessionId, repo, this.currentStateDir, this.lastCtxWindow || 0);
525
+ if (this.lastCtxTokens != null) {
526
+ appendTokenSample(this.rt.sessionId, repo, this.lastCtxTokens, this.lastCtxPercent ?? 0, this.lastCtxWindow || 0, join(this.currentStateDir, "events.log"));
527
+ }
528
+ }
529
+ catch {
530
+ /* non-fatal: S39 monitoring must never block the snapshot path */
531
+ }
516
532
  // Live stats widget above the editor
517
533
  if (ctx) {
518
534
  // ── gather widget data (computed per snapshot, rendered per frame) ────
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Per-repo SQLite connection management (open / reuse / close).
3
+ *
4
+ * In-process cache so the same stateDir reuses one connection (and so a fresh
5
+ * VectorStore over the same dir shares the open DB). Cross-process durability
6
+ * comes from reopening the same file path — proven by the integration test.
7
+ */
8
+ import { DatabaseSync } from "node:sqlite";
9
+ import { existsSync, mkdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { getStateDir } from "../../store.js";
12
+ import { initSchema } from "./schema.js";
13
+ const cache = new Map();
14
+ /** Open (or reuse) the SQLite store for a state dir. */
15
+ export function openStore(stateDir = getStateDir()) {
16
+ const existing = cache.get(stateDir);
17
+ if (existing)
18
+ return existing;
19
+ if (!existsSync(stateDir))
20
+ mkdirSync(stateDir, { recursive: true });
21
+ const db = new DatabaseSync(join(stateDir, "sqlite.db"));
22
+ db.exec("PRAGMA journal_mode = WAL");
23
+ db.exec("PRAGMA foreign_keys = ON");
24
+ initSchema(db);
25
+ cache.set(stateDir, db);
26
+ return db;
27
+ }
28
+ /** Close and evict a cached connection (test teardown only). */
29
+ export function closeStore(stateDir) {
30
+ const db = cache.get(stateDir);
31
+ if (db) {
32
+ db.close();
33
+ cache.delete(stateDir);
34
+ }
35
+ }
@@ -12,7 +12,7 @@
12
12
  * infrequent idempotent upserts (ON CONFLICT). Fully local (PREVENT-PI-004).
13
13
  */
14
14
  import { DatabaseSync } from "node:sqlite";
15
- import { existsSync, mkdirSync } from "node:fs";
15
+ import { appendFileSync, existsSync, mkdirSync } from "node:fs";
16
16
  import { homedir, tmpdir } from "node:os";
17
17
  import { join } from "node:path";
18
18
  /** Resolve the machine-wide index directory (env-overridable). */
@@ -71,6 +71,31 @@ export function openIndexStore(indexDir = getIndexDir()) {
71
71
  PRIMARY KEY (checkpoint_id, session_id)
72
72
  );
73
73
  CREATE INDEX IF NOT EXISTS idx_injected_global_cid ON injected_global(checkpoint_id);
74
+ -- S39: machine-wide session heartbeats + token time-series for the live
75
+ -- multi-pi stacked-memory graph. One row per (pid, session_id), updated
76
+ -- on every material snapshot() change (NOT on idle/context-repeat events).
77
+ -- Written by every pi process; read by the dashboard server. WAL-safe.
78
+ -- PRAMETERIZED queries (PREVENT-002); local node:sqlite (PREVENT-PI-004).
79
+ CREATE TABLE IF NOT EXISTS session_heartbeats (
80
+ pid INTEGER NOT NULL,
81
+ session_id TEXT NOT NULL,
82
+ repo_root TEXT,
83
+ state_dir TEXT,
84
+ ctx_window INTEGER DEFAULT 0,
85
+ last_seen INTEGER NOT NULL,
86
+ PRIMARY KEY (pid, session_id)
87
+ );
88
+ CREATE TABLE IF NOT EXISTS token_samples (
89
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
90
+ session_id TEXT NOT NULL,
91
+ repo_root TEXT,
92
+ tokens INTEGER NOT NULL,
93
+ percent REAL NOT NULL,
94
+ ctx_window INTEGER DEFAULT 0,
95
+ ts INTEGER NOT NULL
96
+ );
97
+ CREATE INDEX IF NOT EXISTS idx_token_samples_ts ON token_samples(ts);
98
+ CREATE INDEX IF NOT EXISTS idx_token_samples_sid_ts ON token_samples(session_id, ts);
74
99
  `);
75
100
  indexCache = iddb;
76
101
  indexCacheDir = indexDir;
@@ -222,3 +247,176 @@ export function countInjectedGlobal(indexDir = getIndexDir()) {
222
247
  const row = db.prepare("SELECT COUNT(*) AS n FROM injected_global").get();
223
248
  return row?.n ?? 0;
224
249
  }
250
+ /** Stable color palette for per-session series (hash-based, no randomness). */
251
+ const SESSION_COLORS = [
252
+ "#60a5fa", // blue-400
253
+ "#34d399", // emerald-400
254
+ "#fbbf24", // amber-400
255
+ "#f87171", // red-400
256
+ "#a78bfa", // violet-400
257
+ "#f472b6", // pink-400
258
+ "#22d3ee", // cyan-400
259
+ "#a3e635", // lime-400
260
+ ];
261
+ /** Hash a sessionId to a stable color index. */
262
+ function sessionColor(sessionId) {
263
+ let h = 0;
264
+ for (let i = 0; i < sessionId.length; i++) {
265
+ h = (h * 31 + sessionId.charCodeAt(i)) | 0;
266
+ }
267
+ return SESSION_COLORS[Math.abs(h) % SESSION_COLORS.length];
268
+ }
269
+ /**
270
+ * Record (upsert) a session heartbeat. Called from snapshot() on every material
271
+ * change. PRIMARY KEY (pid, session_id) means concurrent pi processes each get
272
+ * their own row. Non-fatal on conflict (INSERT ... ON CONFLICT DO UPDATE).
273
+ */
274
+ export function recordSessionHeartbeat(pid, sessionId, repoRoot, stateDir, ctxWindow, indexDir = getIndexDir()) {
275
+ const db = openIndexStore(indexDir);
276
+ const now = Date.now();
277
+ db.prepare(`INSERT INTO session_heartbeats (pid, session_id, repo_root, state_dir, ctx_window, last_seen)
278
+ VALUES (@pid, @session_id, @repo_root, @state_dir, @ctx_window, @last_seen)
279
+ ON CONFLICT(pid, session_id) DO UPDATE SET
280
+ repo_root = excluded.repo_root,
281
+ state_dir = excluded.state_dir,
282
+ ctx_window = excluded.ctx_window,
283
+ last_seen = excluded.last_seen`).run({
284
+ pid,
285
+ session_id: sessionId,
286
+ repo_root: repoRoot,
287
+ state_dir: stateDir,
288
+ ctx_window: ctxWindow,
289
+ last_seen: now,
290
+ });
291
+ }
292
+ /**
293
+ * Append a token sample row + optionally a session_sample line to events.log
294
+ * (for SSE real-time push via /api/events). The eventsLogPath is optional —
295
+ * callers without an events.log (e.g. tests) can omit it.
296
+ */
297
+ export function appendTokenSample(sessionId, repoRoot, tokens, percent, ctxWindow, eventsLogPath, indexDir = getIndexDir()) {
298
+ const db = openIndexStore(indexDir);
299
+ const now = Date.now();
300
+ db.prepare(`INSERT INTO token_samples (session_id, repo_root, tokens, percent, ctx_window, ts)
301
+ VALUES (@session_id, @repo_root, @tokens, @percent, @ctx_window, @ts)`).run({
302
+ session_id: sessionId,
303
+ repo_root: repoRoot,
304
+ tokens,
305
+ percent,
306
+ ctx_window: ctxWindow,
307
+ ts: now,
308
+ });
309
+ // Step 5: also append a session_sample JSON line to events.log so the
310
+ // existing /api/events SSE tail streams it for free (real-time chart push).
311
+ // Mirrors the DashboardEmitter events.log append pattern in
312
+ // extensions/mega-dashboard.ts:{ event(type, data) }: a JSON object with
313
+ // `ts` (ISO 8601 string), `type`, and the event-specific payload. The ISO
314
+ // timestamp matches the shape of every other SSE variant (SseSessionSample
315
+ // contract; every SSE variant's `ts` is a string); a numeric ms `ts` would
316
+ // violate the contract union's "every SSE variant has a ts field of type
317
+ // string" invariant and break DashboardEmitter consumers.
318
+ if (eventsLogPath) {
319
+ try {
320
+ const dir = eventsLogPath.includes("/") ? eventsLogPath.slice(0, eventsLogPath.lastIndexOf("/")) : ".";
321
+ if (!existsSync(dir))
322
+ mkdirSync(dir, { recursive: true });
323
+ appendFileSync(eventsLogPath, JSON.stringify({
324
+ ts: new Date(now).toISOString(),
325
+ type: "session_sample",
326
+ sessionId,
327
+ tokens,
328
+ percent,
329
+ }) + "\n");
330
+ }
331
+ catch {
332
+ /* non-fatal: SSE push is best-effort */
333
+ }
334
+ }
335
+ }
336
+ /**
337
+ * Prune stale session heartbeats (sessions not seen within maxAgeMs).
338
+ * Default 30-min retention. Called by /api/sessions.
339
+ */
340
+ export function pruneStaleSessions(maxAgeMs = 1_800_000, indexDir = getIndexDir()) {
341
+ const db = openIndexStore(indexDir);
342
+ const cutoff = Date.now() - maxAgeMs;
343
+ const result = db.prepare("DELETE FROM session_heartbeats WHERE last_seen < @cutoff").run({ cutoff });
344
+ return Number(result.changes);
345
+ }
346
+ /**
347
+ * Prune old token samples older than maxAgeMs. Default 30-min retention.
348
+ * Called by /api/sessions/timeseries.
349
+ */
350
+ export function pruneTokenSamples(maxAgeMs = 1_800_000, indexDir = getIndexDir()) {
351
+ const db = openIndexStore(indexDir);
352
+ const cutoff = Date.now() - maxAgeMs;
353
+ const result = db.prepare("DELETE FROM token_samples WHERE ts < @cutoff").run({ cutoff });
354
+ return Number(result.changes);
355
+ }
356
+ /**
357
+ * Read all active sessions with their latest token sample (if any).
358
+ * JOINs session_heartbeats with the latest token_samples per session_id
359
+ * via a correlated subquery. Returns rows sorted by last_seen descending.
360
+ */
361
+ export function readActiveSessions(indexDir = getIndexDir()) {
362
+ const db = openIndexStore(indexDir);
363
+ const rows = db.prepare(`SELECT h.pid, h.session_id, h.repo_root, h.state_dir, h.ctx_window, h.last_seen,
364
+ s.tokens, s.percent
365
+ FROM session_heartbeats h
366
+ LEFT JOIN token_samples s ON s.id = (
367
+ SELECT id FROM token_samples t
368
+ WHERE t.session_id = h.session_id
369
+ ORDER BY t.ts DESC LIMIT 1
370
+ )
371
+ ORDER BY h.last_seen DESC`).all();
372
+ return rows.map((r) => ({
373
+ pid: r.pid,
374
+ sessionId: r.session_id,
375
+ repoRoot: r.repo_root,
376
+ stateDir: r.state_dir,
377
+ ctxWindow: r.ctx_window ?? 0,
378
+ lastSeen: r.last_seen,
379
+ tokens: r.tokens,
380
+ percent: r.percent,
381
+ }));
382
+ }
383
+ /**
384
+ * Read token samples since sinceMs, returning a recharts-ready stacked shape:
385
+ * per-session `SessionSeries` (with stable color) + a `totals` array
386
+ * [{ts, tokens}] (sum of all sessions at each timestamp).
387
+ */
388
+ export function readSessionTimeseries(sinceMs, indexDir = getIndexDir()) {
389
+ const db = openIndexStore(indexDir);
390
+ const rows = db.prepare(`SELECT session_id, tokens, percent, ts FROM token_samples WHERE ts >= @since ORDER BY ts ASC`).all({ since: sinceMs });
391
+ // Group by session_id → series; + accumulate totals per timestamp.
392
+ const seriesMap = new Map();
393
+ const totalsMap = new Map();
394
+ for (const r of rows) {
395
+ let pts = seriesMap.get(r.session_id);
396
+ if (!pts) {
397
+ pts = [];
398
+ seriesMap.set(r.session_id, pts);
399
+ }
400
+ pts.push({ ts: r.ts, tokens: r.tokens, percent: r.percent });
401
+ totalsMap.set(r.ts, (totalsMap.get(r.ts) ?? 0) + r.tokens);
402
+ }
403
+ const series = [];
404
+ for (const [sessionId, data] of seriesMap) {
405
+ const label = sessionId.length > 12 ? sessionId.slice(0, 12) : sessionId;
406
+ series.push({ sessionId, label, color: sessionColor(sessionId), data });
407
+ }
408
+ // Sort series by first-timestamp for stable legend order.
409
+ series.sort((a, b) => (a.data[0]?.ts ?? 0) - (b.data[0]?.ts ?? 0));
410
+ const totals = Array.from(totalsMap.entries())
411
+ .sort((a, b) => a[0] - b[0])
412
+ .map(([ts, tokens]) => ({ ts, tokens }));
413
+ return { series, totals };
414
+ }
415
+ /**
416
+ * Clear a session's heartbeat row (e.g. on clean shutdown / session reset).
417
+ * Non-fatal: no-op if the row doesn't exist.
418
+ */
419
+ export function clearSessionHeartbeat(pid, sessionId, indexDir = getIndexDir()) {
420
+ const db = openIndexStore(indexDir);
421
+ db.prepare("DELETE FROM session_heartbeats WHERE pid = @pid AND session_id = @session_id").run({ pid, session_id: sessionId });
422
+ }