pi-mega-compact 0.8.17 → 0.8.19
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-events/context-handler.js +22 -8
- 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-DVgFowc-.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js.map → AchievementTiles-DVgFowc-.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/AchievementsTab-Bg86TWPU.js +2 -0
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-DKgqx1RQ.js.map → AchievementsTab-Bg86TWPU.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js → CacheTab-DBzoH_q_.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js.map → CacheTab-DBzoH_q_.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ConfigTab-DXD7zbqy.js → ConfigTab-Bj12LgTh.js} +2 -2
- package/extensions/dashboard-client/dist/assets/ConfigTab-Bj12LgTh.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/EventsTab-EC0wMsp_.js +2 -0
- package/extensions/dashboard-client/dist/assets/EventsTab-EC0wMsp_.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js → GameTab-CISQitbu.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js.map → GameTab-CISQitbu.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js → MetricsTab-us4vVG5D.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js.map → MetricsTab-us4vVG5D.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/OverviewTab-eJ7vFgBE.js +2 -0
- package/extensions/dashboard-client/dist/assets/OverviewTab-eJ7vFgBE.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js → ReposTab-CiU9AJkb.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js.map → ReposTab-CiU9AJkb.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/SessionsTab-DaCUvTC7.js +70 -0
- package/extensions/dashboard-client/dist/assets/SessionsTab-DaCUvTC7.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js → TimeSavedCard-BAUSCzq5.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js.map → TimeSavedCard-BAUSCzq5.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js → index-5MPZyayK.js} +10 -10
- package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js.map → index-5MPZyayK.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{index-Cfxniu2R.css → index-CDkmYP9l.css} +1 -1
- package/extensions/dashboard-client/dist/assets/useSSE-BSpmWZZi.js +2 -0
- package/extensions/dashboard-client/dist/assets/useSSE-BSpmWZZi.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/RepoContextStack.tsx +238 -0
- package/extensions/dashboard-client/src/components/SessionsMemoryChart.tsx +199 -0
- package/extensions/dashboard-client/src/main.tsx +2 -0
- package/extensions/dashboard-client/src/styles/repostack.css +87 -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/OverviewTab.tsx +49 -1
- 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-events/context-handler.ts +40 -18
- 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/AchievementsTab-DKgqx1RQ.js +0 -2
- 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
- package/extensions/dashboard-client/dist/assets/OverviewTab-lVGR1Jl_.js +0 -2
- package/extensions/dashboard-client/dist/assets/OverviewTab-lVGR1Jl_.js.map +0 -1
|
@@ -22,6 +22,7 @@ const MetricsTab = React.lazy(() => import("./tabs/MetricsTab"));
|
|
|
22
22
|
const CacheTab = React.lazy(() => import("./tabs/CacheTab"));
|
|
23
23
|
const GameTab = React.lazy(() => import("./tabs/GameTab"));
|
|
24
24
|
const AchievementsTab = React.lazy(() => import("./tabs/AchievementsTab"));
|
|
25
|
+
const SessionsTab = React.lazy(() => import("./tabs/SessionsTab"));
|
|
25
26
|
|
|
26
27
|
export type TabId =
|
|
27
28
|
| "overview"
|
|
@@ -31,7 +32,8 @@ export type TabId =
|
|
|
31
32
|
| "metrics"
|
|
32
33
|
| "cache"
|
|
33
34
|
| "game"
|
|
34
|
-
| "achievements"
|
|
35
|
+
| "achievements"
|
|
36
|
+
| "sessions";
|
|
35
37
|
|
|
36
38
|
const TABS: Array<{ id: TabId; label: string }> = [
|
|
37
39
|
{ id: "overview", label: "Overview" },
|
|
@@ -42,6 +44,7 @@ const TABS: Array<{ id: TabId; label: string }> = [
|
|
|
42
44
|
{ id: "cache", label: "Cache" },
|
|
43
45
|
{ id: "game", label: "Game" },
|
|
44
46
|
{ id: "achievements", label: "Achievements" },
|
|
47
|
+
{ id: "sessions", label: "Sessions" },
|
|
45
48
|
];
|
|
46
49
|
|
|
47
50
|
export default function App(): React.ReactElement {
|
|
@@ -88,6 +91,7 @@ export default function App(): React.ReactElement {
|
|
|
88
91
|
{activeTab === "cache" && <CacheTab />}
|
|
89
92
|
{activeTab === "game" && <GameTab />}
|
|
90
93
|
{activeTab === "achievements" && <AchievementsTab />}
|
|
94
|
+
{activeTab === "sessions" && <SessionsTab />}
|
|
91
95
|
</React.Suspense>
|
|
92
96
|
</main>
|
|
93
97
|
</div>
|
|
@@ -26,6 +26,8 @@ import type {
|
|
|
26
26
|
PerfResponse,
|
|
27
27
|
PerfQuery,
|
|
28
28
|
AchievementRow,
|
|
29
|
+
SessionsResponse,
|
|
30
|
+
SessionTimeseriesResponse,
|
|
29
31
|
} from "@contracts";
|
|
30
32
|
|
|
31
33
|
/** Error thrown when a dashboard API response is not 2xx. */
|
|
@@ -143,3 +145,13 @@ export function fetchPerf(params: PerfQuery = {}): Promise<PerfResponse> {
|
|
|
143
145
|
export function fetchAchievements(): Promise<AchievementRow[]> {
|
|
144
146
|
return getJson<AchievementRow[]>(ENDPOINTS.achievements.path);
|
|
145
147
|
}
|
|
148
|
+
|
|
149
|
+
export function fetchSessions(): Promise<SessionsResponse> {
|
|
150
|
+
return getJson<SessionsResponse>(ENDPOINTS.sessions.path);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function fetchSessionTimeseries(minutes: number): Promise<SessionTimeseriesResponse> {
|
|
154
|
+
return getJson<SessionTimeseriesResponse>(
|
|
155
|
+
`${ENDPOINTS.sessionTimeseries.path}${query({ minutes })}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dashboard-client/src/components/ActiveSessionsTable.tsx — Active sessions table (S39).
|
|
3
|
+
*
|
|
4
|
+
* Per-session rows: PID, Repo, Model, Context %, Tokens/Window,
|
|
5
|
+
* Heartbeat age, State dir, Sparkline.
|
|
6
|
+
*
|
|
7
|
+
* Data from /api/sessions (active sessions list) and /api/sessions/timeseries
|
|
8
|
+
* (per-session data points used to render inline sparklines).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type React from "react";
|
|
12
|
+
import type { ActiveSession, SessionSeries } from "@contracts";
|
|
13
|
+
|
|
14
|
+
export interface ActiveSessionsTableProps {
|
|
15
|
+
/** Active sessions, sorted by lastSeen descending (from /api/sessions). */
|
|
16
|
+
sessions: ActiveSession[];
|
|
17
|
+
/** Per-session timeseries used to render the inline sparkline column. */
|
|
18
|
+
series: SessionSeries[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const HEADERS = [
|
|
22
|
+
"PID",
|
|
23
|
+
"Repo",
|
|
24
|
+
"Model",
|
|
25
|
+
"Context %",
|
|
26
|
+
"Tokens / Window",
|
|
27
|
+
"Last Heartbeat",
|
|
28
|
+
"State Dir",
|
|
29
|
+
"Trend",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
/** Format a duration in milliseconds → human-readable age string. */
|
|
33
|
+
function fmtAge(ms: number): string {
|
|
34
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
35
|
+
if (s < 60) return `${s}s ago`;
|
|
36
|
+
const m = Math.floor(s / 60);
|
|
37
|
+
if (m < 60) return `${m}m ${s % 60}s ago`;
|
|
38
|
+
const h = Math.floor(m / 60);
|
|
39
|
+
return `${h}h ${m % 60}m ago`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fmtTokens(n: number | null): string {
|
|
43
|
+
if (n == null) return "\u2014";
|
|
44
|
+
if (n >= 100_000) return `${(n / 1000).toFixed(0)}k`;
|
|
45
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
46
|
+
return n.toLocaleString();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pctClass(pct: number | null): string {
|
|
50
|
+
if (pct == null) return "sessions-pct-ok";
|
|
51
|
+
if (pct >= 85) return "sessions-pct-err";
|
|
52
|
+
if (pct >= 60) return "sessions-pct-warn";
|
|
53
|
+
return "sessions-pct-ok";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fmtPct(pct: number | null): string {
|
|
57
|
+
if (pct == null) return "\u2014";
|
|
58
|
+
return `${Math.round(pct)}%`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fmtWindow(tokens: number | null, window: number): string {
|
|
62
|
+
if (tokens == null) return "\u2014";
|
|
63
|
+
return `${fmtTokens(tokens)} / ${fmtTokens(window)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface SparklineProps {
|
|
67
|
+
/** Token counts over time (oldest → newest). */
|
|
68
|
+
points: number[];
|
|
69
|
+
/** Stable hex color for the stroke. */
|
|
70
|
+
color: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function Sparkline({ points, color }: SparklineProps): React.ReactElement {
|
|
74
|
+
if (points.length < 2) {
|
|
75
|
+
return <span className="sessions-sparkline-empty">{"\u2014"}</span>;
|
|
76
|
+
}
|
|
77
|
+
const min = Math.min(...points);
|
|
78
|
+
const max = Math.max(...points);
|
|
79
|
+
const range = max - min || 1;
|
|
80
|
+
const w = 64;
|
|
81
|
+
const h = 18;
|
|
82
|
+
const stepX = w / (points.length - 1);
|
|
83
|
+
const segments = points.map((p, i) => {
|
|
84
|
+
const x = i * stepX;
|
|
85
|
+
const y = h - ((p - min) / range) * h;
|
|
86
|
+
return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
|
|
87
|
+
});
|
|
88
|
+
return (
|
|
89
|
+
<svg
|
|
90
|
+
className="sessions-sparkline"
|
|
91
|
+
width={w}
|
|
92
|
+
height={h}
|
|
93
|
+
viewBox={`0 0 ${w} ${h}`}
|
|
94
|
+
role="img"
|
|
95
|
+
aria-label="token trend"
|
|
96
|
+
>
|
|
97
|
+
<path
|
|
98
|
+
d={segments.join(" ")}
|
|
99
|
+
fill="none"
|
|
100
|
+
stroke={color}
|
|
101
|
+
strokeWidth="1.5"
|
|
102
|
+
strokeLinejoin="round"
|
|
103
|
+
strokeLinecap="round"
|
|
104
|
+
/>
|
|
105
|
+
</svg>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Map sessionId → series for the sparkline column lookup. */
|
|
110
|
+
function indexSeries(
|
|
111
|
+
series: SessionSeries[],
|
|
112
|
+
): Map<string, SessionSeries> {
|
|
113
|
+
const m = new Map<string, SessionSeries>();
|
|
114
|
+
for (const s of series) m.set(s.sessionId, s);
|
|
115
|
+
return m;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function ActiveSessionsTable({
|
|
119
|
+
sessions,
|
|
120
|
+
series,
|
|
121
|
+
}: ActiveSessionsTableProps): React.ReactElement {
|
|
122
|
+
const seriesById = indexSeries(series);
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className="sessions-table-scroll">
|
|
127
|
+
<table className="sessions-table">
|
|
128
|
+
<thead>
|
|
129
|
+
<tr>
|
|
130
|
+
{HEADERS.map((h, i) => (
|
|
131
|
+
<th
|
|
132
|
+
key={h}
|
|
133
|
+
className={i >= 3 && i !== 6 ? "num" : undefined}
|
|
134
|
+
>
|
|
135
|
+
{h}
|
|
136
|
+
</th>
|
|
137
|
+
))}
|
|
138
|
+
</tr>
|
|
139
|
+
</thead>
|
|
140
|
+
<tbody>
|
|
141
|
+
{sessions.length === 0 && (
|
|
142
|
+
<tr className="sessions-empty-row">
|
|
143
|
+
<td colSpan={HEADERS.length}>No active sessions.</td>
|
|
144
|
+
</tr>
|
|
145
|
+
)}
|
|
146
|
+
{sessions.map((s) => {
|
|
147
|
+
const srs = seriesById.get(s.sessionId);
|
|
148
|
+
const sparkPts =
|
|
149
|
+
srs?.data.map((d) => d.tokens) ?? [];
|
|
150
|
+
const color = srs?.color ?? "#58a6ff";
|
|
151
|
+
return (
|
|
152
|
+
<tr key={`${s.pid}:${s.sessionId}`}>
|
|
153
|
+
<td className="num">{s.pid}</td>
|
|
154
|
+
<td title={s.repoRoot ?? undefined}>
|
|
155
|
+
{s.displayName || s.repoRoot || "\u2014"}
|
|
156
|
+
</td>
|
|
157
|
+
<td>{s.model ?? "\u2014"}</td>
|
|
158
|
+
<td className="num">
|
|
159
|
+
<span className={`sessions-pct-pill ${pctClass(s.percent)}`}>
|
|
160
|
+
{fmtPct(s.percent)}
|
|
161
|
+
</span>
|
|
162
|
+
</td>
|
|
163
|
+
<td className="num">
|
|
164
|
+
{fmtWindow(s.tokens, s.ctxWindow)}
|
|
165
|
+
</td>
|
|
166
|
+
<td className="num">{fmtAge(now - s.lastSeen)}</td>
|
|
167
|
+
<td
|
|
168
|
+
title={s.stateDir ?? undefined}
|
|
169
|
+
className="sessions-meta"
|
|
170
|
+
>
|
|
171
|
+
{s.stateDir
|
|
172
|
+
? s.stateDir.split("/").pop() || s.stateDir
|
|
173
|
+
: "\u2014"}
|
|
174
|
+
</td>
|
|
175
|
+
<td>
|
|
176
|
+
<Sparkline points={sparkPts} color={color} />
|
|
177
|
+
</td>
|
|
178
|
+
</tr>
|
|
179
|
+
);
|
|
180
|
+
})}
|
|
181
|
+
</tbody>
|
|
182
|
+
</table>
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -30,6 +30,7 @@ const TYPE_COLORS: Record<string, string> = {
|
|
|
30
30
|
crew_presence_changed: "ev-crew",
|
|
31
31
|
crew_turn_changed: "ev-crew",
|
|
32
32
|
crew_bandit_chosen: "ev-crew",
|
|
33
|
+
session_sample: "ev-recall",
|
|
33
34
|
game_ritual_start: "ev-game",
|
|
34
35
|
game_ritual_stage: "ev-game",
|
|
35
36
|
game_ritual_end: "ev-game",
|
|
@@ -81,6 +82,8 @@ function summarize(ev: SseEvent): string {
|
|
|
81
82
|
return `turn=${ev.turnIndex} agent=${ev.agentName}`;
|
|
82
83
|
case "crew_bandit_chosen":
|
|
83
84
|
return `agent=${ev.chosenAgent} score=${ev.score} regret=${ev.regret}`;
|
|
85
|
+
case "session_sample":
|
|
86
|
+
return `sess=${ev.sessionId.slice(0, 8)} tok=${ev.tokens.toLocaleString()} ctx=${Math.round(ev.percent)}%`;
|
|
84
87
|
case "game_ritual_start":
|
|
85
88
|
case "game_ritual_stage":
|
|
86
89
|
case "game_ritual_end":
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dashboard-client/src/components/RepoContextStack.tsx — Stacked per-repo context bar (S40).
|
|
3
|
+
*
|
|
4
|
+
* Horizontal stacked bar: one colored segment per active repo, aggregating
|
|
5
|
+
* that repo's active sessions' token counts. Renders a combined total line
|
|
6
|
+
* and a legend mapping each repo (basename of repoRoot) to its segment color
|
|
7
|
+
* + token count + % of total.
|
|
8
|
+
*
|
|
9
|
+
* Stable per-repo colors: the repoRoot basename is hashed to a deterministic
|
|
10
|
+
* index into a fixed palette array, so the same repo always gets the same
|
|
11
|
+
* color across renders/tabs (mirrors SessionsMemoryChart's stable-color
|
|
12
|
+
* contract from S39).
|
|
13
|
+
*
|
|
14
|
+
* PREVENT-PI-004: this is a pure presentational component — no fetch, no
|
|
15
|
+
* network. Data is passed in by the parent (OverviewTab) via
|
|
16
|
+
* useApi(fetchSessions) + useSSE() for real-time updates.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type React from "react";
|
|
20
|
+
import type { ActiveSession, SessionsResponse } from "@contracts";
|
|
21
|
+
|
|
22
|
+
export interface RepoContextStackProps {
|
|
23
|
+
/** Active sessions from /api/sessions (may be null while loading). */
|
|
24
|
+
sessions: SessionsResponse | null;
|
|
25
|
+
/** Whether the initial fetch is in flight. */
|
|
26
|
+
loading: boolean;
|
|
27
|
+
/** Fetch error, if any. */
|
|
28
|
+
error: Error | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Stable color palette ─────────────────────────────────────────────────────
|
|
32
|
+
// Fixed set of hex colors used for stable per-repo coloring. The hash function
|
|
33
|
+
// maps a repoRoot basename to a deterministic palette index so the same repo
|
|
34
|
+
// always renders the same color.
|
|
35
|
+
const REPO_PALETTE: ReadonlyArray<string> = [
|
|
36
|
+
"#58a6ff", // blue
|
|
37
|
+
"#3fb950", // green
|
|
38
|
+
"#d29922", // yellow
|
|
39
|
+
"#f85149", // red
|
|
40
|
+
"#bc8cff", // purple
|
|
41
|
+
"#ff7b72", // salmon
|
|
42
|
+
"#79c0ff", // light blue
|
|
43
|
+
"#56d4dd", // cyan
|
|
44
|
+
"#e3b341", // gold
|
|
45
|
+
"#a5d6ff", // pale blue
|
|
46
|
+
"#7ee787", // mint
|
|
47
|
+
"#ffa657", // orange
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Simple deterministic string hash (djb2 variant). Returns a non-negative
|
|
52
|
+
* 32-bit integer.
|
|
53
|
+
*/
|
|
54
|
+
function hashString(str: string): number {
|
|
55
|
+
let hash = 5381;
|
|
56
|
+
for (let i = 0; i < str.length; i++) {
|
|
57
|
+
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
|
|
58
|
+
}
|
|
59
|
+
return hash;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pick a stable color for a repoRoot basename. Falls back to "(unknown)" if
|
|
64
|
+
* the basename is empty.
|
|
65
|
+
*/
|
|
66
|
+
function colorForRepo(basename: string): string {
|
|
67
|
+
if (!basename) return REPO_PALETTE[0];
|
|
68
|
+
return REPO_PALETTE[hashString(basename) % REPO_PALETTE.length];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extract the basename from a repoRoot path. Uses `"/"` as the universal
|
|
73
|
+
* separator on all OSes (repoRoot is an absolute path from the dashboard
|
|
74
|
+
* server; on Windows pi actually uses forward-slash normalized paths anyway).
|
|
75
|
+
*/
|
|
76
|
+
function repoBasename(repoRoot: string | null): string {
|
|
77
|
+
if (!repoRoot) return "(unknown)";
|
|
78
|
+
const trimmed = repoRoot.replace(/[\\/]+$/, "");
|
|
79
|
+
if (!trimmed) return "(unknown)";
|
|
80
|
+
const parts = trimmed.split(/[\\/]/);
|
|
81
|
+
const last = parts[parts.length - 1];
|
|
82
|
+
return last || trimmed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Aggregation ─────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
interface RepoAggregate {
|
|
88
|
+
repoRoot: string | null;
|
|
89
|
+
basename: string;
|
|
90
|
+
tokens: number;
|
|
91
|
+
color: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function aggregateRepos(sessions: ReadonlyArray<ActiveSession>): {
|
|
95
|
+
repos: RepoAggregate[];
|
|
96
|
+
totalTokens: number;
|
|
97
|
+
maxWindow: number;
|
|
98
|
+
sessionCount: number;
|
|
99
|
+
} {
|
|
100
|
+
const map = new Map<string, RepoAggregate>();
|
|
101
|
+
for (const s of sessions) {
|
|
102
|
+
const key = s.repoRoot ?? "__null__";
|
|
103
|
+
const tokens = s.tokens ?? 0;
|
|
104
|
+
const existing = map.get(key);
|
|
105
|
+
if (existing) {
|
|
106
|
+
existing.tokens += tokens;
|
|
107
|
+
} else {
|
|
108
|
+
const basename = repoBasename(s.repoRoot);
|
|
109
|
+
map.set(key, {
|
|
110
|
+
repoRoot: s.repoRoot,
|
|
111
|
+
basename,
|
|
112
|
+
tokens,
|
|
113
|
+
color: colorForRepo(basename),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const repos = [...map.values()];
|
|
118
|
+
const totalTokens = repos.reduce((sum, r) => sum + r.tokens, 0);
|
|
119
|
+
// Combined max window: the largest ctxWindow among the active sessions.
|
|
120
|
+
// This mirrors SessionsTab's SummaryTiles combined-% logic.
|
|
121
|
+
const maxWindow = sessions.reduce(
|
|
122
|
+
(max, s) => Math.max(max, s.ctxWindow),
|
|
123
|
+
0,
|
|
124
|
+
);
|
|
125
|
+
return {
|
|
126
|
+
repos,
|
|
127
|
+
totalTokens,
|
|
128
|
+
maxWindow,
|
|
129
|
+
sessionCount: sessions.length,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function fmtTokens(n: number): string {
|
|
134
|
+
if (n >= 100_000) return `${(n / 1000).toFixed(0)}k`;
|
|
135
|
+
return n.toLocaleString();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function RepoContextStack({
|
|
139
|
+
sessions,
|
|
140
|
+
loading,
|
|
141
|
+
error,
|
|
142
|
+
}: RepoContextStackProps): React.ReactElement | null {
|
|
143
|
+
if (error) {
|
|
144
|
+
return (
|
|
145
|
+
<div className="card repo-stack-card">
|
|
146
|
+
<h3>Context per Repo (live)</h3>
|
|
147
|
+
<div className="sessions-empty">
|
|
148
|
+
Error loading sessions: {error.message}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (loading && !sessions) {
|
|
155
|
+
return (
|
|
156
|
+
<div className="card repo-stack-card">
|
|
157
|
+
<h3>Context per Repo (live)</h3>
|
|
158
|
+
<div className="sessions-empty">Loading sessions…</div>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const activeSessions = sessions?.sessions ?? [];
|
|
164
|
+
const { repos, totalTokens, maxWindow, sessionCount } =
|
|
165
|
+
aggregateRepos(activeSessions);
|
|
166
|
+
|
|
167
|
+
if (repos.length === 0) {
|
|
168
|
+
return (
|
|
169
|
+
<div className="card repo-stack-card">
|
|
170
|
+
<h3>Context per Repo (live)</h3>
|
|
171
|
+
<div className="sessions-empty">No active sessions.</div>
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const combinedPct =
|
|
177
|
+
maxWindow > 0 ? Math.round((totalTokens / maxWindow) * 100) : 0;
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div className="card repo-stack-card">
|
|
181
|
+
<h3>Context per Repo (live)</h3>
|
|
182
|
+
<div
|
|
183
|
+
className="repo-stack-bar"
|
|
184
|
+
role="group"
|
|
185
|
+
aria-label="Per-repo context usage"
|
|
186
|
+
>
|
|
187
|
+
{repos.map((r) => {
|
|
188
|
+
const pctOfTotal =
|
|
189
|
+
totalTokens > 0 ? (r.tokens / totalTokens) * 100 : 0;
|
|
190
|
+
const widthPct = totalTokens > 0 ? pctOfTotal : 0;
|
|
191
|
+
const tooltip =
|
|
192
|
+
`${r.basename}: ${fmtTokens(r.tokens)} tokens ` +
|
|
193
|
+
`(${pctOfTotal.toFixed(1)}% of total)`;
|
|
194
|
+
return (
|
|
195
|
+
<div
|
|
196
|
+
key={r.repoRoot ?? "__null__"}
|
|
197
|
+
className="repo-stack-segment"
|
|
198
|
+
style={{
|
|
199
|
+
width: `${Math.max(widthPct, 0.5)}%`,
|
|
200
|
+
backgroundColor: r.color,
|
|
201
|
+
}}
|
|
202
|
+
title={tooltip}
|
|
203
|
+
aria-label={tooltip}
|
|
204
|
+
/>
|
|
205
|
+
);
|
|
206
|
+
})}
|
|
207
|
+
</div>
|
|
208
|
+
<p className="repo-stack-total">
|
|
209
|
+
{fmtTokens(totalTokens)} tokens across {repos.length}{" "}
|
|
210
|
+
{repos.length === 1 ? "repo" : "repos"} /{" "}
|
|
211
|
+
{sessionCount}{" "}
|
|
212
|
+
{sessionCount === 1 ? "session" : "sessions"}{" "}
|
|
213
|
+
<span className="pct">— {combinedPct}% of combined max window</span>
|
|
214
|
+
</p>
|
|
215
|
+
<div className="repo-stack-legend">
|
|
216
|
+
{repos.map((r) => {
|
|
217
|
+
const pctOfTotal =
|
|
218
|
+
totalTokens > 0 ? (r.tokens / totalTokens) * 100 : 0;
|
|
219
|
+
return (
|
|
220
|
+
<div
|
|
221
|
+
key={r.repoRoot ?? "__null__"}
|
|
222
|
+
className="repo-stack-legend-item"
|
|
223
|
+
>
|
|
224
|
+
<span
|
|
225
|
+
className="repo-stack-legend-swatch"
|
|
226
|
+
style={{ backgroundColor: r.color }}
|
|
227
|
+
/>
|
|
228
|
+
<span className="repo-stack-legend-name">{r.basename}</span>
|
|
229
|
+
<span>
|
|
230
|
+
{fmtTokens(r.tokens)} ({pctOfTotal.toFixed(0)}%)
|
|
231
|
+
</span>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
})}
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dashboard-client/src/components/SessionsMemoryChart.tsx — Stacked memory graph (S39).
|
|
3
|
+
*
|
|
4
|
+
* Recharts stacked `<Area>` per session + total `<Line>` + `<Tooltip>` +
|
|
5
|
+
* `<Legend>` + `<ResponsiveContainer>`. Data comes from `/api/sessions/timeseries`
|
|
6
|
+
* which returns `SessionSeries[]` (per-session data points + stable hex color)
|
|
7
|
+
* and a `totals[]` array (sum across all sessions per timestamp).
|
|
8
|
+
*
|
|
9
|
+
* PREVENT-PI-004: recharts is bundled into this localhost-served static
|
|
10
|
+
* dashboard bundle; it makes no runtime network calls. Chart data arrives via
|
|
11
|
+
* same-origin fetch to the dashboard server (loopback, ports 9320–9329).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type React from "react";
|
|
15
|
+
import { useMemo } from "react";
|
|
16
|
+
import {
|
|
17
|
+
ResponsiveContainer,
|
|
18
|
+
AreaChart,
|
|
19
|
+
Area,
|
|
20
|
+
Line,
|
|
21
|
+
XAxis,
|
|
22
|
+
YAxis,
|
|
23
|
+
CartesianGrid,
|
|
24
|
+
Tooltip,
|
|
25
|
+
Legend,
|
|
26
|
+
} from "recharts";
|
|
27
|
+
import type { SessionSeries } from "@contracts";
|
|
28
|
+
|
|
29
|
+
export interface SessionsMemoryChartProps {
|
|
30
|
+
/** Per-session series with stable colors (from /api/sessions/timeseries). */
|
|
31
|
+
series: SessionSeries[];
|
|
32
|
+
/** Totals (sum across all sessions) per timestamp. */
|
|
33
|
+
totals: ReadonlyArray<{ ts: number; tokens: number }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A row in the pivoted data table passed to recharts `<AreaChart>`. */
|
|
37
|
+
interface PivotedRow {
|
|
38
|
+
/** Unix timestamp (ms). */
|
|
39
|
+
ts: number;
|
|
40
|
+
/** Total tokens across all sessions at this timestamp. */
|
|
41
|
+
total: number;
|
|
42
|
+
/** Per-session token count keyed by sessionId. */
|
|
43
|
+
[sessionId: string]: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pivot the per-session series + totals into recharts' expected flat shape:
|
|
48
|
+
* one row per unique timestamp, with each session's token count as a column
|
|
49
|
+
* keyed by sessionId. Missing values for a session at a given timestamp are
|
|
50
|
+
* filled with zero so the stack renders continuously.
|
|
51
|
+
*/
|
|
52
|
+
function pivot(
|
|
53
|
+
series: SessionSeries[],
|
|
54
|
+
totals: ReadonlyArray<{ ts: number; tokens: number }>,
|
|
55
|
+
): PivotedRow[] {
|
|
56
|
+
const map = new Map<number, PivotedRow>();
|
|
57
|
+
const sessionIds = series.map((s) => s.sessionId);
|
|
58
|
+
|
|
59
|
+
for (const s of series) {
|
|
60
|
+
for (const pt of s.data) {
|
|
61
|
+
let row = map.get(pt.ts);
|
|
62
|
+
if (!row) {
|
|
63
|
+
row = { ts: pt.ts, total: 0 } as PivotedRow;
|
|
64
|
+
for (const sid of sessionIds) row[sid] = 0;
|
|
65
|
+
map.set(pt.ts, row);
|
|
66
|
+
}
|
|
67
|
+
row[s.sessionId] = pt.tokens;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const t of totals) {
|
|
71
|
+
let row = map.get(t.ts);
|
|
72
|
+
if (!row) {
|
|
73
|
+
row = { ts: t.ts, total: 0 } as PivotedRow;
|
|
74
|
+
for (const sid of sessionIds) row[sid] = 0;
|
|
75
|
+
map.set(t.ts, row);
|
|
76
|
+
}
|
|
77
|
+
row.total = t.tokens;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return [...map.values()].sort((a, b) => a.ts - b.ts);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function fmtTs(ts: number): string {
|
|
84
|
+
return new Date(ts).toLocaleTimeString([], {
|
|
85
|
+
hour: "2-digit",
|
|
86
|
+
minute: "2-digit",
|
|
87
|
+
second: "2-digit",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function fmtTokens(value: number | string): string {
|
|
92
|
+
const n = Number(value);
|
|
93
|
+
return Number.isFinite(n) ? n.toLocaleString() : String(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Sanitize an arbitrary sessionId for use in an SVG gradient id. */
|
|
97
|
+
function gradId(sessionId: string): string {
|
|
98
|
+
const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
99
|
+
return `grad-${safe}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function SessionsMemoryChart({
|
|
103
|
+
series,
|
|
104
|
+
totals,
|
|
105
|
+
}: SessionsMemoryChartProps): React.ReactElement {
|
|
106
|
+
const data = useMemo(
|
|
107
|
+
() => pivot(series, totals),
|
|
108
|
+
[series, totals],
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
if (series.length === 0) {
|
|
112
|
+
return (
|
|
113
|
+
<div className="sessions-chart-empty">
|
|
114
|
+
No token samples in this window yet.
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div className="sessions-chart">
|
|
121
|
+
<ResponsiveContainer width="100%" height={360}>
|
|
122
|
+
<AreaChart
|
|
123
|
+
data={data}
|
|
124
|
+
margin={{ top: 8, right: 16, left: 0, bottom: 0 }}
|
|
125
|
+
>
|
|
126
|
+
<defs>
|
|
127
|
+
{series.map((s) => (
|
|
128
|
+
<linearGradient
|
|
129
|
+
key={`${s.sessionId}-grad`}
|
|
130
|
+
id={gradId(s.sessionId)}
|
|
131
|
+
x1="0"
|
|
132
|
+
y1="0"
|
|
133
|
+
x2="0"
|
|
134
|
+
y2="1"
|
|
135
|
+
>
|
|
136
|
+
<stop offset="5%" stopColor={s.color} stopOpacity={0.7} />
|
|
137
|
+
<stop offset="95%" stopColor={s.color} stopOpacity={0.25} />
|
|
138
|
+
</linearGradient>
|
|
139
|
+
))}
|
|
140
|
+
</defs>
|
|
141
|
+
<CartesianGrid strokeDasharray="3 3" stroke="#30363d" />
|
|
142
|
+
<XAxis
|
|
143
|
+
dataKey="ts"
|
|
144
|
+
type="number"
|
|
145
|
+
domain={["dataMin", "dataMax"]}
|
|
146
|
+
tickFormatter={(ts: number) => fmtTs(ts)}
|
|
147
|
+
stroke="#8b949e"
|
|
148
|
+
tick={{ fontSize: 11 }}
|
|
149
|
+
scale="time"
|
|
150
|
+
/>
|
|
151
|
+
<YAxis
|
|
152
|
+
stroke="#8b949e"
|
|
153
|
+
tick={{ fontSize: 11 }}
|
|
154
|
+
width={56}
|
|
155
|
+
tickFormatter={(v: number) => fmtTokens(v)}
|
|
156
|
+
/>
|
|
157
|
+
<Tooltip
|
|
158
|
+
labelFormatter={(ts: number) => fmtTs(ts)}
|
|
159
|
+
formatter={(value) =>
|
|
160
|
+
[fmtTokens(value as number), "tokens"] as [string, string]
|
|
161
|
+
}
|
|
162
|
+
contentStyle={{
|
|
163
|
+
background: "#161b22",
|
|
164
|
+
border: "1px solid #30363d",
|
|
165
|
+
borderRadius: "6px",
|
|
166
|
+
color: "#e6edf3",
|
|
167
|
+
}}
|
|
168
|
+
labelStyle={{ color: "#8b949e", fontSize: 12 }}
|
|
169
|
+
/>
|
|
170
|
+
<Legend wrapperStyle={{ fontSize: 12 }} />
|
|
171
|
+
{series.map((s) => (
|
|
172
|
+
<Area
|
|
173
|
+
key={s.sessionId}
|
|
174
|
+
type="monotone"
|
|
175
|
+
dataKey={s.sessionId}
|
|
176
|
+
name={s.label}
|
|
177
|
+
stackId="1"
|
|
178
|
+
stroke={s.color}
|
|
179
|
+
fill={`url(#${gradId(s.sessionId)})`}
|
|
180
|
+
fillOpacity={1}
|
|
181
|
+
isAnimationActive={false}
|
|
182
|
+
dot={false}
|
|
183
|
+
/>
|
|
184
|
+
))}
|
|
185
|
+
<Line
|
|
186
|
+
type="monotone"
|
|
187
|
+
dataKey="total"
|
|
188
|
+
name="Total"
|
|
189
|
+
stroke="#e6edf3"
|
|
190
|
+
strokeWidth={1.5}
|
|
191
|
+
strokeDasharray="4 2"
|
|
192
|
+
dot={false}
|
|
193
|
+
isAnimationActive={false}
|
|
194
|
+
/>
|
|
195
|
+
</AreaChart>
|
|
196
|
+
</ResponsiveContainer>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -15,6 +15,8 @@ import "./styles/overview-extra.css";
|
|
|
15
15
|
import "./styles/config.css";
|
|
16
16
|
import "./styles/metrics-extra.css";
|
|
17
17
|
import "./styles/game-achievements.css";
|
|
18
|
+
import "./styles/sessions.css";
|
|
19
|
+
import "./styles/repostack.css";
|
|
18
20
|
|
|
19
21
|
const rootEl = document.getElementById("root");
|
|
20
22
|
if (!rootEl) {
|