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.
Files changed (93) 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-events/context-handler.js +22 -8
  16. package/dist/extensions/mega-runtime/state.js +17 -1
  17. package/dist/src/store/sqlite/connection.js +35 -0
  18. package/dist/src/store/sqlite/global-index.js +199 -1
  19. package/dist/src/store/sqlite/global-index.test.js +136 -0
  20. package/dist/src/store/sqlite/index-store.js +167 -0
  21. package/dist/src/store/sqlite/memory.js +54 -0
  22. package/dist/src/store/sqlite/minhash-lsh.js +47 -0
  23. package/dist/src/store/sqlite/sessions.js +39 -0
  24. package/dist/src/store/sqlite/transaction.js +19 -0
  25. package/dist/src/vectorStore/add.js +260 -0
  26. package/dist/src/vectorStore/dedup.js +52 -0
  27. package/dist/src/vectorStore/index.js +10 -0
  28. package/dist/src/vectorStore/queries.js +83 -0
  29. package/dist/src/vectorStore/search.js +95 -0
  30. package/dist/src/vectorStore/session.js +19 -0
  31. package/dist/src/vectorStore/store.js +105 -0
  32. package/dist/src/vectorStore/types.js +6 -0
  33. package/dist/src/vectorStore/utils.js +23 -0
  34. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js → AchievementTiles-DVgFowc-.js} +2 -2
  35. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BozP_h3c.js.map → AchievementTiles-DVgFowc-.js.map} +1 -1
  36. package/extensions/dashboard-client/dist/assets/AchievementsTab-Bg86TWPU.js +2 -0
  37. package/extensions/dashboard-client/dist/assets/{AchievementsTab-DKgqx1RQ.js.map → AchievementsTab-Bg86TWPU.js.map} +1 -1
  38. package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js → CacheTab-DBzoH_q_.js} +2 -2
  39. package/extensions/dashboard-client/dist/assets/{CacheTab-CPvso4-0.js.map → CacheTab-DBzoH_q_.js.map} +1 -1
  40. package/extensions/dashboard-client/dist/assets/{ConfigTab-DXD7zbqy.js → ConfigTab-Bj12LgTh.js} +2 -2
  41. package/extensions/dashboard-client/dist/assets/ConfigTab-Bj12LgTh.js.map +1 -0
  42. package/extensions/dashboard-client/dist/assets/EventsTab-EC0wMsp_.js +2 -0
  43. package/extensions/dashboard-client/dist/assets/EventsTab-EC0wMsp_.js.map +1 -0
  44. package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js → GameTab-CISQitbu.js} +2 -2
  45. package/extensions/dashboard-client/dist/assets/{GameTab-Qj19W1Y-.js.map → GameTab-CISQitbu.js.map} +1 -1
  46. package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js → MetricsTab-us4vVG5D.js} +2 -2
  47. package/extensions/dashboard-client/dist/assets/{MetricsTab-DA27vA95.js.map → MetricsTab-us4vVG5D.js.map} +1 -1
  48. package/extensions/dashboard-client/dist/assets/OverviewTab-eJ7vFgBE.js +2 -0
  49. package/extensions/dashboard-client/dist/assets/OverviewTab-eJ7vFgBE.js.map +1 -0
  50. package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js → ReposTab-CiU9AJkb.js} +2 -2
  51. package/extensions/dashboard-client/dist/assets/{ReposTab-_HNR3qYj.js.map → ReposTab-CiU9AJkb.js.map} +1 -1
  52. package/extensions/dashboard-client/dist/assets/SessionsTab-DaCUvTC7.js +70 -0
  53. package/extensions/dashboard-client/dist/assets/SessionsTab-DaCUvTC7.js.map +1 -0
  54. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js → TimeSavedCard-BAUSCzq5.js} +2 -2
  55. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B7aPcBMs.js.map → TimeSavedCard-BAUSCzq5.js.map} +1 -1
  56. package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js → index-5MPZyayK.js} +10 -10
  57. package/extensions/dashboard-client/dist/assets/{index-B2JJExE9.js.map → index-5MPZyayK.js.map} +1 -1
  58. package/extensions/dashboard-client/dist/assets/{index-Cfxniu2R.css → index-CDkmYP9l.css} +1 -1
  59. package/extensions/dashboard-client/dist/assets/useSSE-BSpmWZZi.js +2 -0
  60. package/extensions/dashboard-client/dist/assets/useSSE-BSpmWZZi.js.map +1 -0
  61. package/extensions/dashboard-client/dist/index.html +2 -2
  62. package/extensions/dashboard-client/package-lock.json +374 -2
  63. package/extensions/dashboard-client/package.json +2 -1
  64. package/extensions/dashboard-client/src/App.tsx +5 -1
  65. package/extensions/dashboard-client/src/api/client.ts +12 -0
  66. package/extensions/dashboard-client/src/components/ActiveSessionsTable.tsx +185 -0
  67. package/extensions/dashboard-client/src/components/EventStream.tsx +3 -0
  68. package/extensions/dashboard-client/src/components/RepoContextStack.tsx +238 -0
  69. package/extensions/dashboard-client/src/components/SessionsMemoryChart.tsx +199 -0
  70. package/extensions/dashboard-client/src/main.tsx +2 -0
  71. package/extensions/dashboard-client/src/styles/repostack.css +87 -0
  72. package/extensions/dashboard-client/src/styles/sessions.css +193 -0
  73. package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +6 -3
  74. package/extensions/dashboard-client/src/tabs/OverviewTab.tsx +49 -1
  75. package/extensions/dashboard-client/src/tabs/SessionsTab.tsx +193 -0
  76. package/extensions/dashboard-server/api-contracts/core.ts +25 -0
  77. package/extensions/dashboard-server/api-contracts/endpoints.ts +102 -0
  78. package/extensions/dashboard-server/api-contracts/index.ts +10 -1
  79. package/extensions/dashboard-server/api-contracts.test.ts +20 -7
  80. package/extensions/dashboard-server/server.ts +100 -0
  81. package/extensions/mega-events/context-handler.ts +40 -18
  82. package/extensions/mega-runtime/state.ts +31 -0
  83. package/package.json +1 -1
  84. package/src/store/sqlite/global-index.test.ts +222 -0
  85. package/src/store/sqlite/global-index.ts +316 -1
  86. package/dist/extensions/dashboard-client/src/hooks/useApi.js +0 -51
  87. package/dist/extensions/dashboard-client/src/hooks/useSSE.js +0 -63
  88. package/extensions/dashboard-client/dist/assets/AchievementsTab-DKgqx1RQ.js +0 -2
  89. package/extensions/dashboard-client/dist/assets/ConfigTab-DXD7zbqy.js.map +0 -1
  90. package/extensions/dashboard-client/dist/assets/EventsTab-BGeuYvM3.js +0 -2
  91. package/extensions/dashboard-client/dist/assets/EventsTab-BGeuYvM3.js.map +0 -1
  92. package/extensions/dashboard-client/dist/assets/OverviewTab-lVGR1Jl_.js +0 -2
  93. 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) {