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,193 @@
1
+ /**
2
+ * dashboard-client/src/tabs/SessionsTab.tsx — Sessions tab (S39).
3
+ *
4
+ * Renders: window selector (5/15/30/60 min), sessions summary tiles,
5
+ * the stacked-memory graph (SessionsMemoryChart), and the active sessions
6
+ * table (ActiveSessionsTable).
7
+ *
8
+ * Data sources: fetchSessions() + fetchSessionTimeseries(minutes). Both poll
9
+ * every 2s for near-real-time updates. useSSE() listens for session_sample
10
+ * events emitted by appendTokenSample (Step 5) so the chart refetches
11
+ * instantly between polls when a new token sample is appended to events.log.
12
+ *
13
+ * Empty state: shows "No active sessions." when the /api/sessions response
14
+ * returns an empty array (e.g. no pi processes are running yet).
15
+ */
16
+
17
+ import type React from "react";
18
+ import { useCallback, useEffect, useMemo, useState } from "react";
19
+ import { useApi } from "../hooks/useApi";
20
+ import { useSSE } from "../hooks/useSSE";
21
+ import { fetchSessions, fetchSessionTimeseries } from "../api/client";
22
+ import type { SseEvent, SseSessionSample, SessionsResponse, SessionTimeseriesResponse } from "@contracts";
23
+ import { SessionsMemoryChart } from "../components/SessionsMemoryChart";
24
+ import { ActiveSessionsTable } from "../components/ActiveSessionsTable";
25
+
26
+ /**
27
+ * Shape of the {@link SseSessionSample} SSE event appended to events.log by
28
+ * `appendTokenSample` (src/store/sqlite/global-index.ts). The contract now
29
+ * defines this variant as part of the {@link SseEvent} union (Step 5); the
30
+ * local alias keeps the surface terser in this file.
31
+ */
32
+ type SessionSampleEvent = SseSessionSample;
33
+
34
+ /** Type guard: narrows an SSE event to a session_sample event. */
35
+ function isSessionSample(
36
+ e: SseEvent,
37
+ ): e is SessionSampleEvent {
38
+ return e.type === "session_sample";
39
+ }
40
+
41
+ const WINDOWS: ReadonlyArray<{ value: number; label: string }> = [
42
+ { value: 5, label: "5m" },
43
+ { value: 15, label: "15m" },
44
+ { value: 30, label: "30m" },
45
+ { value: 60, label: "60m" },
46
+ ] as const;
47
+
48
+ interface SummaryTilesProps {
49
+ sessions: SessionsResponse;
50
+ }
51
+
52
+ function SummaryTiles({ sessions }: SummaryTilesProps): React.ReactElement {
53
+ const active = sessions.sessions.length;
54
+ const combinedTokens = sessions.sessions.reduce(
55
+ (sum, s) => sum + (s.tokens ?? 0),
56
+ 0,
57
+ );
58
+ const maxWindow = sessions.sessions.reduce(
59
+ (max, s) => Math.max(max, s.ctxWindow),
60
+ 0,
61
+ );
62
+ const combinedPct = maxWindow > 0 ? (combinedTokens / maxWindow) * 100 : 0;
63
+ return (
64
+ <div className="sessions-summary-tiles">
65
+ <div className="tile">
66
+ <span className="tile-label">Active sessions</span>
67
+ <span className="tile-value">{active}</span>
68
+ </div>
69
+ <div className="tile">
70
+ <span className="tile-label">Combined tokens</span>
71
+ <span className="tile-value">
72
+ {combinedTokens >= 100_000
73
+ ? `${(combinedTokens / 1000).toFixed(0)}k`
74
+ : combinedTokens.toLocaleString()}
75
+ </span>
76
+ </div>
77
+ <div className="tile">
78
+ <span className="tile-label">Combined % of max window</span>
79
+ <span className="tile-value">{Math.round(combinedPct)}%</span>
80
+ </div>
81
+ <div className="tile">
82
+ <span className="tile-label">Pruned (stale)</span>
83
+ <span className="tile-value">{sessions.pruned}</span>
84
+ </div>
85
+ </div>
86
+ );
87
+ }
88
+
89
+ export default function SessionsTab(): React.ReactElement {
90
+ const [minutes, setMinutes] = useState(30);
91
+
92
+ const {
93
+ data: sessionsData,
94
+ error: sessionsErr,
95
+ refetch: refetchSessions,
96
+ } = useApi<SessionsResponse>(
97
+ useCallback(() => fetchSessions(), []),
98
+ { pollInterval: 2_000 },
99
+ );
100
+
101
+ const {
102
+ data: timeseriesData,
103
+ error: timeseriesErr,
104
+ refetch: refetchTimeseries,
105
+ } = useApi<SessionTimeseriesResponse>(
106
+ useCallback(() => fetchSessionTimeseries(minutes), [minutes]),
107
+ { pollInterval: 2_000 },
108
+ );
109
+
110
+ const { events } = useSSE();
111
+
112
+ // Filter SSE events for session_sample pushes (Step 5) and trigger an
113
+ // immediate refetch when a new sample arrives, so the chart updates
114
+ // between the 2s polls. The events array grows as new samples arrive; the
115
+ // length-based dependency means the effect re-runs when a new sample event
116
+ // lands (not on every poll). We debounce via a microtask guard.
117
+ const sampleEvents = useMemo(
118
+ () => events.filter(isSessionSample),
119
+ [events],
120
+ );
121
+
122
+ const lastSampleTs = useMemo(() => {
123
+ if (sampleEvents.length === 0) return null;
124
+ return sampleEvents[sampleEvents.length - 1].ts;
125
+ }, [sampleEvents]);
126
+
127
+ useEffect(() => {
128
+ if (lastSampleTs == null) return;
129
+ refetchSessions();
130
+ refetchTimeseries();
131
+ }, [lastSampleTs, refetchSessions, refetchTimeseries]);
132
+
133
+ if (sessionsErr && !sessionsData) {
134
+ return (
135
+ <div className="tab-stub">
136
+ Error loading sessions: {sessionsErr.message}
137
+ </div>
138
+ );
139
+ }
140
+ if (timeseriesErr && !timeseriesData) {
141
+ return (
142
+ <div className="tab-stub">
143
+ Error loading timeseries: {timeseriesErr.message}
144
+ </div>
145
+ );
146
+ }
147
+ if (!sessionsData) {
148
+ return <div className="tab-stub">Loading sessions…</div>;
149
+ }
150
+
151
+ const sessions = sessionsData.sessions;
152
+ const series = timeseriesData?.series ?? [];
153
+ const totals = timeseriesData?.totals ?? [];
154
+ const windowLabel =
155
+ WINDOWS.find((w) => w.value === minutes)?.label ?? `${minutes}m`;
156
+
157
+ return (
158
+ <div className="sessions-tab">
159
+ <div className="sessions-toolbar">
160
+ <h2>Session Memory Graph</h2>
161
+ <div className="sessions-window-select">
162
+ {WINDOWS.map((w) => (
163
+ <button
164
+ key={w.value}
165
+ type="button"
166
+ className={`sessions-window-btn ${minutes === w.value ? "active" : ""}`}
167
+ onClick={() => setMinutes(w.value)}
168
+ aria-pressed={minutes === w.value}
169
+ >
170
+ {w.label}
171
+ </button>
172
+ ))}
173
+ </div>
174
+ </div>
175
+
176
+ {sessions.length === 0 ? (
177
+ <div className="sessions-empty">No active sessions.</div>
178
+ ) : (
179
+ <>
180
+ <SummaryTiles sessions={sessionsData} />
181
+ <SessionsMemoryChart series={series} totals={totals} />
182
+ <h2 className="section-header">Active Sessions</h2>
183
+ <ActiveSessionsTable sessions={sessions} series={series} />
184
+ <div className="sessions-footer">
185
+ {series.length} series · {totals.length} timestamps ·{" "}
186
+ {windowLabel} window · updated{" "}
187
+ {timeseriesData?.updatedAt ?? sessionsData.updatedAt}
188
+ </div>
189
+ </>
190
+ )}
191
+ </div>
192
+ );
193
+ }
@@ -300,3 +300,28 @@ export interface SseCrewBanditChosen {
300
300
  /** Cumulative regret of the bandit policy (dimensionless). */
301
301
  regret: number;
302
302
  }
303
+
304
+ /**
305
+ * SSE event appened to events.log by `appendTokenSample`
306
+ * (src/store/sqlite/global-index.ts) on every material snapshot. The existing
307
+ * `GET /api/events` SSE handler tails events.log line-by-line, so these
308
+ * `session_sample` lines stream to the dashboard client for free
309
+ * (real-time chart updates between the 2s polls in the Sessions tab).
310
+ *
311
+ * Wire shape mirrors {@link SseCompactStart} etc. with `ts` (ISO 8601 string)
312
+ * plus `type` (`'session_sample'`) and the per-session payload.
313
+ *
314
+ * Served via `GET /api/events` (Server-Sent Events stream).
315
+ */
316
+ export interface SseSessionSample {
317
+ /** Discriminator. Always `'session_sample'`. */
318
+ type: 'session_sample';
319
+ /** ISO 8601 timestamp of the sample (coincides with the token_samples row `ts`). */
320
+ ts: string;
321
+ /** Session identifier the sample belongs to. */
322
+ sessionId: string;
323
+ /** Context window token count at this sample (tokens). */
324
+ tokens: number;
325
+ /** Context pressure percentage (0–100). */
326
+ percent: number;
327
+ }
@@ -336,6 +336,94 @@ export interface AchievementRow {
336
336
  readonly unlocked_at: number | null;
337
337
  }
338
338
 
339
+ // ─── Sessions Memory Graph (S39) ─────────────────────────────────────────
340
+
341
+ /**
342
+ * A single active session in the GET /api/sessions response. Represents a
343
+ * live pi process with its latest token usage and heartbeat.
344
+ */
345
+ export interface ActiveSession {
346
+ /** OS process ID of the pi process. */
347
+ readonly pid: number;
348
+ /** Session identifier (normalized). */
349
+ readonly sessionId: string;
350
+ /** Absolute path to the repo root, or null if not in a git repo. */
351
+ readonly repoRoot: string | null;
352
+ /** Display name (repo basename or state dir basename). */
353
+ readonly displayName: string;
354
+ /** Model name, or null if not captured. Available from repo_registry join. */
355
+ readonly model: string | null;
356
+ /** Latest context token count, or null if no sample yet. */
357
+ readonly tokens: number | null;
358
+ /** Latest context pressure percentage (0–100), or null. */
359
+ readonly percent: number | null;
360
+ /** Context window size in tokens. */
361
+ readonly ctxWindow: number;
362
+ /** Unix timestamp (ms) of the last heartbeat. */
363
+ readonly lastSeen: number;
364
+ /** State directory of the session. */
365
+ readonly stateDir: string | null;
366
+ }
367
+
368
+ /**
369
+ * Response for GET /api/sessions. Lists active sessions with their latest
370
+ * token usage after pruning stale entries.
371
+ */
372
+ export interface SessionsResponse {
373
+ /** ISO timestamp when the response was generated. */
374
+ readonly updatedAt: string;
375
+ /** Number of stale sessions pruned during this request. */
376
+ readonly pruned: number;
377
+ /** Array of active sessions, sorted by lastSeen descending. */
378
+ readonly sessions: ActiveSession[];
379
+ }
380
+
381
+ /** A single time-series data point for a session. */
382
+ export interface SessionDataPoint {
383
+ /** Unix timestamp (ms). */
384
+ readonly ts: number;
385
+ /** Token count at this sample. */
386
+ readonly tokens: number;
387
+ /** Context pressure percentage (0–100). */
388
+ readonly percent: number;
389
+ }
390
+
391
+ /** A recharts-ready per-session series with a stable color. */
392
+ export interface SessionSeries {
393
+ /** Session identifier. */
394
+ readonly sessionId: string;
395
+ /** Short display label for the legend. */
396
+ readonly label: string;
397
+ /** Stable hex color string (e.g. "#60a5fa"). */
398
+ readonly color: string;
399
+ /** Data points ordered by timestamp ascending. */
400
+ readonly data: SessionDataPoint[];
401
+ }
402
+
403
+ /**
404
+ * Query parameters for GET /api/sessions/timeseries. The `minutes` parameter
405
+ * controls the rolling window size.
406
+ */
407
+ export interface SessionTimeseriesQuery {
408
+ /** Rolling window size in minutes (default: 30, clamped to [1, 1440]). */
409
+ readonly minutes?: number;
410
+ }
411
+
412
+ /**
413
+ * Response for GET /api/sessions/timeseries. Returns recharts-ready stacked
414
+ * per-session series + a totals array.
415
+ */
416
+ export interface SessionTimeseriesResponse {
417
+ /** ISO timestamp when the response was generated. */
418
+ readonly updatedAt: string;
419
+ /** Rolling window size in minutes. */
420
+ readonly windowMinutes: number;
421
+ /** Per-session series with stable colors. */
422
+ readonly series: SessionSeries[];
423
+ /** Totals (sum of all sessions) per timestamp. */
424
+ readonly totals: { readonly ts: number; readonly tokens: number }[];
425
+ }
426
+
339
427
  // ─── Game State Patch (PUT request body) ────────────────────────────────────
340
428
 
341
429
  /**
@@ -473,4 +561,18 @@ export const ENDPOINTS = {
473
561
  path: '/api/achievements',
474
562
  description: 'All achievement rows with unlock state.',
475
563
  } as const satisfies EndpointDef<'GET', undefined, AchievementRow[]>,
564
+
565
+ /** GET /api/sessions — Active sessions with latest token usage (S39). */
566
+ sessions: {
567
+ method: 'GET',
568
+ path: '/api/sessions',
569
+ description: 'Active pi sessions with latest token usage + heartbeat.',
570
+ } as const satisfies EndpointDef<'GET', undefined, SessionsResponse>,
571
+
572
+ /** GET /api/sessions/timeseries — Stacked per-session token timeseries (S39). */
573
+ sessionTimeseries: {
574
+ method: 'GET',
575
+ path: '/api/sessions/timeseries',
576
+ description: 'Recharts-ready per-session token timeseries for the stacked memory graph.',
577
+ } as const satisfies EndpointDef<'GET', SessionTimeseriesQuery, SessionTimeseriesResponse>,
476
578
  } as const;
@@ -26,6 +26,7 @@ export type {
26
26
  SseCrewPresenceChanged,
27
27
  SseCrewTurnChanged,
28
28
  SseCrewBanditChosen,
29
+ SseSessionSample,
29
30
  } from './core.js';
30
31
 
31
32
  // Snapshot / store / compression / session
@@ -103,6 +104,7 @@ import type {
103
104
  SseCrewPresenceChanged,
104
105
  SseCrewTurnChanged,
105
106
  SseCrewBanditChosen,
107
+ SseSessionSample,
106
108
  } from './core.js';
107
109
 
108
110
  // Endpoints registry (Sprint A1)
@@ -130,6 +132,12 @@ export type {
130
132
  GameScoresQuery,
131
133
  AchievementRow,
132
134
  GameStatePatch,
135
+ ActiveSession,
136
+ SessionsResponse,
137
+ SessionDataPoint,
138
+ SessionSeries,
139
+ SessionTimeseriesQuery,
140
+ SessionTimeseriesResponse,
133
141
  SseEndpointDef,
134
142
  } from './endpoints.js';
135
143
 
@@ -156,4 +164,5 @@ export type SseEvent =
156
164
  | SseGameRitualStage
157
165
  | SseGameRitualEnd
158
166
  | SseGameModeChanged
159
- | SseGameRender;
167
+ | SseGameRender
168
+ | SseSessionSample;
@@ -158,10 +158,11 @@ void _c_perf;
158
158
  void _c_achievements;
159
159
 
160
160
  // ─── Compile-Time: ENDPOINTS path/method consistency ─────────────────────────
161
- // Verify the ENDPOINTS registry has exactly 13 entries with correct paths.
161
+ // Verify the ENDPOINTS registry has exactly 15 entries with correct paths.
162
+ // (Sprint 39 added /api/sessions + /api/sessions/timeseries.)
162
163
 
163
164
  const ENDPOINT_KEYS = Object.keys(ENDPOINTS) as (keyof typeof ENDPOINTS)[];
164
- const EXPECTED_ENDPOINT_COUNT = 13;
165
+ const EXPECTED_ENDPOINT_COUNT = 15;
165
166
 
166
167
  /** All `/api/*` paths served by server.ts (extracted from the route handlers). */
167
168
  const SERVER_TS_PATHS: string[] = [
@@ -177,12 +178,14 @@ const SERVER_TS_PATHS: string[] = [
177
178
  "/api/game-scores",
178
179
  "/api/perf",
179
180
  "/api/achievements",
181
+ "/api/sessions",
182
+ "/api/sessions/timeseries",
180
183
  ];
181
184
 
182
185
  // ─── Tests ───────────────────────────────────────────────────────────────────
183
186
 
184
187
  describe("ENDPOINTS registry", () => {
185
- test("has exactly 13 endpoint entries", () => {
188
+ test("has exactly 15 endpoint entries", () => {
186
189
  assert.equal(
187
190
  ENDPOINT_KEYS.length,
188
191
  EXPECTED_ENDPOINT_COUNT,
@@ -803,7 +806,7 @@ describe("GET /api/achievements", () => {
803
806
  // matching its discriminator.
804
807
 
805
808
  describe("SSE /api/events — SseEvent union discriminator validation", () => {
806
- /** All 20 SseEvent variants with their discriminator `type` value and a minimal payload. */
809
+ /** All 21 SseEvent variants with their discriminator `type` value and a minimal payload. */
807
810
  const SSE_VARIANTS: ReadonlyArray<{
808
811
  discriminator: string;
809
812
  payload: string;
@@ -996,13 +999,23 @@ describe("SSE /api/events — SseEvent union discriminator validation", () => {
996
999
  frame: "\x1b[2J\x1b[H",
997
1000
  }),
998
1001
  },
1002
+ {
1003
+ discriminator: "session_sample",
1004
+ payload: JSON.stringify({
1005
+ type: "session_sample",
1006
+ ts: "2025-01-01T00:00:00Z",
1007
+ sessionId: "sess-abc",
1008
+ tokens: 42_000,
1009
+ percent: 35.5,
1010
+ }),
1011
+ },
999
1012
  ];
1000
1013
 
1001
- test("all 20 SseEvent variants have a type field matching their discriminator", () => {
1014
+ test("all 21 SseEvent variants have a type field matching their discriminator", () => {
1002
1015
  assert.equal(
1003
1016
  SSE_VARIANTS.length,
1004
- 20,
1005
- "SseEvent union should have 20 variants",
1017
+ 21,
1018
+ "SseEvent union should have 21 variants",
1006
1019
  );
1007
1020
 
1008
1021
  for (const variant of SSE_VARIANTS) {
@@ -770,6 +770,106 @@ export async function launchDashboardServer(
770
770
  return;
771
771
  }
772
772
 
773
+ // /api/sessions — S39: active pi sessions with latest token usage + heartbeat.
774
+ // GET returns {updatedAt, pruned, sessions[]} after pruning stale heartbeats.
775
+ // The dashboard server is a detached child with no MegaRuntime ref, so it
776
+ // reads session_heartbeats via a require()'d sqlite helper (same pattern as
777
+ // /api/achievements, /api/perf). Non-GET -> 405. PREVENT-PI-004: loopback.
778
+ if (req.url?.startsWith("/api/sessions")) {
779
+ // Guard: /api/sessions/timeseries handled separately below.
780
+ if (req.url.startsWith("/api/sessions/timeseries")) {
781
+ // Fall through to the timeseries handler below.
782
+ } else {
783
+ const sReq = createRequire(import.meta.url);
784
+ const {
785
+ readActiveSessions,
786
+ pruneStaleSessions,
787
+ listRepoRegistry,
788
+ } = sReq(
789
+ "../../src/store/sqlite.js",
790
+ ) as typeof import("../../src/store/sqlite.js");
791
+ if (req.method !== "GET") {
792
+ res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
793
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
794
+ return;
795
+ }
796
+ try {
797
+ const pruned = pruneStaleSessions(); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
798
+ const active = readActiveSessions();
799
+ const repos = listRepoRegistry();
800
+ const repoMap = new Map(repos.map((r) => [r.repoRoot, r]));
801
+ const sessions = active.map((s) => ({
802
+ pid: s.pid,
803
+ sessionId: s.sessionId,
804
+ repoRoot: s.repoRoot,
805
+ displayName: s.repoRoot
806
+ ? (s.repoRoot.split(/[\\/]/).filter(Boolean).pop() ?? s.repoRoot)
807
+ : (s.stateDir?.split(/[\\/]/).filter(Boolean).pop() ?? "unknown"),
808
+ model: s.repoRoot ? (repoMap.get(s.repoRoot)?.modelName ?? null) : null,
809
+ tokens: s.tokens,
810
+ percent: s.percent,
811
+ ctxWindow: s.ctxWindow,
812
+ lastSeen: s.lastSeen,
813
+ stateDir: s.stateDir,
814
+ }));
815
+ res.writeHead(200, { "Content-Type": "application/json" });
816
+ res.end(
817
+ JSON.stringify({ updatedAt: new Date().toISOString(), pruned, sessions }),
818
+ );
819
+ } catch (e) {
820
+ res.writeHead(500, { "Content-Type": "application/json" });
821
+ res.end(
822
+ JSON.stringify({ error: "sessions_unavailable", detail: String(e) }),
823
+ );
824
+ }
825
+ return;
826
+ }
827
+ }
828
+
829
+ // /api/sessions/timeseries — S39: stacked per-session token timeseries for
830
+ // the recharts memory graph. GET ?minutes=N (clamped [1,1440]) returns
831
+ // {updatedAt, windowMinutes, series[], totals[]} in recharts-ready shape.
832
+ // Non-GET -> 405. PREVENT-PI-004: loopback.
833
+ if (req.url?.startsWith("/api/sessions/timeseries")) {
834
+ const tsReq = createRequire(import.meta.url);
835
+ const {
836
+ readSessionTimeseries,
837
+ pruneTokenSamples,
838
+ } = tsReq(
839
+ "../../src/store/sqlite.js",
840
+ ) as typeof import("../../src/store/sqlite.js");
841
+ if (req.method !== "GET") {
842
+ res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
843
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
844
+ return;
845
+ }
846
+ try {
847
+ const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
848
+ let minutes = Number(url.searchParams.get("minutes") ?? "30");
849
+ if (!Number.isFinite(minutes) || minutes <= 0) minutes = 30;
850
+ minutes = Math.min(Math.max(minutes, 1), 1440);
851
+ const sinceTs = Date.now() - minutes * 60_000;
852
+ const pruneMs = Math.max(minutes * 60_000, 1_800_000);
853
+ pruneTokenSamples(pruneMs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
854
+ const result = readSessionTimeseries(sinceTs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
855
+ res.writeHead(200, { "Content-Type": "application/json" });
856
+ res.end(
857
+ JSON.stringify({
858
+ updatedAt: new Date().toISOString(),
859
+ windowMinutes: minutes,
860
+ series: result.series,
861
+ totals: result.totals,
862
+ }),
863
+ );
864
+ } catch (e) {
865
+ res.writeHead(500, { "Content-Type": "application/json" });
866
+ res.end(
867
+ JSON.stringify({ error: "timeseries_unavailable", detail: String(e) }),
868
+ );
869
+ }
870
+ return;
871
+ }
872
+
773
873
  // Fallback — serve the React client build (SPA route) or legacy dashboard.
774
874
  // Non-/api/* GETs hit here: serve client assets if built, else inline HTML.
775
875
  if (
@@ -29,6 +29,8 @@ import {
29
29
  getCacheHitTokensSaved,
30
30
  getGameState,
31
31
  recordPerfSample,
32
+ recordSessionHeartbeat,
33
+ appendTokenSample,
32
34
  type ModelSnapshot,
33
35
  type GameState,
34
36
  } from "../../src/store/sqlite.js";
@@ -575,6 +577,35 @@ export class MegaRuntime {
575
577
  } as DashboardSnapshot);
576
578
  const perfDiskMs = this.dashboard.lastWriteMs;
577
579
 
580
+ // S39: record a session heartbeat + token sample into the shared
581
+ // machine-wide index.sqlite so the dashboard can show a real-time
582
+ // stacked-memory graph across all active pi processes. Behind the
583
+ // material-change gate (this code only runs when sig changed). Non-fatal
584
+ // try/catch mirrors the recordPerfSample pattern below. Skip the token
585
+ // sample when lastCtxTokens is null (no context data yet).
586
+ try {
587
+ const repo = resolveRepoRoot(ctx?.cwd ?? this.currentStateDir) ?? this.currentStateDir;
588
+ recordSessionHeartbeat(
589
+ process.pid,
590
+ this.rt.sessionId,
591
+ repo,
592
+ this.currentStateDir,
593
+ this.lastCtxWindow || 0,
594
+ );
595
+ if (this.lastCtxTokens != null) {
596
+ appendTokenSample(
597
+ this.rt.sessionId,
598
+ repo,
599
+ this.lastCtxTokens,
600
+ this.lastCtxPercent ?? 0,
601
+ this.lastCtxWindow || 0,
602
+ join(this.currentStateDir, "events.log"),
603
+ );
604
+ }
605
+ } catch {
606
+ /* non-fatal: S39 monitoring must never block the snapshot path */
607
+ }
608
+
578
609
  // Live stats widget above the editor
579
610
  if (ctx) {
580
611
  // ── gather widget data (computed per snapshot, rendered per frame) ────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.8.17",
3
+ "version": "0.8.18",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-2-Clause",