pi-mega-compact 0.15.0 → 0.15.2

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 (57) hide show
  1. package/dist/extensions/dashboard-server/resolve-snapshot.js +20 -8
  2. package/dist/src/store/sqlite/global-sessions.js +40 -7
  3. package/extensions/dashboard-client/dist/assets/{AreaChart-rJdaOUlj.js → AreaChart-D0hDLKC1.js} +2 -2
  4. package/extensions/dashboard-client/dist/assets/{AreaChart-rJdaOUlj.js.map → AreaChart-D0hDLKC1.js.map} +1 -1
  5. package/extensions/dashboard-client/dist/assets/{BarChart-DOfc8me9.js → BarChart-DSbTWZTQ.js} +2 -2
  6. package/extensions/dashboard-client/dist/assets/{BarChart-DOfc8me9.js.map → BarChart-DSbTWZTQ.js.map} +1 -1
  7. package/extensions/dashboard-client/dist/assets/{CacheTab-EhqkEglo.js → CacheTab-Bgt2Wuar.js} +2 -2
  8. package/extensions/dashboard-client/dist/assets/{CacheTab-EhqkEglo.js.map → CacheTab-Bgt2Wuar.js.map} +1 -1
  9. package/extensions/dashboard-client/dist/assets/{EventsTab-BUACwWXf.js → EventsTab-CBb7O-6M.js} +2 -2
  10. package/extensions/dashboard-client/dist/assets/{EventsTab-BUACwWXf.js.map → EventsTab-CBb7O-6M.js.map} +1 -1
  11. package/extensions/dashboard-client/dist/assets/{HealthTab-CDEYVRBb.js → HealthTab-BFMHwnlg.js} +2 -2
  12. package/extensions/dashboard-client/dist/assets/{HealthTab-CDEYVRBb.js.map → HealthTab-BFMHwnlg.js.map} +1 -1
  13. package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DtanIeCQ.js → MaintenanceTab-DxIQyeh-.js} +2 -2
  14. package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DtanIeCQ.js.map → MaintenanceTab-DxIQyeh-.js.map} +1 -1
  15. package/extensions/dashboard-client/dist/assets/{MemoryMapTab-BGEhL7OG.js → MemoryMapTab-C576-nmM.js} +2 -2
  16. package/extensions/dashboard-client/dist/assets/{MemoryMapTab-BGEhL7OG.js.map → MemoryMapTab-C576-nmM.js.map} +1 -1
  17. package/extensions/dashboard-client/dist/assets/{MetricsTab-Cktozn4s.js → MetricsTab-vk8Nqg7k.js} +2 -2
  18. package/extensions/dashboard-client/dist/assets/{MetricsTab-Cktozn4s.js.map → MetricsTab-vk8Nqg7k.js.map} +1 -1
  19. package/extensions/dashboard-client/dist/assets/OverviewTab-BVaddBS4.js +16 -0
  20. package/extensions/dashboard-client/dist/assets/OverviewTab-BVaddBS4.js.map +1 -0
  21. package/extensions/dashboard-client/dist/assets/{ReposTab-Bg72Ozdc.js → ReposTab-CLCd-Gfd.js} +2 -2
  22. package/extensions/dashboard-client/dist/assets/{ReposTab-Bg72Ozdc.js.map → ReposTab-CLCd-Gfd.js.map} +1 -1
  23. package/extensions/dashboard-client/dist/assets/{SessionsTab-BcTK9Lg6.js → SessionsTab-CV_qDKZB.js} +2 -2
  24. package/extensions/dashboard-client/dist/assets/{SessionsTab-BcTK9Lg6.js.map → SessionsTab-CV_qDKZB.js.map} +1 -1
  25. package/extensions/dashboard-client/dist/assets/{SetupTab-Bt-Ru1xp.js → SetupTab-BuIwk7LZ.js} +2 -2
  26. package/extensions/dashboard-client/dist/assets/{SetupTab-Bt-Ru1xp.js.map → SetupTab-BuIwk7LZ.js.map} +1 -1
  27. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-ic_LjJ79.js → TimeSavedCard-BS5-w4mP.js} +2 -2
  28. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-ic_LjJ79.js.map → TimeSavedCard-BS5-w4mP.js.map} +1 -1
  29. package/extensions/dashboard-client/dist/assets/{TurnsTab-D9AZTMgG.js → TurnsTab-DhgjZmEQ.js} +2 -2
  30. package/extensions/dashboard-client/dist/assets/{TurnsTab-D9AZTMgG.js.map → TurnsTab-DhgjZmEQ.js.map} +1 -1
  31. package/extensions/dashboard-client/dist/assets/{WikiTab-hRcJPi_m.js → WikiTab-CmOAaPnM.js} +2 -2
  32. package/extensions/dashboard-client/dist/assets/{WikiTab-hRcJPi_m.js.map → WikiTab-CmOAaPnM.js.map} +1 -1
  33. package/extensions/dashboard-client/dist/assets/{button-BdAtIbVu.js → button-DyV48WDX.js} +2 -2
  34. package/extensions/dashboard-client/dist/assets/{button-BdAtIbVu.js.map → button-DyV48WDX.js.map} +1 -1
  35. package/extensions/dashboard-client/dist/assets/{card-BFYc_QrK.js → card-CfCQ36pG.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{card-BFYc_QrK.js.map → card-CfCQ36pG.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/{generateCategoricalChart-BwzyqytT.js → generateCategoricalChart-CGlLggNu.js} +2 -2
  38. package/extensions/dashboard-client/dist/assets/{generateCategoricalChart-BwzyqytT.js.map → generateCategoricalChart-CGlLggNu.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/{index-pmtEr3sb.js → index-tPP7N38D.js} +28 -28
  40. package/extensions/dashboard-client/dist/assets/index-tPP7N38D.js.map +1 -0
  41. package/extensions/dashboard-client/dist/assets/{switch-DEyGlFwJ.js → switch-7461qYip.js} +2 -2
  42. package/extensions/dashboard-client/dist/assets/{switch-DEyGlFwJ.js.map → switch-7461qYip.js.map} +1 -1
  43. package/extensions/dashboard-client/dist/assets/{toggle-pDMBQYQj.js → toggle-CHyUS1MH.js} +2 -2
  44. package/extensions/dashboard-client/dist/assets/{toggle-pDMBQYQj.js.map → toggle-CHyUS1MH.js.map} +1 -1
  45. package/extensions/dashboard-client/dist/assets/{useSSE-BKaC4dao.js → useSSE-CPIU9hax.js} +2 -2
  46. package/extensions/dashboard-client/dist/assets/{useSSE-BKaC4dao.js.map → useSSE-CPIU9hax.js.map} +1 -1
  47. package/extensions/dashboard-client/dist/index.html +1 -1
  48. package/extensions/dashboard-client/package-lock.json +0 -39
  49. package/extensions/dashboard-client/src/components/SessionContextGauges.tsx +43 -19
  50. package/extensions/dashboard-client/src/hooks/useApi.ts +38 -5
  51. package/extensions/dashboard-client/src/tabs/OverviewTab.tsx +32 -16
  52. package/extensions/dashboard-server/resolve-snapshot.ts +22 -10
  53. package/package.json +1 -1
  54. package/src/store/sqlite/global-sessions.ts +205 -175
  55. package/extensions/dashboard-client/dist/assets/OverviewTab-08v6Ayuf.js +0 -16
  56. package/extensions/dashboard-client/dist/assets/OverviewTab-08v6Ayuf.js.map +0 -1
  57. package/extensions/dashboard-client/dist/assets/index-pmtEr3sb.js.map +0 -1
@@ -18,56 +18,56 @@ import { openIndexStore, getIndexDir } from "./global-index.js";
18
18
 
19
19
  /** A live active session row (joined heartbeat + latest token sample). */
20
20
  export interface ActiveSessionRow {
21
- pid: number;
22
- sessionId: string;
23
- repoRoot: string | null;
24
- stateDir: string | null;
25
- ctxWindow: number;
26
- lastSeen: number;
27
- tokens: number | null;
28
- percent: number | null;
21
+ pid: number;
22
+ sessionId: string;
23
+ repoRoot: string | null;
24
+ stateDir: string | null;
25
+ ctxWindow: number;
26
+ lastSeen: number;
27
+ tokens: number | null;
28
+ percent: number | null;
29
29
  }
30
30
 
31
31
  /** A single time-series data point for a session. */
32
32
  export interface TokenSamplePoint {
33
- ts: number;
34
- tokens: number;
35
- percent: number;
33
+ ts: number;
34
+ tokens: number;
35
+ percent: number;
36
36
  }
37
37
 
38
38
  /** A recharts-ready per-session series with a stable color. */
39
39
  export interface SessionSeries {
40
- sessionId: string;
41
- label: string;
42
- color: string;
43
- data: TokenSamplePoint[];
40
+ sessionId: string;
41
+ label: string;
42
+ color: string;
43
+ data: TokenSamplePoint[];
44
44
  }
45
45
 
46
46
  /** Timeseries response shape (recharts-ready). */
47
47
  export interface SessionTimeseriesResult {
48
- series: SessionSeries[];
49
- totals: { ts: number; tokens: number }[];
48
+ series: SessionSeries[];
49
+ totals: { ts: number; tokens: number }[];
50
50
  }
51
51
 
52
52
  /** Stable color palette for per-session series (hash-based, no randomness). */
53
53
  const SESSION_COLORS = [
54
- "#60a5fa", // blue-400
55
- "#34d399", // emerald-400
56
- "#fbbf24", // amber-400
57
- "#f87171", // red-400
58
- "#a78bfa", // violet-400
59
- "#f472b6", // pink-400
60
- "#22d3ee", // cyan-400
61
- "#a3e635", // lime-400
54
+ "#60a5fa", // blue-400
55
+ "#34d399", // emerald-400
56
+ "#fbbf24", // amber-400
57
+ "#f87171", // red-400
58
+ "#a78bfa", // violet-400
59
+ "#f472b6", // pink-400
60
+ "#22d3ee", // cyan-400
61
+ "#a3e635", // lime-400
62
62
  ];
63
63
 
64
64
  /** Hash a sessionId to a stable color index. */
65
65
  function sessionColor(sessionId: string): string {
66
- let h = 0;
67
- for (let i = 0; i < sessionId.length; i++) {
68
- h = (h * 31 + sessionId.charCodeAt(i)) | 0;
69
- }
70
- return SESSION_COLORS[Math.abs(h) % SESSION_COLORS.length];
66
+ let h = 0;
67
+ for (let i = 0; i < sessionId.length; i++) {
68
+ h = (h * 31 + sessionId.charCodeAt(i)) | 0;
69
+ }
70
+ return SESSION_COLORS[Math.abs(h) % SESSION_COLORS.length];
71
71
  }
72
72
 
73
73
  /**
@@ -76,31 +76,42 @@ function sessionColor(sessionId: string): string {
76
76
  * their own row. Non-fatal on conflict (INSERT ... ON CONFLICT DO UPDATE).
77
77
  */
78
78
  export function recordSessionHeartbeat(
79
- pid: number,
80
- sessionId: string,
81
- repoRoot: string,
82
- stateDir: string,
83
- ctxWindow: number,
84
- indexDir: string = getIndexDir(),
79
+ pid: number,
80
+ sessionId: string,
81
+ repoRoot: string,
82
+ stateDir: string,
83
+ ctxWindow: number,
84
+ indexDir: string = getIndexDir(),
85
85
  ): void {
86
- const db = openIndexStore(indexDir);
87
- const now = Date.now();
88
- db.prepare(
89
- `INSERT INTO session_heartbeats (pid, session_id, repo_root, state_dir, ctx_window, last_seen)
86
+ const db = openIndexStore(indexDir);
87
+ const now = Date.now();
88
+ db.prepare(
89
+ `INSERT INTO session_heartbeats (pid, session_id, repo_root, state_dir, ctx_window, last_seen)
90
90
  VALUES (@pid, @session_id, @repo_root, @state_dir, @ctx_window, @last_seen)
91
91
  ON CONFLICT(pid, session_id) DO UPDATE SET
92
92
  repo_root = excluded.repo_root,
93
93
  state_dir = excluded.state_dir,
94
94
  ctx_window = excluded.ctx_window,
95
95
  last_seen = excluded.last_seen`,
96
- ).run({
97
- pid,
98
- session_id: sessionId,
99
- repo_root: repoRoot,
100
- state_dir: stateDir,
101
- ctx_window: ctxWindow,
102
- last_seen: now,
103
- });
96
+ ).run({
97
+ pid,
98
+ session_id: sessionId,
99
+ repo_root: repoRoot,
100
+ state_dir: stateDir,
101
+ ctx_window: ctxWindow,
102
+ last_seen: now,
103
+ });
104
+ // Enforce the invariant: one heartbeat row per live pi PROCESS (pid).
105
+ // A pi process owns exactly one session_id at a time — when it restarts /
106
+ // resumes into a new session_id, the OLD (pid, old_session_id) row is stale
107
+ // and must be deleted, otherwise it lingers (its last_seen was updated by
108
+ // the prior heartbeat, so the 30-min prune won't catch it) and surfaces as
109
+ // a phantom 0% "pi-<repo>" gauge in the dashboard's "Context per Session"
110
+ // card (null tokens, dead session_id that no longer matches the launcher).
111
+ // Parameterized (PREVENT-002).
112
+ db.prepare(
113
+ `DELETE FROM session_heartbeats WHERE pid = @pid AND session_id != @session_id`,
114
+ ).run({ pid, session_id: sessionId });
104
115
  }
105
116
 
106
117
  /**
@@ -109,54 +120,56 @@ export function recordSessionHeartbeat(
109
120
  * callers without an events.log (e.g. tests) can omit it.
110
121
  */
111
122
  export function appendTokenSample(
112
- sessionId: string,
113
- repoRoot: string,
114
- tokens: number,
115
- percent: number,
116
- ctxWindow: number,
117
- eventsLogPath: string | null,
118
- indexDir: string = getIndexDir(),
123
+ sessionId: string,
124
+ repoRoot: string,
125
+ tokens: number,
126
+ percent: number,
127
+ ctxWindow: number,
128
+ eventsLogPath: string | null,
129
+ indexDir: string = getIndexDir(),
119
130
  ): void {
120
- const db = openIndexStore(indexDir);
121
- const now = Date.now();
122
- db.prepare(
123
- `INSERT INTO token_samples (session_id, repo_root, tokens, percent, ctx_window, ts)
131
+ const db = openIndexStore(indexDir);
132
+ const now = Date.now();
133
+ db.prepare(
134
+ `INSERT INTO token_samples (session_id, repo_root, tokens, percent, ctx_window, ts)
124
135
  VALUES (@session_id, @repo_root, @tokens, @percent, @ctx_window, @ts)`,
125
- ).run({
126
- session_id: sessionId,
127
- repo_root: repoRoot,
128
- tokens,
129
- percent,
130
- ctx_window: ctxWindow,
131
- ts: now,
132
- });
133
- // Step 5: also append a session_sample JSON line to events.log so the
134
- // existing /api/events SSE tail streams it for free (real-time chart push).
135
- // Mirrors the DashboardEmitter events.log append pattern in
136
- // extensions/mega-dashboard.ts:{ event(type, data) }: a JSON object with
137
- // `ts` (ISO 8601 string), `type`, and the event-specific payload. The ISO
138
- // timestamp matches the shape of every other SSE variant (SseSessionSample
139
- // contract; every SSE variant's `ts` is a string); a numeric ms `ts` would
140
- // violate the contract union's "every SSE variant has a ts field of type
141
- // string" invariant and break DashboardEmitter consumers.
142
- if (eventsLogPath) {
143
- try {
144
- const dir = eventsLogPath.includes("/") ? eventsLogPath.slice(0, eventsLogPath.lastIndexOf("/")) : ".";
145
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
146
- appendFileSync(
147
- eventsLogPath,
148
- JSON.stringify({
149
- ts: new Date(now).toISOString(),
150
- type: "session_sample",
151
- sessionId,
152
- tokens,
153
- percent,
154
- }) + "\n",
155
- );
156
- } catch {
157
- /* non-fatal: SSE push is best-effort */
158
- }
159
- }
136
+ ).run({
137
+ session_id: sessionId,
138
+ repo_root: repoRoot,
139
+ tokens,
140
+ percent,
141
+ ctx_window: ctxWindow,
142
+ ts: now,
143
+ });
144
+ // Step 5: also append a session_sample JSON line to events.log so the
145
+ // existing /api/events SSE tail streams it for free (real-time chart push).
146
+ // Mirrors the DashboardEmitter events.log append pattern in
147
+ // extensions/mega-dashboard.ts:{ event(type, data) }: a JSON object with
148
+ // `ts` (ISO 8601 string), `type`, and the event-specific payload. The ISO
149
+ // timestamp matches the shape of every other SSE variant (SseSessionSample
150
+ // contract; every SSE variant's `ts` is a string); a numeric ms `ts` would
151
+ // violate the contract union's "every SSE variant has a ts field of type
152
+ // string" invariant and break DashboardEmitter consumers.
153
+ if (eventsLogPath) {
154
+ try {
155
+ const dir = eventsLogPath.includes("/")
156
+ ? eventsLogPath.slice(0, eventsLogPath.lastIndexOf("/"))
157
+ : ".";
158
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
159
+ appendFileSync(
160
+ eventsLogPath,
161
+ JSON.stringify({
162
+ ts: new Date(now).toISOString(),
163
+ type: "session_sample",
164
+ sessionId,
165
+ tokens,
166
+ percent,
167
+ }) + "\n",
168
+ );
169
+ } catch {
170
+ /* non-fatal: SSE push is best-effort */
171
+ }
172
+ }
160
173
  }
161
174
 
162
175
  /**
@@ -164,15 +177,15 @@ export function appendTokenSample(
164
177
  * Default 30-min retention. Called by /api/sessions.
165
178
  */
166
179
  export function pruneStaleSessions(
167
- maxAgeMs: number = 1_800_000,
168
- indexDir: string = getIndexDir(),
180
+ maxAgeMs: number = 1_800_000,
181
+ indexDir: string = getIndexDir(),
169
182
  ): number {
170
- const db = openIndexStore(indexDir);
171
- const cutoff = Date.now() - maxAgeMs;
172
- const result = db.prepare(
173
- "DELETE FROM session_heartbeats WHERE last_seen < @cutoff",
174
- ).run({ cutoff });
175
- return Number(result.changes);
183
+ const db = openIndexStore(indexDir);
184
+ const cutoff = Date.now() - maxAgeMs;
185
+ const result = db
186
+ .prepare("DELETE FROM session_heartbeats WHERE last_seen < @cutoff")
187
+ .run({ cutoff });
188
+ return Number(result.changes);
176
189
  }
177
190
 
178
191
  /**
@@ -180,15 +193,15 @@ export function pruneStaleSessions(
180
193
  * Called by /api/sessions/timeseries.
181
194
  */
182
195
  export function pruneTokenSamples(
183
- maxAgeMs: number = 1_800_000,
184
- indexDir: string = getIndexDir(),
196
+ maxAgeMs: number = 1_800_000,
197
+ indexDir: string = getIndexDir(),
185
198
  ): number {
186
- const db = openIndexStore(indexDir);
187
- const cutoff = Date.now() - maxAgeMs;
188
- const result = db.prepare(
189
- "DELETE FROM token_samples WHERE ts < @cutoff",
190
- ).run({ cutoff });
191
- return Number(result.changes);
199
+ const db = openIndexStore(indexDir);
200
+ const cutoff = Date.now() - maxAgeMs;
201
+ const result = db
202
+ .prepare("DELETE FROM token_samples WHERE ts < @cutoff")
203
+ .run({ cutoff });
204
+ return Number(result.changes);
192
205
  }
193
206
 
194
207
  /**
@@ -197,11 +210,12 @@ export function pruneTokenSamples(
197
210
  * via a correlated subquery. Returns rows sorted by last_seen descending.
198
211
  */
199
212
  export function readActiveSessions(
200
- indexDir: string = getIndexDir(),
213
+ indexDir: string = getIndexDir(),
201
214
  ): ActiveSessionRow[] {
202
- const db = openIndexStore(indexDir);
203
- const rows = db.prepare(
204
- `SELECT h.pid, h.session_id, h.repo_root, h.state_dir, h.ctx_window, h.last_seen,
215
+ const db = openIndexStore(indexDir);
216
+ const rows = db
217
+ .prepare(
218
+ `SELECT h.pid, h.session_id, h.repo_root, h.state_dir, h.ctx_window, h.last_seen,
205
219
  s.tokens, s.percent
206
220
  FROM session_heartbeats h
207
221
  LEFT JOIN token_samples s ON s.id = (
@@ -210,26 +224,40 @@ export function readActiveSessions(
210
224
  ORDER BY t.ts DESC LIMIT 1
211
225
  )
212
226
  ORDER BY h.last_seen DESC`,
213
- ).all() as Array<{
214
- pid: number;
215
- session_id: string;
216
- repo_root: string | null;
217
- state_dir: string | null;
218
- ctx_window: number;
219
- last_seen: number;
220
- tokens: number | null;
221
- percent: number | null;
222
- }>;
223
- return rows.map((r) => ({
224
- pid: r.pid,
225
- sessionId: r.session_id,
226
- repoRoot: r.repo_root,
227
- stateDir: r.state_dir,
228
- ctxWindow: r.ctx_window ?? 0,
229
- lastSeen: r.last_seen,
230
- tokens: r.tokens,
231
- percent: r.percent,
232
- }));
227
+ )
228
+ .all() as Array<{
229
+ pid: number;
230
+ session_id: string;
231
+ repo_root: string | null;
232
+ state_dir: string | null;
233
+ ctx_window: number;
234
+ last_seen: number;
235
+ tokens: number | null;
236
+ percent: number | null;
237
+ }>;
238
+ // Dedup BY PID (one row per live pi process, keeping the most-recently-seen
239
+ // row). The schema PK is (pid, session_id), so a process that restarted /
240
+ // resumed into a new session_id leaves a stale (pid, old_session_id) row
241
+ // until recordSessionHeartbeat's cleanup fires or the 30-min prune runs.
242
+ // Without this dedup those stale rows surface as phantom 0% gauges in the
243
+ // "Context per Session" card (null tokens, dead session_id). Rows are
244
+ // already ordered by last_seen DESC, so the first row seen for a pid wins.
245
+ const seenPid = new Set<number>();
246
+ const deduped = rows.filter((r) => {
247
+ if (seenPid.has(r.pid)) return false;
248
+ seenPid.add(r.pid);
249
+ return true;
250
+ });
251
+ return deduped.map((r) => ({
252
+ pid: r.pid,
253
+ sessionId: r.session_id,
254
+ repoRoot: r.repo_root,
255
+ stateDir: r.state_dir,
256
+ ctxWindow: r.ctx_window ?? 0,
257
+ lastSeen: r.last_seen,
258
+ tokens: r.tokens,
259
+ percent: r.percent,
260
+ }));
233
261
  }
234
262
 
235
263
  /**
@@ -238,41 +266,43 @@ export function readActiveSessions(
238
266
  * [{ts, tokens}] (sum of all sessions at each timestamp).
239
267
  */
240
268
  export function readSessionTimeseries(
241
- sinceMs: number,
242
- indexDir: string = getIndexDir(),
269
+ sinceMs: number,
270
+ indexDir: string = getIndexDir(),
243
271
  ): SessionTimeseriesResult {
244
- const db = openIndexStore(indexDir);
245
- const rows = db.prepare(
246
- `SELECT session_id, tokens, percent, ts FROM token_samples WHERE ts >= @since ORDER BY ts ASC`,
247
- ).all({ since: sinceMs }) as Array<{
248
- session_id: string;
249
- tokens: number;
250
- percent: number;
251
- ts: number;
252
- }>;
253
- // Group by session_id → series; + accumulate totals per timestamp.
254
- const seriesMap = new Map<string, TokenSamplePoint[]>();
255
- const totalsMap = new Map<number, number>();
256
- for (const r of rows) {
257
- let pts = seriesMap.get(r.session_id);
258
- if (!pts) {
259
- pts = [];
260
- seriesMap.set(r.session_id, pts);
261
- }
262
- pts.push({ ts: r.ts, tokens: r.tokens, percent: r.percent });
263
- totalsMap.set(r.ts, (totalsMap.get(r.ts) ?? 0) + r.tokens);
264
- }
265
- const series: SessionSeries[] = [];
266
- for (const [sessionId, data] of seriesMap) {
267
- const label = sessionId.length > 12 ? sessionId.slice(0, 12) : sessionId;
268
- series.push({ sessionId, label, color: sessionColor(sessionId), data });
269
- }
270
- // Sort series by first-timestamp for stable legend order.
271
- series.sort((a, b) => (a.data[0]?.ts ?? 0) - (b.data[0]?.ts ?? 0));
272
- const totals = Array.from(totalsMap.entries())
273
- .sort((a, b) => a[0] - b[0])
274
- .map(([ts, tokens]) => ({ ts, tokens }));
275
- return { series, totals };
272
+ const db = openIndexStore(indexDir);
273
+ const rows = db
274
+ .prepare(
275
+ `SELECT session_id, tokens, percent, ts FROM token_samples WHERE ts >= @since ORDER BY ts ASC`,
276
+ )
277
+ .all({ since: sinceMs }) as Array<{
278
+ session_id: string;
279
+ tokens: number;
280
+ percent: number;
281
+ ts: number;
282
+ }>;
283
+ // Group by session_id series; + accumulate totals per timestamp.
284
+ const seriesMap = new Map<string, TokenSamplePoint[]>();
285
+ const totalsMap = new Map<number, number>();
286
+ for (const r of rows) {
287
+ let pts = seriesMap.get(r.session_id);
288
+ if (!pts) {
289
+ pts = [];
290
+ seriesMap.set(r.session_id, pts);
291
+ }
292
+ pts.push({ ts: r.ts, tokens: r.tokens, percent: r.percent });
293
+ totalsMap.set(r.ts, (totalsMap.get(r.ts) ?? 0) + r.tokens);
294
+ }
295
+ const series: SessionSeries[] = [];
296
+ for (const [sessionId, data] of seriesMap) {
297
+ const label = sessionId.length > 12 ? sessionId.slice(0, 12) : sessionId;
298
+ series.push({ sessionId, label, color: sessionColor(sessionId), data });
299
+ }
300
+ // Sort series by first-timestamp for stable legend order.
301
+ series.sort((a, b) => (a.data[0]?.ts ?? 0) - (b.data[0]?.ts ?? 0));
302
+ const totals = Array.from(totalsMap.entries())
303
+ .sort((a, b) => a[0] - b[0])
304
+ .map(([ts, tokens]) => ({ ts, tokens }));
305
+ return { series, totals };
276
306
  }
277
307
 
278
308
  /**
@@ -280,12 +310,12 @@ export function readSessionTimeseries(
280
310
  * Non-fatal: no-op if the row doesn't exist.
281
311
  */
282
312
  export function clearSessionHeartbeat(
283
- pid: number,
284
- sessionId: string,
285
- indexDir: string = getIndexDir(),
313
+ pid: number,
314
+ sessionId: string,
315
+ indexDir: string = getIndexDir(),
286
316
  ): void {
287
- const db = openIndexStore(indexDir);
288
- db.prepare(
289
- "DELETE FROM session_heartbeats WHERE pid = @pid AND session_id = @session_id",
290
- ).run({ pid, session_id: sessionId });
317
+ const db = openIndexStore(indexDir);
318
+ db.prepare(
319
+ "DELETE FROM session_heartbeats WHERE pid = @pid AND session_id = @session_id",
320
+ ).run({ pid, session_id: sessionId });
291
321
  }