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.
- package/dist/extensions/dashboard-server/api-contracts/endpoints.js +12 -0
- package/dist/extensions/dashboard-server/api-contracts.test.js +19 -6
- package/dist/extensions/dashboard-server/helpers.js +37 -0
- package/dist/extensions/dashboard-server/html/all-repos-tab.js +26 -0
- package/dist/extensions/dashboard-server/html/body-open.js +23 -0
- package/dist/extensions/dashboard-server/html/current-repo-tab.js +130 -0
- package/dist/extensions/dashboard-server/html/head-open.js +16 -0
- package/dist/extensions/dashboard-server/html/high-score-tab.js +25 -0
- package/dist/extensions/dashboard-server/html/repo-detail-modal.js +26 -0
- package/dist/extensions/dashboard-server/html/script.js +259 -0
- package/dist/extensions/dashboard-server/html/styles.js +103 -0
- package/dist/extensions/dashboard-server/html/summary-tab.js +19 -0
- package/dist/extensions/dashboard-server/html-template.js +41 -0
- package/dist/extensions/dashboard-server/server.js +83 -0
- package/dist/extensions/mega-runtime/state.js +17 -1
- package/dist/src/store/sqlite/connection.js +35 -0
- package/dist/src/store/sqlite/global-index.js +199 -1
- package/dist/src/store/sqlite/global-index.test.js +136 -0
- package/dist/src/store/sqlite/index-store.js +167 -0
- package/dist/src/store/sqlite/memory.js +54 -0
- package/dist/src/store/sqlite/minhash-lsh.js +47 -0
- package/dist/src/store/sqlite/sessions.js +39 -0
- package/dist/src/store/sqlite/transaction.js +19 -0
- package/dist/src/vectorStore/add.js +260 -0
- package/dist/src/vectorStore/dedup.js +52 -0
- package/dist/src/vectorStore/index.js +10 -0
- package/dist/src/vectorStore/queries.js +83 -0
- package/dist/src/vectorStore/search.js +95 -0
- package/dist/src/vectorStore/session.js +19 -0
- package/dist/src/vectorStore/store.js +105 -0
- package/dist/src/vectorStore/types.js +6 -0
- package/dist/src/vectorStore/utils.js +23 -0
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js → AchievementTiles-CNWZ5NjP.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js.map → AchievementTiles-CNWZ5NjP.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-DKgqx1RQ.js → AchievementsTab-BKPifNKj.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-DKgqx1RQ.js.map → AchievementsTab-BKPifNKj.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js → CacheTab--epzrQHD.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js.map → CacheTab--epzrQHD.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ConfigTab-DXD7zbqy.js → ConfigTab-D3ralwSj.js} +2 -2
- package/extensions/dashboard-client/dist/assets/ConfigTab-D3ralwSj.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/EventsTab-BKkuVQsM.js +2 -0
- package/extensions/dashboard-client/dist/assets/EventsTab-BKkuVQsM.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js → GameTab-CgUvITYy.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js.map → GameTab-CgUvITYy.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js → MetricsTab-DrlJqXGL.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js.map → MetricsTab-DrlJqXGL.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-lVGR1Jl_.js → OverviewTab-_gD756wJ.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-lVGR1Jl_.js.map → OverviewTab-_gD756wJ.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js → ReposTab-DjEZmqUb.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js.map → ReposTab-DjEZmqUb.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/SessionsTab-DsYMUzoh.js +70 -0
- package/extensions/dashboard-client/dist/assets/SessionsTab-DsYMUzoh.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js → TimeSavedCard-BbAXT1wO.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js.map → TimeSavedCard-BbAXT1wO.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{index-Cfxniu2R.css → index-BTft6DM_.css} +1 -1
- package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js → index-CnRzusfi.js} +10 -10
- package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js.map → index-CnRzusfi.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/useSSE-Bf-1rZ2N.js +2 -0
- package/extensions/dashboard-client/dist/assets/useSSE-Bf-1rZ2N.js.map +1 -0
- package/extensions/dashboard-client/dist/index.html +2 -2
- package/extensions/dashboard-client/package-lock.json +374 -2
- package/extensions/dashboard-client/package.json +2 -1
- package/extensions/dashboard-client/src/App.tsx +5 -1
- package/extensions/dashboard-client/src/api/client.ts +12 -0
- package/extensions/dashboard-client/src/components/ActiveSessionsTable.tsx +185 -0
- package/extensions/dashboard-client/src/components/EventStream.tsx +3 -0
- package/extensions/dashboard-client/src/components/SessionsMemoryChart.tsx +199 -0
- package/extensions/dashboard-client/src/main.tsx +1 -0
- package/extensions/dashboard-client/src/styles/sessions.css +193 -0
- package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +6 -3
- package/extensions/dashboard-client/src/tabs/SessionsTab.tsx +193 -0
- package/extensions/dashboard-server/api-contracts/core.ts +25 -0
- package/extensions/dashboard-server/api-contracts/endpoints.ts +102 -0
- package/extensions/dashboard-server/api-contracts/index.ts +10 -1
- package/extensions/dashboard-server/api-contracts.test.ts +20 -7
- package/extensions/dashboard-server/server.ts +100 -0
- package/extensions/mega-runtime/state.ts +31 -0
- package/package.json +1 -1
- package/src/store/sqlite/global-index.test.ts +222 -0
- package/src/store/sqlite/global-index.ts +316 -1
- package/dist/extensions/dashboard-client/src/hooks/useApi.js +0 -51
- package/dist/extensions/dashboard-client/src/hooks/useSSE.js +0 -63
- package/extensions/dashboard-client/dist/assets/ConfigTab-DXD7zbqy.js.map +0 -1
- package/extensions/dashboard-client/dist/assets/EventsTab-BGeuYvM3.js +0 -2
- 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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
1014
|
+
test("all 21 SseEvent variants have a type field matching their discriminator", () => {
|
|
1002
1015
|
assert.equal(
|
|
1003
1016
|
SSE_VARIANTS.length,
|
|
1004
|
-
|
|
1005
|
-
"SseEvent union should have
|
|
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.
|
|
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",
|